fix(agentmail): allow official ClawHub channel to load (#155097)

* fix(agentmail): allow official ClawHub channel to load

* docs(agentmail): keep setup guide vendor-owned

* fix(agentmail): require API key for configured state

* fix(plugin): verify pinned digest before legacy trust
This commit is contained in:
Patrick Erichsen 2026-09-28 16:10:33 -07:00 • committed by GitHub
parent 2a7642919e
commit f9358275ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 256 additions and 17 deletions

View file

@ -1,5 +1,6 @@
{
"entries": [
{"name":"@agentmail/agentmail","description":"Official AgentMail email channel and CLI-backed skill.","source":"external","kind":"channel","openclaw":{"plugin":{"id":"agentmail","label":"AgentMail"},"channel":{"id":"agentmail","label":"AgentMail","selectionLabel":"AgentMail (Email)","detailLabel":"AgentMail Email","docsPath":"https://www.agentmail.to/docs/integrations/openclaw","docsLabel":"agentmail","exposure":{"docs":false},"blurb":"Durable, allowlisted, reply-only email conversations through AgentMail.","order":89,"quickstartAllowFrom":true,"configuredState":{"env":{"anyOf":["AGENTMAIL_API_KEY"]}}},"channelConfigs":{"agentmail":{"label":"AgentMail","description":"AgentMail email channel accounts and sender allowlist.","schema":{"type":"object","additionalProperties":true}}},"install":{"clawhubSpec":"clawhub:@agentmail/agentmail@0.2.1","expectedIntegrity":"sha256:155221cec38673a39bc27629f9f6ec87567ce4e37b7fa619ec4b1f7ca3d28730","defaultChoice":"clawhub","minHostVersion":">=2026.8.1-beta.2","allowInvalidConfigRecovery":true}}},
{"name":"@openclaw/buzz","version":"2026.9.6","description":"Connect OpenClaw agents to Buzz rooms","source":"official","kind":"channel","openclaw":{"channelConfigs":{"buzz":{"label":"Buzz","description":"Connect OpenClaw agents to Buzz team rooms."}},"channel":{"id":"buzz","configuredState":{"env":{"allOf":["BUZZ_RELAY_URL","BUZZ_PRIVATE_KEY"]}},"label":"Buzz","selectionLabel":"Buzz","docsPath":"/channels/buzz","docsLabel":"buzz","blurb":"Connect OpenClaw agents to Buzz team rooms.","markdownCapable":true,"order":56,"setup":{"fields":[{"key":"relayUrl","kind":"string","cli":{"flags":"--relay-url <url>","description":"Buzz relay WebSocket URL"}},{"key":"privateKey","kind":"string","sensitive":true,"cli":{"flags":"--private-key <key>","description":"Buzz bot Nostr private key"}},{"key":"useEnv","kind":"boolean","cli":{"flags":"--use-env","description":"Use BUZZ_PRIVATE_KEY with the supplied relay URL"},"envVars":["BUZZ_PRIVATE_KEY"]}]}},"install":{"clawhubSpec":"clawhub:@openclaw/buzz","npmSpec":"@openclaw/buzz","defaultChoice":"npm","minHostVersion":">=2026.7.2"}}},
{"name":"@openclaw/clickclack","version":"2026.9.6","description":"OpenClaw ClickClack channel plugin","source":"official","kind":"channel","openclaw":{"contracts":{"tools":["discussion"]},"channelConfigs":{"clickclack":{"label":"ClickClack","description":"ClickClack channel accounts and group activation policy."}},"channel":{"id":"clickclack","configuredState":{"env":{"anyOf":["CLICKCLACK_BOT_TOKEN"]}},"label":"ClickClack","selectionLabel":"ClickClack","detailLabel":"ClickClack Bot","docsPath":"/channels/clickclack","docsLabel":"clickclack","blurb":"self-hosted chat via first-class ClickClack bot tokens.","systemImage":"bubble.left.and.bubble.right","markdownCapable":true,"preferSessionLookupForAnnounceTarget":true,"order":85,"commands":{"nativeCommandsAutoEnabled":false,"nativeSkillsAutoEnabled":false},"setup":{"fields":[{"key":"code","kind":"string","sensitive":true,"cli":{"flags":"--code <code>","description":"ClickClack one-time setup code or setup URL"}},{"key":"token","kind":"string","sensitive":true,"cli":{"flags":"--token <token>","description":"ClickClack bot token"}},{"key":"tokenFile","kind":"string","sensitive":true,"cli":{"flags":"--token-file <path>","description":"ClickClack bot token file"}},{"key":"baseUrl","kind":"string","cli":{"flags":"--base-url <url>","description":"ClickClack API base URL"}},{"key":"workspace","kind":"string","cli":{"flags":"--workspace <workspace>","description":"ClickClack workspace id, slug, or name"}},{"key":"defaultTo","kind":"string","cli":{"flags":"--default-to <target>","description":"Default ClickClack target"}},{"key":"allowFrom","kind":"string-list","cli":{"flags":"--allow-from <ids>","description":"Allowed ClickClack senders"}},{"key":"agentActivity","kind":"boolean","cli":{"flags":"--agent-activity","description":"Enable ClickClack agent activity"}},{"key":"useEnv","kind":"boolean","cli":{"flags":"--use-env","description":"Use CLICKCLACK_BOT_TOKEN"},"envVars":["CLICKCLACK_BOT_TOKEN"]}]}},"install":{"clawhubSpec":"clawhub:@openclaw/clickclack","npmSpec":"@openclaw/clickclack","defaultChoice":"npm","minHostVersion":">=2026.6.9","allowInvalidConfigRecovery":true}}},
{"name":"@openclaw/discord","version":"2026.9.6","description":"OpenClaw Discord channel plugin for channels, DMs, commands, and app events.","source":"official","kind":"channel","openclaw":{"contracts":{"transcriptSourceProviders":["discord-voice"]},"channel":{"id":"discord","configuredState":{"env":{"anyOf":["DISCORD_BOT_TOKEN"]}},"approvalFlags":["native"],"label":"Discord","selectionLabel":"Discord (Bot API)","detailLabel":"Discord Bot","docsPath":"/channels/discord","docsLabel":"discord","blurb":"very well supported right now.","systemImage":"bubble.left.and.bubble.right","markdownCapable":true,"preferSessionLookupForAnnounceTarget":true,"setup":{"fields":[{"key":"token","kind":"string","sensitive":true,"cli":{"flags":"--token <token>","description":"Discord bot token"}},{"key":"useEnv","kind":"boolean","cli":{"flags":"--use-env","description":"Use DISCORD_BOT_TOKEN"},"envVars":["DISCORD_BOT_TOKEN"]}]},"commands":{"nativeCommandsAutoEnabled":true,"nativeSkillsAutoEnabled":true},"doctorCapabilities":{"dmAllowFromMode":"topOnly","groupModel":"route","groupAllowFromFallbackToAllowFrom":false,"warnOnEmptyGroupSenderAllowlist":false}},"install":{"clawhubSpec":"clawhub:@openclaw/discord","npmSpec":"@openclaw/discord","defaultChoice":"npm","minHostVersion":">=2026.5.26","allowInvalidConfigRecovery":true}}},

View file

@ -1,5 +1,43 @@
{
"entries": [
{
"name": "@agentmail/agentmail",
"description": "Official AgentMail email channel and CLI-backed skill.",
"source": "external",
"kind": "channel",
"openclaw": {
"plugin": { "id": "agentmail", "label": "AgentMail" },
"channel": {
"id": "agentmail",
"label": "AgentMail",
"selectionLabel": "AgentMail (Email)",
"detailLabel": "AgentMail Email",
"docsPath": "https://www.agentmail.to/docs/integrations/openclaw",
"docsLabel": "agentmail",
"exposure": { "docs": false },
"blurb": "Durable, allowlisted, reply-only email conversations through AgentMail.",
"order": 89,
"quickstartAllowFrom": true,
"configuredState": {
"env": { "anyOf": ["AGENTMAIL_API_KEY"] }
}
},
"channelConfigs": {
"agentmail": {
"label": "AgentMail",
"description": "AgentMail email channel accounts and sender allowlist.",
"schema": { "type": "object", "additionalProperties": true }
}
},
"install": {
"clawhubSpec": "clawhub:@agentmail/agentmail@0.2.1",
"expectedIntegrity": "sha256:155221cec38673a39bc27629f9f6ec87567ce4e37b7fa619ec4b1f7ca3d28730",
"defaultChoice": "clawhub",
"minHostVersion": ">=2026.8.1-beta.2",
"allowInvalidConfigRecovery": true
}
}
},
{
"name": "@wecom/wecom-openclaw-plugin",
"description": "OpenClaw WeCom channel plugin by the Tencent WeCom team.",

View file

@ -22,6 +22,58 @@ describe("describePluginInstallSource", () => {
});
});
it.each([
"ab".repeat(32),
`sha256:${"ab".repeat(32)}`,
`sha256-${Buffer.alloc(32, 0xab).toString("base64")}`,
])("accepts ClawHub-only integrity metadata %s", (expectedIntegrity) => {
const source = describePluginInstallSource({
clawhubSpec: "clawhub:@vendor/demo@1.2.3",
expectedIntegrity,
defaultChoice: "clawhub",
});
expect(source.clawhub).toMatchObject({ packageName: "@vendor/demo", exactVersion: true });
expect(source.npm).toBeUndefined();
expect(source.warnings).toEqual([]);
});
it("does not let ClawHub conceal integrity on an invalid declared npm source", () => {
expect(
describePluginInstallSource({
clawhubSpec: "clawhub:@vendor/demo@1.2.3",
npmSpec: "github:vendor/demo",
expectedIntegrity: `sha256:${"ab".repeat(32)}`,
}).warnings,
).toEqual(["invalid-npm-spec", "npm-integrity-without-source"]);
});
it.each([
{},
{ localPath: "extensions/demo" },
{ clawhubSpec: "clawhub:@vendor/demo@1.2.3", expectedIntegrity: "not-a-hash" },
])("preserves warnings for unusable integrity metadata %j", (install) => {
expect(
describePluginInstallSource({
expectedIntegrity: `sha256:${"ab".repeat(32)}`,
...install,
}).warnings,
).toEqual(["npm-integrity-without-source"]);
});
it("keeps npm integrity ownership when both sources are declared", () => {
const source = describePluginInstallSource({
clawhubSpec: "clawhub:@vendor/demo@1.2.3",
npmSpec: "@vendor/demo@1.2.3",
expectedIntegrity: "sha512-demo",
defaultChoice: "clawhub",
});
expect(source.npm).toMatchObject({
expectedIntegrity: "sha512-demo",
pinState: "exact-with-integrity",
});
expect(source.warnings).toEqual([]);
});
it("marks exact npm specs with integrity as fully pinned", () => {
expect(
describePluginInstallSource({

View file

@ -1,5 +1,6 @@
/** Describes package-authored plugin install source metadata and pinning warnings. */
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { normalizeClawHubSha256Integrity } from "../infra/clawhub-integrity.js";
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import { isExactSemverVersion, parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import type {
@ -111,7 +112,10 @@ export function describePluginInstallSource(
) {
warnings.push("default-choice-missing-source");
}
if (expectedIntegrity && !npm) {
// Integrity belongs to npm when declared, otherwise to a ClawHub-only source.
const hasClawHubIntegrity =
!npmSpec && clawhub && expectedIntegrity && normalizeClawHubSha256Integrity(expectedIntegrity);
if (expectedIntegrity && !npm && !hasClawHubIntegrity) {
warnings.push("npm-integrity-without-source");
}

View file

@ -3,6 +3,7 @@ import path from "node:path";
import { afterAll, afterEach, describe, expect, it } from "vitest";
import { maybeRepairPluginRegistryState } from "../commands/doctor-plugin-registry.js";
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { normalizeClawHubSha256Integrity } from "../infra/clawhub-integrity.js";
import { resetPluginStateStoreForTests } from "../plugin-state/plugin-state-store.js";
import { withEnvAsync } from "../test-utils/env.js";
import { refreshPersistedInstalledPluginIndex } from "./installed-plugin-index-store-write.js";
@ -17,8 +18,14 @@ import {
} from "./loader.test-fixtures.js";
import { buildPluginInspectReport, buildPluginSnapshotReport } from "./status.js";
const pluginId = "diagnostics-otel";
const packageName = `@openclaw/${pluginId}`;
const defaultPluginId = "diagnostics-otel";
const defaultPackageName = `@openclaw/${defaultPluginId}`;
const agentMailIntegrity = normalizeClawHubSha256Integrity(
"sha256:155221cec38673a39bc27629f9f6ec87567ce4e37b7fa619ec4b1f7ca3d28730",
);
if (!agentMailIntegrity) {
throw new Error("Expected a valid AgentMail catalog integrity");
}
afterEach(() => {
resetPluginStateStoreForTests();
@ -31,7 +38,7 @@ describe("recorded plugin trust diagnostics", () => {
{ name: "legacy npm spec", override: {}, reason: "trusted-official", trusted: true },
{
name: "legacy ClawHub spec",
override: { source: "clawhub", spec: `clawhub:${packageName}@2026.8.2` },
override: { source: "clawhub", spec: `clawhub:${defaultPackageName}@2026.8.2` },
reason: "provenance-missing",
trusted: false,
repair: true,
@ -56,37 +63,113 @@ describe("recorded plugin trust diagnostics", () => {
reason: "origin-path",
trusted: false,
},
{
name: "official AgentMail ClawHub install",
pluginId: "agentmail",
packageName: "@agentmail/agentmail",
version: "0.2.1",
override: {
source: "clawhub",
spec: "clawhub:@agentmail/agentmail@0.2.1",
clawhubPackage: "@agentmail/agentmail",
clawhubUrl: "https://clawhub.ai",
clawhubChannel: "official",
},
reason: "trusted-official",
trusted: true,
},
{
name: "legacy AgentMail ClawHub install",
pluginId: "agentmail",
packageName: "@agentmail/agentmail",
version: "0.2.1",
override: { source: "clawhub", spec: "clawhub:@agentmail/agentmail@0.2.1" },
reason: "provenance-missing",
trusted: false,
repair: true,
repairTrusted: false,
},
{
name: "legacy AgentMail ClawHub install with matching integrity",
pluginId: "agentmail",
packageName: "@agentmail/agentmail",
version: "0.2.1",
override: {
source: "clawhub",
spec: "clawhub:@agentmail/agentmail@0.2.1",
integrity: agentMailIntegrity,
},
reason: "provenance-missing",
trusted: false,
repair: true,
repairTrusted: true,
},
{
name: "unendorsed AgentMail npm namesake",
pluginId: "agentmail",
packageName: "@agentmail/agentmail",
version: "0.2.1",
reason: "provenance-invalid",
trusted: false,
},
] satisfies Array<{
name: string;
pluginId?: string;
packageName?: string;
version?: string;
override?: Partial<PluginInstallRecord>;
missing?: boolean;
reason: string;
trusted: boolean;
repair?: boolean;
repairTrusted?: boolean;
}>)(
"inspection and registration agree for $name",
async ({ override, missing, reason, trusted, repair }) => {
async ({
override,
missing,
reason,
trusted,
repair,
repairTrusted,
pluginId = defaultPluginId,
packageName = defaultPackageName,
version = "2026.8.2",
}) => {
useNoBundledPlugins();
const stateDir = fs.realpathSync(makePluginLoaderTempDir());
const plugin = writePlugin({
id: pluginId,
dir: path.join(stateDir, "extensions", pluginId),
filename: "index.cjs",
body: `module.exports = { id: ${JSON.stringify(pluginId)}, register(api) { api.runtime.state.openKeyedStore({ namespace: "proof", maxEntries: 2 }); } };`,
body: `module.exports = { id: ${JSON.stringify(pluginId)}, register(api) {
const blocked = [];
try {
api.runtime.state.openKeyedStore({ namespace: "proof", maxEntries: 2 });
} catch (error) {
blocked.push("openKeyedStore: " + String(error));
}
try {
api.runtime.state.openChannelIngressQueue({ accountId: "default" });
} catch (error) {
blocked.push("openChannelIngressQueue: " + String(error));
}
if (blocked.length) throw new Error(blocked.join("; "));
} };`,
});
writePluginMetadata({
dir: plugin.dir,
id: plugin.id,
packageJson: {
name: packageName,
version: "2026.8.2",
version,
openclaw: { extensions: ["./index.cjs"] },
},
});
await withEnvAsync({ OPENCLAW_STATE_DIR: stateDir }, async () => {
const install: PluginInstallRecord = {
source: "npm",
spec: `${packageName}@2026.8.2`,
spec: `${packageName}@${version}`,
installPath: plugin.dir,
...override,
};
@ -119,6 +202,12 @@ describe("recorded plugin trust diagnostics", () => {
});
expect(loaded.status).toBe(trusted ? "loaded" : "error");
if (!trusted) {
expect(loaded.error).toContain(
"openKeyedStore is only available for trusted plugins in this release.",
);
expect(loaded.error).toContain(
"openChannelIngressQueue is only available for trusted plugins in this release.",
);
expect(loaded.error).toContain(`loaded from ${JSON.stringify(plugin.file)}`);
expect(loaded.error).toContain(`reason=${reason}`);
expect(loaded.error).toContain(
@ -144,10 +233,10 @@ describe("recorded plugin trust diagnostics", () => {
(entry) => entry.id === pluginId,
)!;
expect(repaired).toMatchObject({
status: "loaded",
trustedOfficialInstall: true,
trust: { reason: "trusted-official" },
status: repairTrusted === false ? "error" : "loaded",
trust: { reason: repairTrusted === false ? reason : "trusted-official" },
});
expect(repaired.trustedOfficialInstall === true).toBe(repairTrusted !== false);
expect(inspectedAfter.trust).toEqual(repaired.trust);
}
});

View file

@ -1,5 +1,6 @@
// Defines official external install records for plugins.
import type { PluginInstallRecord } from "../config/types.plugins.js";
import { normalizeClawHubSha256Integrity } from "../infra/clawhub-integrity.js";
import { parseClawHubPluginSpec } from "../infra/clawhub-spec.js";
import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js";
import {
@ -148,6 +149,7 @@ export function isTrustedOfficialPluginInstallRecord(params: {
function hasTrustedClawHubSourceAuthority(
record: PluginInstallRecord,
officialClawHubSpec: string | undefined,
officialExpectedIntegrity: string | undefined,
): boolean {
const hasAuthorityMetadata =
record.clawhubUrl !== undefined || record.clawhubChannel !== undefined;
@ -155,7 +157,19 @@ function hasTrustedClawHubSourceAuthority(
return isOfficialClawHubInstallRecord(record);
}
// Older official installs persisted only their catalog-backed ClawHub spec.
// Preserve that shipped shape, but do not let package-only records claim it.
// For a pinned catalog artifact, require the recorded digest before restoring
// authority; a matching package name alone cannot prove which code was installed.
if (officialExpectedIntegrity) {
const expectedIntegrity = normalizeClawHubSha256Integrity(officialExpectedIntegrity);
const recordedIntegrity = record.integrity
? normalizeClawHubSha256Integrity(record.integrity)
: null;
if (!expectedIntegrity || recordedIntegrity !== expectedIntegrity) {
return false;
}
}
// Preserve the old spec-only shape for unpinned entries, but do not let
// package-only records claim official provenance.
return Boolean(
officialClawHubSpec &&
record.spec &&
@ -374,7 +388,11 @@ export function resolveTrustedSourceLinkedOfficialClawHubInstall(params: {
}
const recordedPackageNames = resolveRecordedClawHubPackageNames(params.record);
if (
!hasTrustedClawHubSourceAuthority(params.record, officialClawHubSpec) ||
!hasTrustedClawHubSourceAuthority(
params.record,
officialClawHubSpec,
install?.expectedIntegrity,
) ||
!recordedPackageNames ||
recordedPackageNames.length === 0 ||
!recordedPackageNames.every((name) => officialNames.includes(name))

View file

@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import {
getOfficialExternalPluginCatalogEntry,
getOfficialExternalPluginCatalogEntryForPackage,
resolveOfficialExternalPluginInstall,
} from "./official-external-plugin-catalog.js";
describe("official AgentMail catalog entry", () => {
it("discovers AgentMail through its official ClawHub package without endorsing an npm namesake", () => {
const entry = getOfficialExternalPluginCatalogEntry("agentmail");
if (!entry) {
throw new Error("Expected AgentMail in the official external channel catalog");
}
expect(getOfficialExternalPluginCatalogEntryForPackage("@agentmail/agentmail")).toBe(entry);
expect(entry.kind).toBe("channel");
expect(entry.openclaw?.channel).toMatchObject({
configuredState: { env: { anyOf: ["AGENTMAIL_API_KEY"] } },
docsPath: "https://www.agentmail.to/docs/integrations/openclaw",
exposure: { docs: false },
});
expect(resolveOfficialExternalPluginInstall(entry)).toEqual({
clawhubSpec: "clawhub:@agentmail/agentmail@0.2.1",
expectedIntegrity: "sha256:155221cec38673a39bc27629f9f6ec87567ce4e37b7fa619ec4b1f7ca3d28730",
defaultChoice: "clawhub",
minHostVersion: ">=2026.8.1-beta.2",
allowInvalidConfigRecovery: true,
});
});
});

View file

@ -19,6 +19,7 @@ import {
writeOfficialChannelDocsIndex,
writeOfficialChannelCatalogSource,
} from "../scripts/write-official-channel-catalog.mts";
import { normalizeClawHubSha256Integrity } from "../src/infra/clawhub-integrity.js";
import { describePluginInstallSource } from "../src/plugins/install-source-info.js";
import { cleanupTempDirs, makeTempDir as makeTempRepoRoot } from "./helpers/temp-dir.js";
import { writeJsonFile } from "./helpers/temp-repo.js";
@ -88,7 +89,7 @@ function writeExternalChannelDocs(repoRoot: string): void {
};
for (const entry of seed.entries) {
const channel = entry.openclaw?.channel;
if (!channel?.docsPath || !channel.label) {
if (!channel?.docsPath?.startsWith("/") || !channel.label) {
continue;
}
const title = channel.id === "openclaw-weixin" ? "WeChat" : channel.label;
@ -683,7 +684,7 @@ describe("buildOfficialChannelCatalog", () => {
);
});
it("keeps third-party official external catalog npm sources pinned unless they track latest", () => {
it("keeps third-party official external catalog install sources pinned", () => {
const repoRoot = makeRepoRoot("openclaw-official-channel-catalog-policy-");
const entries = buildOfficialChannelCatalog({ repoRoot }).entries.filter(
(entry) => entry.source === "external" && !entry.name?.startsWith("@openclaw/"),
@ -691,9 +692,16 @@ describe("buildOfficialChannelCatalog", () => {
expect(entries.length).toBeGreaterThan(0);
for (const entry of entries) {
const installSource = describePluginInstallSource(requireInstall(entry));
const install = requireInstall(entry);
const installSource = describePluginInstallSource(install);
expect(installSource.warnings).toStrictEqual([]);
expect(requireNpmInstallSource(installSource).pinState).toBe("exact-with-integrity");
if (install.npmSpec) {
expect(requireNpmInstallSource(installSource).pinState).toBe("exact-with-integrity");
} else {
expect(installSource.npm).toBeUndefined();
expect(installSource.clawhub?.exactVersion).toBe(true);
expect(normalizeClawHubSha256Integrity(install.expectedIntegrity ?? "")).not.toBeNull();
}
}
});