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
This commit is contained in:
Vincent Koc
2026-04-26 01:41:57 -07:00
committed by GitHub
parent 218636a0ea
commit 2652c9eacf
13 changed files with 353 additions and 98 deletions

View File

@@ -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.

View File

@@ -188,6 +188,7 @@ function formatThreadBindingDurationForConfigLabel(durationMs: number): string {
async function appendPluginCommandSpecs(params: {
commandSpecs: NativeCommandSpec[];
runtime: RuntimeEnv;
cfg: OpenClawConfig;
}): Promise<NativeCommandSpec[]> {
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.`,

View File

@@ -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<string, unknown>, normalizedChannelId)
: undefined;

View File

@@ -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({

View File

@@ -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<NonNullable<OpenClawConfig["tools"]>["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<string, unknown> | 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<string, unknown> | undefined),
},
};
}
}

View File

@@ -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["']/);
});
});

View File

@@ -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", () => {

View File

@@ -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;

View File

@@ -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) };
}

View File

@@ -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;

View File

@@ -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..." },

View File

@@ -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<string, unknown>).polluted).toBeUndefined();
});
it("returns null for missing or invalid persisted indexes", async () => {
const stateDir = makeTempDir();
await expect(readPersistedInstalledPluginIndex({ stateDir })).resolves.toBeNull();

View File

@@ -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<Record<string, InstalledPluginInstallRecordInfo>> | undefined,
): Record<string, InstalledPluginInstallRecordInfo> | undefined {
if (!records) {
return undefined;
}
const safeRecords: Record<string, InstalledPluginInstallRecordInfo> = {};
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,
};
}