fix(plugins): avoid false SecretRef setup errors (#155618)

Validate managed plugin setup against the authored config paired with the runtime snapshot. Capture that source before hosted catalog I/O so a concurrent runtime publication cannot substitute another generation.

Preserve malformed and missing authored config errors and explicit candidate edits. Cover active and captured runtime snapshots across publication changes.
This commit is contained in:
Vincent Koc 2026-09-22 19:02:06 +08:00 • committed by GitHub
parent 88f558d453
commit b05632e131
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 104 additions and 2 deletions

View file

@ -3,6 +3,12 @@ import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
} from "../config/runtime-snapshot.js";
import { captureRuntimeConfig } from "../config/runtime-source-projection.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { joinClawHubPluginCatalog } from "./catalog-discovery.js";
import {
emptyMetadataSnapshot,
@ -64,7 +70,10 @@ function mockHostedOfficialCatalog(entries: unknown[]) {
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe("managed plugin catalog", () => {
afterEach(() => vi.unstubAllEnvs());
afterEach(() => {
vi.unstubAllEnvs();
clearRuntimeConfigSnapshot();
});
beforeEach(() => {
clearManagedPluginCatalogCache();
@ -157,6 +166,95 @@ describe("managed plugin catalog", () => {
});
});
describe("authored credential validation", () => {
const secretRef = { source: "store", provider: "default", id: "TEST_PLUGIN_KEY" };
const configured = (config?: Record<string, unknown>, enabled = true): OpenClawConfig => ({
plugins: { entries: { "ref-plugin": { enabled, ...(config ? { config } : {}) } } },
});
beforeEach(() => {
mocks.metadata.mockReturnValue(
metadataSnapshot({
enabled: true,
id: "ref-plugin",
configSchema: {
type: "object",
required: ["apiKey"],
properties: {
apiKey: {
type: "object",
required: ["source", "provider", "id"],
properties: {
source: { const: "store" },
provider: { type: "string" },
id: { type: "string" },
},
},
},
},
}),
);
});
it.each(["active", "captured"])(
"validates the %s runtime's authored refs across a catalog await",
async (mode) => {
const source = configured({ apiKey: secretRef });
const runtime = configured({ apiKey: "synthetic-resolved-value" });
setRuntimeConfigSnapshot(runtime, source);
const config = mode === "captured" ? captureRuntimeConfig(runtime) : runtime;
// Captured requests can already predate the current publication on entry.
if (mode === "captured") {
setRuntimeConfigSnapshot(configured(), configured());
}
mocks.officialCatalog.mockImplementationOnce(async () => {
setRuntimeConfigSnapshot(configured(), configured({ apiKey: 42 }));
return { source: "hosted", entries: [] };
});
const catalog = await listManagedPlugins({ config, env: {} });
expect(catalog.plugins[0]).toMatchObject({ id: "ref-plugin", state: "enabled" });
expect(catalog.plugins[0]).not.toHaveProperty("error");
expect(runtime.plugins?.entries?.["ref-plugin"]?.config?.apiKey).toBe(
"synthetic-resolved-value",
);
expect(source.plugins?.entries?.["ref-plugin"]?.config?.apiKey).toEqual(secretRef);
},
);
it.each([
["invalid authored config", { apiKey: "synthetic-invalid-plaintext" }, "error"],
["missing authored config", undefined, "needs-setup"],
] as const)("preserves %s", async (_name, authoredConfig, state) => {
const source = configured(authoredConfig, false);
const runtime = configured({ apiKey: secretRef }, false);
setRuntimeConfigSnapshot(runtime, source);
const catalog = await listManagedPlugins({ config: runtime, env: {} });
expect(catalog.plugins[0]).toMatchObject({ state });
if (state === "error") {
expect(catalog.plugins[0]?.error).toBe("apiKey: must be object");
}
});
it("does not replace an explicit candidate with the active source", async () => {
setRuntimeConfigSnapshot(
configured({ apiKey: "synthetic-resolved-value" }),
configured({ apiKey: secretRef }),
);
const candidate = configured({ apiKey: "synthetic-invalid-candidate" });
const catalog = await listManagedPlugins({ config: candidate, env: {} });
expect(catalog.plugins[0]).toMatchObject({
state: "error",
error: "apiKey: must be object",
});
});
});
it("does not transfer bundled endorsement to a package identity impostor", async () => {
mocks.metadata.mockReturnValue(emptyMetadataSnapshot());
mockHostedOfficialCatalog([

View file

@ -10,6 +10,7 @@ import { resolveIsConfigReadOnly } from "../config/paths.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { resolveClawHubBaseUrl } from "../infra/clawhub-client.js";
import { fetchClawHubPluginVersionCategories } from "../infra/clawhub-plugin-catalog.js";
import { resolvePluginActivationSourceConfig } from "./activation-source-config.js";
import { resolvePendingPluginCapabilityReview } from "./capability-consent.js";
import {
buildPluginCapabilitySummary,
@ -237,6 +238,9 @@ export const listManagedPlugins = withManagedPluginCache(
officialCatalog?: OfficialCatalogResult;
metadata?: PluginMetadataSnapshot;
}): Promise<ManagedPluginCatalog> => {
// Manifest schemas describe authored SecretRefs, not resolved runtime strings.
// Retain the paired source before hosted catalog I/O can publish another generation.
const sourceConfig = resolvePluginActivationSourceConfig({ config: params.config });
const env = params.env ?? process.env;
const workspace = resolvePluginControlPlaneWorkspace({ config: params.config, env });
const metadata = params.metadata ?? resolveManagedPluginMetadata(params.config, env);
@ -302,7 +306,7 @@ export const listManagedPlugins = withManagedPluginCache(
? { ...localCatalog, ...officialCatalogMetadata }
: localCatalog;
const setup = resolvePluginConfigEnablement({
config: params.config,
config: sourceConfig,
pluginId: record.pluginId,
manifest,
});