From f41e28202fa797bd190ef53e596a27781c9bb494 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Fri, 24 Jul 2026 09:19:14 -0700 Subject: [PATCH] fix(plugins): keep feed trust within env budget (#113364) --- docs/cli/plugins.md | 8 +- .../official-external-plugin-catalog.test.ts | 74 +++++++------------ .../official-external-plugin-catalog.ts | 59 +-------------- 3 files changed, 34 insertions(+), 107 deletions(-) diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index 27aba4408b18..7a9caf82834b 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -563,11 +563,9 @@ refreshes fail if OpenClaw cannot persist the validated snapshot. The built-in `clawhub-public` profile expects payload identity `clawhub-official`. OpenClaw will bundle ClawHub's production public key after -ClawHub generates and hands off that key. Until then, staging and private -deployments can inject both `OPENCLAW_CLAWHUB_FEED_TRUSTED_KEY_ID` and -`OPENCLAW_CLAWHUB_FEED_TRUSTED_PUBLIC_KEY`; setting only one fails closed before -fetch. Public keys must come from a trusted release or operator channel, not -from a key endpoint on the feed host. +ClawHub generates and hands off that key. Until then, the built-in profile does +not grant signed-feed install authority. Public keys must come from a trusted +release or operator channel, not from a key endpoint on the feed host. OpenClaw verifies the DSSE envelope and, when a profile declares `feedId`, requires the decoded payload ID to match it. The built-in `clawhub-public` diff --git a/src/plugins/official-external-plugin-catalog.test.ts b/src/plugins/official-external-plugin-catalog.test.ts index efedabaa92ad..2753c7c5b2e6 100644 --- a/src/plugins/official-external-plugin-catalog.test.ts +++ b/src/plugins/official-external-plugin-catalog.test.ts @@ -389,9 +389,17 @@ describe("official external plugin catalog", () => { }); const result = await loadHostedCatalog({ - env: { - OPENCLAW_CLAWHUB_FEED_TRUSTED_KEY_ID: "acme-root", - OPENCLAW_CLAWHUB_FEED_TRUSTED_PUBLIC_KEY: signed.publicKeyPem, + catalogConfig: { + feeds: { + "clawhub-public": { + url: "https://clawhub.ai/v1/feeds/plugins", + feedId: "clawhub-official", + verification: { + mode: "signed", + keys: [{ keyId: "acme-root", publicKey: signed.publicKeyPem }], + }, + }, + }, }, ifModifiedSince: "Mon, 22 Jun 2026 00:00:00 GMT", fetchImpl, @@ -404,7 +412,7 @@ describe("official external plugin catalog", () => { expect(result.trust).toMatchObject({ mode: "signed", signedBy: "acme-root" }); }); - it("preserves default ClawHub trust when the built-in profile is customized", async () => { + it("uses configured ClawHub trust when the built-in profile is customized", async () => { const feed = { ...hostedCatalogFeed({ sequence: 12, pluginName: "@openclaw/default-configured" }), id: "clawhub-official", @@ -416,13 +424,13 @@ describe("official external plugin catalog", () => { "clawhub-public": { url: "https://clawhub.ai/v1/feeds/plugins", feedId: "clawhub-official", + verification: { + mode: "signed", + keys: [{ keyId: "acme-root", publicKey: signed.publicKeyPem }], + }, }, }, }, - env: { - OPENCLAW_CLAWHUB_FEED_TRUSTED_KEY_ID: "acme-root", - OPENCLAW_CLAWHUB_FEED_TRUSTED_PUBLIC_KEY: signed.publicKeyPem, - }, fetchImpl: vi.fn(async () => dsseResponse(signed.body, { status: 200 })), snapshotStore: null, }); @@ -446,9 +454,6 @@ describe("official external plugin catalog", () => { }, }, }, - env: { - OPENCLAW_CLAWHUB_FEED_TRUSTED_KEY_ID: "incomplete", - }, fetchImpl: vi.fn(async () => new Response(body, { status: 200 })), snapshotStore: null, }); @@ -463,9 +468,17 @@ describe("official external plugin catalog", () => { }); const result = await loadHostedCatalog({ - env: { - OPENCLAW_CLAWHUB_FEED_TRUSTED_KEY_ID: "acme-root", - OPENCLAW_CLAWHUB_FEED_TRUSTED_PUBLIC_KEY: signed.publicKeyPem, + catalogConfig: { + feeds: { + "clawhub-public": { + url: "https://clawhub.ai/v1/feeds/plugins", + feedId: "clawhub-official", + verification: { + mode: "signed", + keys: [{ keyId: "acme-root", publicKey: signed.publicKeyPem }], + }, + }, + }, }, fetchImpl: vi.fn(async () => dsseResponse(signed.body, { status: 200 })), snapshotStore: null, @@ -478,39 +491,6 @@ describe("official external plugin catalog", () => { ); }); - it("fails closed before fetch when default ClawHub trust env is incomplete", async () => { - const fetchImpl = vi.fn(async () => new Response("{}", { status: 200 })); - const result = await loadHostedCatalog({ - env: { - OPENCLAW_CLAWHUB_FEED_TRUSTED_KEY_ID: "acme-root", - }, - fetchImpl, - snapshotStore: null, - }); - - expect(fetchImpl).not.toHaveBeenCalled(); - expectBundledFallback(result); - expect(result.entries).toEqual([]); - expect(result.error).toContain("requires both key id and public key env vars"); - }); - - it("preserves default ClawHub trust for an explicit default feed URL", async () => { - const fetchImpl = vi.fn(async () => new Response("{}", { status: 200 })); - const result = await loadHostedCatalog({ - feedUrl: "https://clawhub.ai/v1/feeds/plugins", - env: { - OPENCLAW_CLAWHUB_FEED_TRUSTED_KEY_ID: "acme-root", - }, - fetchImpl, - snapshotStore: null, - }); - - expect(fetchImpl).not.toHaveBeenCalled(); - expectBundledFallback(result); - expect(result.entries).toEqual([]); - expect(result.error).toContain("requires both key id and public key env vars"); - }); - it("loads schema-v2 marketplace entries and gates installs by state and trust", async () => { const body = JSON.stringify({ schemaVersion: 2, diff --git a/src/plugins/official-external-plugin-catalog.ts b/src/plugins/official-external-plugin-catalog.ts index 951de92965d8..d86de66962b2 100644 --- a/src/plugins/official-external-plugin-catalog.ts +++ b/src/plugins/official-external-plugin-catalog.ts @@ -27,13 +27,6 @@ class HostedCatalogSnapshotWriteError extends Error { } } -class HostedCatalogTrustConfigurationError extends Error { - constructor(message: string) { - super(message); - this.name = "HostedCatalogTrustConfigurationError"; - } -} - export type OfficialExternalProviderAuthChoice = { method?: string; choiceId?: string; @@ -268,10 +261,6 @@ const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE = "clawhub-public"; const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_ID = "clawhub-official"; const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_SOURCE_REF = "public-clawhub"; const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_NPM_SOURCE_REF = "public-npm"; -const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_TRUSTED_KEY_ID_ENV = - "OPENCLAW_CLAWHUB_FEED_TRUSTED_KEY_ID"; -const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_TRUSTED_PUBLIC_KEY_ENV = - "OPENCLAW_CLAWHUB_FEED_TRUSTED_PUBLIC_KEY"; const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_TRUSTED_KEYS: readonly OfficialExternalPluginCatalogFeedSigningKey[] = []; const DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_PROFILE_CONFIG: OfficialExternalPluginCatalogProfileConfig = @@ -405,38 +394,11 @@ function resolveHostedCatalogFeedUrl(raw: string): URL { return parsed; } -function resolveDefaultClawHubFeedVerificationFromEnv( - env?: NodeJS.ProcessEnv, -): OfficialExternalPluginCatalogFeedVerification | undefined { - const keyId = normalizeOptionalString( - env?.[DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_TRUSTED_KEY_ID_ENV], - ); - const publicKey = normalizeOptionalString( - env?.[DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_TRUSTED_PUBLIC_KEY_ENV], - ); - if (!keyId && !publicKey) { - return undefined; - } - if (!keyId || !publicKey) { - throw new HostedCatalogTrustConfigurationError( - "default ClawHub feed trust requires both key id and public key env vars", - ); - } - return { - mode: "signed", - keys: [{ keyId, publicKey }], - }; -} - function resolveOfficialExternalPluginCatalogProfileConfig( config?: OfficialExternalPluginCatalogProfileConfig, - env?: NodeJS.ProcessEnv, ): Required { const configuredDefaultFeed = config?.feeds?.[DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE]; - const envVerification = configuredDefaultFeed?.verification - ? undefined - : resolveDefaultClawHubFeedVerificationFromEnv(env); const bundledVerification = DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_TRUSTED_KEYS.length > 0 ? { @@ -444,7 +406,6 @@ function resolveOfficialExternalPluginCatalogProfileConfig( keys: DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_CLAWHUB_TRUSTED_KEYS, } : undefined; - const defaultVerification = envVerification ?? bundledVerification; const defaultFeed = DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_PROFILE_CONFIG.feeds?.[ DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE ] ?? { @@ -456,7 +417,7 @@ function resolveOfficialExternalPluginCatalogProfileConfig( ...config?.feeds, [DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE]: { ...defaultFeed, - ...(defaultVerification ? { verification: defaultVerification } : {}), + ...(bundledVerification ? { verification: bundledVerification } : {}), ...configuredDefaultFeed, }, }, @@ -471,7 +432,6 @@ function resolveHostedCatalogFeedSource(params: { feedUrl?: string; feedProfile?: string; catalogConfig?: OfficialExternalPluginCatalogProfileConfig; - env?: NodeJS.ProcessEnv; }): { url: URL; hostnameAllowlist: string[]; @@ -499,12 +459,7 @@ function resolveHostedCatalogFeedSource(params: { const profileConfig = profileName === undefined ? undefined - : resolveOfficialExternalPluginCatalogProfileConfig( - params.catalogConfig, - profileName === DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE - ? params.env - : undefined, - ); + : resolveOfficialExternalPluginCatalogProfileConfig(params.catalogConfig); const profile = profileName === undefined ? undefined : profileConfig?.feeds[profileName]; if (profileName !== undefined && !profile) { throw new Error(`hosted catalog feed profile "${profileName}" is not configured`); @@ -517,10 +472,7 @@ function resolveHostedCatalogFeedSource(params: { }; } const profileName = explicitProfileName ?? DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE; - const profileConfig = resolveOfficialExternalPluginCatalogProfileConfig( - params.catalogConfig, - profileName === DEFAULT_OFFICIAL_EXTERNAL_PLUGIN_CATALOG_FEED_PROFILE ? params.env : undefined, - ); + const profileConfig = resolveOfficialExternalPluginCatalogProfileConfig(params.catalogConfig); const profile = profileConfig.feeds[profileName]; if (!profile) { throw new Error(`hosted catalog feed profile "${profileName}" is not configured`); @@ -1109,12 +1061,9 @@ async function loadHostedOfficialExternalPluginCatalogEntries(params?: { feedUrl: params?.feedUrl, feedProfile: params?.feedProfile, catalogConfig: params?.catalogConfig, - env: params?.env ?? process.env, }); } catch (err) { - return err instanceof HostedCatalogTrustConfigurationError - ? emptyBundledFallbackResult(err) - : bundledFallbackResult(err); + return bundledFallbackResult(err); } const { url } = source; const snapshotStore = await resolveHostedCatalogSnapshotStore({