mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 10:10:45 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
15
src/commands/search-setup-cold-imports.test.ts
Normal file
15
src/commands/search-setup-cold-imports.test.ts
Normal 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["']/);
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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..." },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user