diff --git a/CHANGELOG.md b/CHANGELOG.md index 39469d6d722..fdb104dcd22 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Voice Call/realtime: bound the paced Twilio audio queue and close overloaded realtime streams before provider audio can pile up behind the websocket backpressure guard. Thanks @vincentkoc. - Docs: clarify that IRC uses raw TCP/TLS sockets outside operator-managed forward proxy routing, so direct IRC egress should be explicitly approved before enabling IRC. Thanks @jesse-merhi. - Gateway/performance: defer non-readiness sidecars until after the ready signal, avoid hot-path channel plugin barrel imports, and fast-path trusted bundled plugin metadata during Gateway startup. +- Gateway/performance: reuse the compatible plugin metadata snapshot across dashboard and channel agent turns so auto-enabled runtime config does not repeatedly rescan plugin metadata before provider calls. Thanks @shakkernerd. - Gateway/performance: avoid importing `jiti` on native-loadable plugin startup paths, so compiled bundled plugin surfaces do not pay source-transform loader cost unless fallback loading is actually needed. - Gateway/diagnostics: add startup phase spans, active work labels, stale terminal bridge markers, and default sync-I/O tracing in `pnpm gateway:watch` so slow Gateway turns are easier to attribute from logs and stability diagnostics. - Plugins/loader: preserve real compiled plugin module evaluation errors on the native fast path instead of treating every thrown `.js` module as a source-transform fallback miss. Thanks @vincentkoc. diff --git a/src/gateway/server.impl.ts b/src/gateway/server.impl.ts index ebc481b110e..b5a823efcb2 100644 --- a/src/gateway/server.impl.ts +++ b/src/gateway/server.impl.ts @@ -655,7 +655,12 @@ export async function startGatewayServer( baseMethods, runtimePluginsLoaded, } = pluginBootstrap; - setCurrentPluginMetadataSnapshot(pluginLookUpTable, { config: gatewayPluginConfigAtStart }); + setCurrentPluginMetadataSnapshot(pluginLookUpTable, { + config: startupActivationSourceConfig, + compatibleConfigs: [gatewayPluginConfigAtStart], + env: process.env, + workspaceDir: defaultWorkspaceDir, + }); if (pluginLookUpTable) { const metrics = pluginLookUpTable.metrics; startupTrace.detail("plugins.lookup-table", [ @@ -1178,7 +1183,11 @@ export async function startGatewayServer( } } await params.beforeReplace(channelsToStopBeforeReplace); - setCurrentPluginMetadataSnapshot(nextPluginLookUpTable, { config: params.nextConfig }); + setCurrentPluginMetadataSnapshot(nextPluginLookUpTable, { + config: params.nextConfig, + env: process.env, + workspaceDir: defaultWorkspaceDir, + }); const loaded = prepareGatewayPluginLoad({ cfg: params.nextConfig, workspaceDir: defaultWorkspaceDir, diff --git a/src/plugins/current-plugin-metadata-snapshot.test.ts b/src/plugins/current-plugin-metadata-snapshot.test.ts index 96a0c4f93a4..37e7f817f39 100644 --- a/src/plugins/current-plugin-metadata-snapshot.test.ts +++ b/src/plugins/current-plugin-metadata-snapshot.test.ts @@ -160,6 +160,27 @@ describe("current plugin metadata snapshot", () => { expect(getCurrentPluginMetadataSnapshot({ config: autoEnabledConfig })).toBeUndefined(); }); + it("accepts explicit compatible configs for gateway runtime reuse", () => { + const sourceConfig = { channels: { telegram: { botToken: "token" } } }; + const runtimeConfig = { + ...sourceConfig, + plugins: { allow: ["telegram"] }, + }; + const snapshot = createSnapshot({ config: sourceConfig, workspaceDir: "/workspace" }); + setCurrentPluginMetadataSnapshot(snapshot, { + config: sourceConfig, + compatibleConfigs: [runtimeConfig], + workspaceDir: "/workspace", + }); + + expect( + getCurrentPluginMetadataSnapshot({ config: sourceConfig, workspaceDir: "/workspace" }), + ).toBe(snapshot); + expect( + getCurrentPluginMetadataSnapshot({ config: runtimeConfig, workspaceDir: "/workspace" }), + ).toBe(snapshot); + }); + it("clears the current snapshot", () => { setCurrentPluginMetadataSnapshot(createSnapshot()); clearCurrentPluginMetadataSnapshot(); diff --git a/src/plugins/current-plugin-metadata-snapshot.ts b/src/plugins/current-plugin-metadata-snapshot.ts index 7668654fc7e..aadbc96ad1b 100644 --- a/src/plugins/current-plugin-metadata-snapshot.ts +++ b/src/plugins/current-plugin-metadata-snapshot.ts @@ -25,8 +25,26 @@ export function resolvePluginMetadataControlPlaneFingerprint( // never accumulate historical metadata snapshots here. export function setCurrentPluginMetadataSnapshot( snapshot: PluginMetadataSnapshot | undefined, - options: { config?: OpenClawConfig; env?: NodeJS.ProcessEnv; workspaceDir?: string } = {}, + options: { + config?: OpenClawConfig; + compatibleConfigs?: readonly OpenClawConfig[]; + env?: NodeJS.ProcessEnv; + workspaceDir?: string; + } = {}, ): void { + const compatiblePolicyHashes = snapshot + ? options.compatibleConfigs?.map((config) => resolveInstalledPluginIndexPolicyHash(config)) + : undefined; + const compatibleConfigFingerprints = snapshot + ? options.compatibleConfigs?.map((config, index) => + resolvePluginMetadataControlPlaneFingerprint(config, { + env: options.env, + index: snapshot.index, + policyHash: compatiblePolicyHashes?.[index], + workspaceDir: options.workspaceDir ?? snapshot.workspaceDir, + }), + ) + : undefined; setCurrentPluginMetadataSnapshotState( snapshot, snapshot @@ -37,6 +55,8 @@ export function setCurrentPluginMetadataSnapshot( workspaceDir: options.workspaceDir ?? snapshot.workspaceDir, }) : undefined, + compatiblePolicyHashes, + compatibleConfigFingerprints, ); } @@ -52,16 +72,24 @@ export function getCurrentPluginMetadataSnapshot( allowWorkspaceScopedSnapshot?: boolean; } = {}, ): PluginMetadataSnapshot | undefined { - const { snapshot: rawSnapshot, configFingerprint } = getCurrentPluginMetadataSnapshotState(); + const { + snapshot: rawSnapshot, + configFingerprint, + compatiblePolicyHashes, + compatibleConfigFingerprints, + } = getCurrentPluginMetadataSnapshotState(); const snapshot = rawSnapshot as PluginMetadataSnapshot | undefined; if (!snapshot) { return undefined; } - if ( - params.config && - snapshot.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config) - ) { - return undefined; + const requestedPolicyHash = params.config + ? resolveInstalledPluginIndexPolicyHash(params.config) + : undefined; + if (requestedPolicyHash && snapshot.policyHash !== requestedPolicyHash) { + const compatiblePolicies = new Set(compatiblePolicyHashes ?? []); + if (!compatiblePolicies.has(requestedPolicyHash)) { + return undefined; + } } const requestedWorkspaceDir = params.workspaceDir ?? @@ -70,13 +98,15 @@ export function getCurrentPluginMetadataSnapshot( const requestedConfigFingerprint = resolvePluginMetadataControlPlaneFingerprint(params.config, { env: params.env, index: snapshot.index, - policyHash: snapshot.policyHash, + policyHash: requestedPolicyHash, workspaceDir: requestedWorkspaceDir, }); - if (configFingerprint && configFingerprint !== requestedConfigFingerprint) { - return undefined; - } - if (snapshot.configFingerprint && snapshot.configFingerprint !== requestedConfigFingerprint) { + const compatibleFingerprints = new Set(compatibleConfigFingerprints ?? []); + const fingerprintMatches = + configFingerprint === requestedConfigFingerprint || + snapshot.configFingerprint === requestedConfigFingerprint || + compatibleFingerprints.has(requestedConfigFingerprint); + if (!fingerprintMatches) { return undefined; } } diff --git a/src/plugins/current-plugin-metadata-state.ts b/src/plugins/current-plugin-metadata-state.ts index 97d68b7c2c6..3f0ffbd59ee 100644 --- a/src/plugins/current-plugin-metadata-state.ts +++ b/src/plugins/current-plugin-metadata-state.ts @@ -1,25 +1,41 @@ let currentPluginMetadataSnapshot: unknown; let currentPluginMetadataSnapshotConfigFingerprint: string | undefined; +let currentPluginMetadataSnapshotCompatiblePolicyHashes: readonly string[] | undefined; +let currentPluginMetadataSnapshotCompatibleConfigFingerprints: readonly string[] | undefined; export function setCurrentPluginMetadataSnapshotState( snapshot: unknown, configFingerprint: string | undefined, + compatiblePolicyHashes?: readonly string[], + compatibleConfigFingerprints?: readonly string[], ): void { currentPluginMetadataSnapshot = snapshot; currentPluginMetadataSnapshotConfigFingerprint = snapshot ? configFingerprint : undefined; + currentPluginMetadataSnapshotCompatiblePolicyHashes = snapshot + ? compatiblePolicyHashes + : undefined; + currentPluginMetadataSnapshotCompatibleConfigFingerprints = snapshot + ? compatibleConfigFingerprints + : undefined; } export function clearCurrentPluginMetadataSnapshotState(): void { currentPluginMetadataSnapshot = undefined; currentPluginMetadataSnapshotConfigFingerprint = undefined; + currentPluginMetadataSnapshotCompatiblePolicyHashes = undefined; + currentPluginMetadataSnapshotCompatibleConfigFingerprints = undefined; } export function getCurrentPluginMetadataSnapshotState(): { snapshot: unknown; configFingerprint: string | undefined; + compatiblePolicyHashes: readonly string[] | undefined; + compatibleConfigFingerprints: readonly string[] | undefined; } { return { snapshot: currentPluginMetadataSnapshot, configFingerprint: currentPluginMetadataSnapshotConfigFingerprint, + compatiblePolicyHashes: currentPluginMetadataSnapshotCompatiblePolicyHashes, + compatibleConfigFingerprints: currentPluginMetadataSnapshotCompatibleConfigFingerprints, }; } diff --git a/src/secrets/provider-env-vars.dynamic.test.ts b/src/secrets/provider-env-vars.dynamic.test.ts index b6118d16338..5199802d19b 100644 --- a/src/secrets/provider-env-vars.dynamic.test.ts +++ b/src/secrets/provider-env-vars.dynamic.test.ts @@ -41,6 +41,7 @@ const pluginRegistryMocks = vi.hoisted(() => { diagnostics: [], })); return { + getCurrentPluginMetadataSnapshot: vi.fn(), loadPluginManifestRegistryForInstalledIndex: loadManifestRegistry, loadPluginManifestRegistryForPluginRegistry: loadManifestRegistry, loadPluginRegistrySnapshot: vi.fn(() => ({ plugins: [] })), @@ -61,6 +62,10 @@ const pluginRegistryMocks = vi.hoisted(() => { }; }); +vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({ + getCurrentPluginMetadataSnapshot: pluginRegistryMocks.getCurrentPluginMetadataSnapshot, +})); + vi.mock("../plugins/manifest-registry-installed.js", () => ({ loadPluginManifestRegistryForInstalledIndex: pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex, @@ -85,6 +90,8 @@ describe("provider env vars dynamic manifest metadata", () => { }); pluginRegistryMocks.loadPluginRegistrySnapshot.mockReset(); pluginRegistryMocks.loadPluginRegistrySnapshot.mockReturnValue({ plugins: [] }); + pluginRegistryMocks.getCurrentPluginMetadataSnapshot.mockReset(); + pluginRegistryMocks.getCurrentPluginMetadataSnapshot.mockReturnValue(undefined); pluginRegistryMocks.loadPluginMetadataSnapshot.mockClear(); __testing.resetProviderEnvVarCachesForTests(); }); @@ -177,6 +184,55 @@ describe("provider env vars dynamic manifest metadata", () => { }); }); + it("reuses the current compatible metadata snapshot for workspace auth evidence", async () => { + pluginRegistryMocks.getCurrentPluginMetadataSnapshot.mockReturnValue({ + index: { + plugins: [ + { + pluginId: "external-cloud", + origin: "global", + enabled: true, + enabledByDefault: true, + }, + ], + }, + plugins: [ + { + id: "external-cloud", + origin: "global", + setup: { + providers: [ + { + id: "external-cloud", + authEvidence: [ + { + type: "local-file-with-env", + fileEnvVar: "EXTERNAL_CLOUD_CREDENTIALS", + credentialMarker: "external-cloud-local-credentials", + }, + ], + }, + ], + }, + }, + ], + }); + + expect( + resolveProviderAuthEvidence({ + config: {}, + workspaceDir: "/workspace", + })["external-cloud"], + ).toEqual([ + { + type: "local-file-with-env", + fileEnvVar: "EXTERNAL_CLOUD_CREDENTIALS", + credentialMarker: "external-cloud-local-credentials", + }, + ]); + expect(pluginRegistryMocks.loadPluginMetadataSnapshot).not.toHaveBeenCalled(); + }); + it("excludes untrusted workspace plugin auth evidence by default", async () => { pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({ plugins: [ diff --git a/src/secrets/provider-env-vars.ts b/src/secrets/provider-env-vars.ts index 63b35337166..22de99e7cdb 100644 --- a/src/secrets/provider-env-vars.ts +++ b/src/secrets/provider-env-vars.ts @@ -1,12 +1,16 @@ import { resolveProviderAuthAliasMap } from "../agents/provider-auth-aliases.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { getCurrentPluginMetadataSnapshot } from "../plugins/current-plugin-metadata-snapshot.js"; import { isInstalledPluginEnabled } from "../plugins/installed-plugin-index.js"; import type { PluginManifestRecord } from "../plugins/manifest-registry.js"; import { isWorkspacePluginAllowedByConfig, normalizePluginConfigId, } from "../plugins/plugin-config-trust.js"; -import { loadPluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js"; +import { + loadPluginMetadataSnapshot, + type PluginMetadataSnapshot, +} from "../plugins/plugin-metadata-snapshot.js"; import { hasKind } from "../plugins/slots.js"; const CORE_PROVIDER_AUTH_ENV_VAR_CANDIDATES = { @@ -115,15 +119,31 @@ function appendUniqueAuthEvidence( } } +function resolveProviderMetadataSnapshot( + params?: ProviderEnvVarLookupParams, +): PluginMetadataSnapshot { + const config = params?.config ?? {}; + const env = params?.env ?? process.env; + const current = getCurrentPluginMetadataSnapshot({ + config, + env, + ...(params?.workspaceDir !== undefined ? { workspaceDir: params.workspaceDir } : {}), + }); + if (current) { + return current; + } + return loadPluginMetadataSnapshot({ + config, + workspaceDir: params?.workspaceDir, + env, + preferPersisted: false, + }); +} + function resolveManifestProviderAuthEnvVarCandidates( params?: ProviderEnvVarLookupParams, ): Record { - const snapshot = loadPluginMetadataSnapshot({ - config: params?.config ?? {}, - workspaceDir: params?.workspaceDir, - env: params?.env ?? process.env, - preferPersisted: false, - }); + const snapshot = resolveProviderMetadataSnapshot(params); const candidates: Record = {}; for (const plugin of snapshot.plugins) { if (!shouldUsePluginProviderEnvVars(plugin, params)) { @@ -155,12 +175,7 @@ function resolveManifestProviderAuthEnvVarCandidates( function resolveManifestProviderAuthEvidence( params?: ProviderEnvVarLookupParams, ): Record { - const snapshot = loadPluginMetadataSnapshot({ - config: params?.config ?? {}, - workspaceDir: params?.workspaceDir, - env: params?.env ?? process.env, - preferPersisted: false, - }); + const snapshot = resolveProviderMetadataSnapshot(params); const evidenceByProvider: Record = {}; for (const plugin of snapshot.plugins) { if (