fix(plugins): keep feed trust within env budget (#113364)

This commit is contained in:
Peter Steinberger
2026-07-24 09:19:14 -07:00
committed by GitHub
parent 8029b3bbb8
commit f41e28202f
3 changed files with 34 additions and 107 deletions

View File

@@ -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`

View File

@@ -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,

View File

@@ -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<OfficialExternalPluginCatalogProfileConfig> {
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({