diff --git a/CHANGELOG.md b/CHANGELOG.md index d610ce3e7ec..72004cfddde 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai - OpenAI/Codex media: advertise Codex audio transcription in runtime and manifest metadata and route active Codex chat models to the OpenAI transcription default instead of sending chat model ids to audio transcription. Thanks @vincentkoc. - Dependencies: refresh runtime and provider packages including Pi 0.73.0, ACPX adapters, OpenAI, Anthropic, Slack, and TypeScript native preview, while keeping the Bedrock runtime installer override pinned below the Windows ARM Node 24 npm resolver failure. - Agents/performance: pass the resolved workspace through BTW, compaction, embedded-run model generation, and PDF model setup so explicit agent-dir model refreshes can reuse the current workspace-scoped plugin metadata snapshot instead of falling back to cold plugin metadata scans. (#77519, #77532) +- Plugins/performance: let unscoped model catalog and manifest-contract readers reuse the current workspace-compatible plugin metadata snapshot, avoiding repeated cold plugin metadata scans on hot control-plane paths while preserving env/config/workspace compatibility checks. (#77519, #77532) - Config/plugin auto-enable: prefer the claiming plugin manifest id over a built-in channel alias when auto-allowlisting a configured channel, so WeCom/Yuanbao-style aliases resolve to the installed plugin id. Thanks @Beandon13. - Plugins/active-memory: skip session-store channel entries that contain `:` when resolving the recall subagent's channel, so QQ c2c agent IDs (e.g. `c2c:10D4F7C2…`) and other scoped conversation IDs do not reach bundled-plugin `dirName` validation and crash the recall run. The same guard already applied to explicit `channelId` params (#76704); this extends it to store-derived channels. (#77396) Thanks @hclsys. - Secrets/external channel contracts: also look in `/dist/` when resolving the `secret-contract-api` sidecar, so npm-published externalized channel plugins (e.g. `@openclaw/discord` since 2026.5.2) whose compiled artifacts live under `dist/` actually contribute their channel SecretRef contracts to the runtime snapshot. Without this, env-backed `channels.discord.token` SecretRefs silently failed to resolve at gateway start on 2026.5.3, leaving the channel `not configured` even though #76449 had landed the generic external-contract loader. Thanks @mogglemoss. diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 9088a04a800..3d9c200bc6e 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -662,6 +662,38 @@ describe("loadModelCatalog", () => { ]); }); + it("lets read-only manifest catalog reuse the current workspace-scoped snapshot", () => { + loadManifestModelCatalog({ + config: {} as OpenClawConfig, + fallbackToMetadataScan: false, + }); + + expect(currentPluginMetadataSnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ + allowWorkspaceScopedSnapshot: true, + }), + ); + expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); + }); + + it("passes explicit env when checking current manifest catalog snapshot compatibility", () => { + const env = { HOME: "/tmp/openclaw-model-catalog-env" } as NodeJS.ProcessEnv; + + loadManifestModelCatalog({ + config: {} as OpenClawConfig, + env, + fallbackToMetadataScan: false, + }); + + expect(currentPluginMetadataSnapshotMock).toHaveBeenCalledWith( + expect.objectContaining({ + env, + allowWorkspaceScopedSnapshot: true, + }), + ); + expect(loadPluginMetadataSnapshotMock).not.toHaveBeenCalled(); + }); + it("dedupes supplemental models against registry entries", async () => { mockSingleOpenAiCatalogModel(); augmentCatalogMock.mockResolvedValueOnce([ diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index e773e08433b..a3268309981 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -123,7 +123,9 @@ export function loadManifestModelCatalog(params: { }): ModelCatalogEntry[] { const snapshot = getCurrentPluginMetadataSnapshot({ config: params.config, + env: params.env, ...(params.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), + ...(params.workspaceDir === undefined ? { allowWorkspaceScopedSnapshot: true } : {}), }); const resolvedSnapshot = snapshot ?? diff --git a/src/plugins/current-plugin-metadata-snapshot.test.ts b/src/plugins/current-plugin-metadata-snapshot.test.ts index 8dbf125a76a..96a0c4f93a4 100644 --- a/src/plugins/current-plugin-metadata-snapshot.test.ts +++ b/src/plugins/current-plugin-metadata-snapshot.test.ts @@ -87,6 +87,19 @@ describe("current plugin metadata snapshot", () => { expect(getCurrentPluginMetadataSnapshot({ config })).toBeUndefined(); }); + it("can opt into reusing the stored workspace scope for unscoped control-plane readers", () => { + const config = { plugins: { allow: ["demo"] } }; + const snapshot = createSnapshot({ config, workspaceDir: "/workspace/a" }); + setCurrentPluginMetadataSnapshot(snapshot, { config }); + + expect( + getCurrentPluginMetadataSnapshot({ + config, + allowWorkspaceScopedSnapshot: true, + }), + ).toBe(snapshot); + }); + it("rejects a current snapshot when plugin load paths change", () => { const config = { plugins: { load: { paths: ["/plugins/one"] } } }; const snapshot = createSnapshot({ config }); diff --git a/src/plugins/current-plugin-metadata-snapshot.ts b/src/plugins/current-plugin-metadata-snapshot.ts index ed7f896555e..7668654fc7e 100644 --- a/src/plugins/current-plugin-metadata-snapshot.ts +++ b/src/plugins/current-plugin-metadata-snapshot.ts @@ -49,6 +49,7 @@ export function getCurrentPluginMetadataSnapshot( config?: OpenClawConfig; env?: NodeJS.ProcessEnv; workspaceDir?: string; + allowWorkspaceScopedSnapshot?: boolean; } = {}, ): PluginMetadataSnapshot | undefined { const { snapshot: rawSnapshot, configFingerprint } = getCurrentPluginMetadataSnapshotState(); @@ -62,12 +63,15 @@ export function getCurrentPluginMetadataSnapshot( ) { return undefined; } + const requestedWorkspaceDir = + params.workspaceDir ?? + (params.allowWorkspaceScopedSnapshot === true ? snapshot.workspaceDir : undefined); if (params.config) { const requestedConfigFingerprint = resolvePluginMetadataControlPlaneFingerprint(params.config, { env: params.env, index: snapshot.index, policyHash: snapshot.policyHash, - workspaceDir: params.workspaceDir, + workspaceDir: requestedWorkspaceDir, }); if (configFingerprint && configFingerprint !== requestedConfigFingerprint) { return undefined; @@ -76,12 +80,12 @@ export function getCurrentPluginMetadataSnapshot( return undefined; } } - if (snapshot.workspaceDir !== undefined && params.workspaceDir === undefined) { + if (snapshot.workspaceDir !== undefined && requestedWorkspaceDir === undefined) { return undefined; } if ( - params.workspaceDir !== undefined && - (snapshot.workspaceDir ?? "") !== (params.workspaceDir ?? "") + requestedWorkspaceDir !== undefined && + (snapshot.workspaceDir ?? "") !== (requestedWorkspaceDir ?? "") ) { return undefined; } diff --git a/src/plugins/manifest-contract-eligibility.test.ts b/src/plugins/manifest-contract-eligibility.test.ts index 3fc82e14c7b..4c1476fa66d 100644 --- a/src/plugins/manifest-contract-eligibility.test.ts +++ b/src/plugins/manifest-contract-eligibility.test.ts @@ -46,6 +46,51 @@ describe("loadManifestContractSnapshot", () => { expect(mocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); }); + it("opts unscoped callers into the stored workspace-scoped snapshot", () => { + const env = { HOME: "/home/snapshot" } as NodeJS.ProcessEnv; + const current = { + index: { plugins: [] }, + plugins: [], + }; + mocks.getCurrentPluginMetadataSnapshot.mockReturnValue(current); + + expect(loadManifestContractSnapshot({ config: {}, env })).toEqual({ + index: current.index, + plugins: current.plugins, + }); + + expect(mocks.getCurrentPluginMetadataSnapshot).toHaveBeenCalledWith({ + config: {}, + env, + allowWorkspaceScopedSnapshot: true, + }); + expect(mocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); + }); + + it("normalizes omitted config before checking unscoped snapshot compatibility", () => { + const env = { HOME: "/home/default-config" } as NodeJS.ProcessEnv; + const snapshot = { + index: { plugins: [{ pluginId: "demo" }] }, + plugins: [{ id: "demo" }], + }; + mocks.loadPluginMetadataSnapshot.mockReturnValue(snapshot); + + expect(loadManifestContractSnapshot({ env })).toEqual({ + index: snapshot.index, + plugins: snapshot.plugins, + }); + + expect(mocks.getCurrentPluginMetadataSnapshot).toHaveBeenCalledWith({ + config: {}, + env, + allowWorkspaceScopedSnapshot: true, + }); + expect(mocks.loadPluginMetadataSnapshot).toHaveBeenCalledWith({ + config: {}, + env, + }); + }); + it("falls back to the shared metadata snapshot loader", () => { const env = { HOME: "/home/fallback" } as NodeJS.ProcessEnv; const snapshot = { diff --git a/src/plugins/manifest-contract-eligibility.ts b/src/plugins/manifest-contract-eligibility.ts index 9579474e3fc..4c8e667cfde 100644 --- a/src/plugins/manifest-contract-eligibility.ts +++ b/src/plugins/manifest-contract-eligibility.ts @@ -96,17 +96,19 @@ export function loadManifestMetadataSnapshot(params: { workspaceDir?: string; env?: NodeJS.ProcessEnv; }): PluginMetadataSnapshot { + const config = params.config ?? {}; const env = params.env ?? process.env; const current = getCurrentPluginMetadataSnapshot({ - config: params.config, + config, env, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), + ...(params.workspaceDir === undefined ? { allowWorkspaceScopedSnapshot: true } : {}), }); if (current) { return current; } return loadPluginMetadataSnapshot({ - config: params.config ?? {}, + config, env, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), });