diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5c74cc491..6f88d299f45 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -219,6 +219,7 @@ Docs: https://docs.openclaw.ai - Agents/usage normalization: normalize missing or partial assistant usage snapshots before compaction accounting so `openclaw agent --json` no longer crashes when provider payloads omit `totalTokens` or related usage fields. (#34977) thanks @sp-hk2ldn. - Venice/default model refresh: switch the built-in Venice default to `kimi-k2-5`, update onboarding aliasing, and refresh Venice provider docs/recommendations to match the current private and anonymized catalog. (from #12964) Fixes #20156. Thanks @sabrinaaquino and @vincentkoc. - Agents/skill API write pacing: add a global prompt guardrail that treats skill-driven external API writes as rate-limited by default, so runners prefer batched writes, avoid tight request loops, and respect `429`/`Retry-After`. Thanks @vincentkoc. +- Google Chat/multi-account webhook auth fallback: when `channels.googlechat.accounts.default` carries shared webhook audience/path settings (for example after config normalization), inherit those defaults for named accounts while preserving top-level and per-account overrides, so inbound webhook verification no longer fails silently for named accounts missing duplicated audience fields. Fixes #38369. ## 2026.3.2 diff --git a/extensions/googlechat/src/accounts.test.ts b/extensions/googlechat/src/accounts.test.ts new file mode 100644 index 00000000000..18256688971 --- /dev/null +++ b/extensions/googlechat/src/accounts.test.ts @@ -0,0 +1,131 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/googlechat"; +import { describe, expect, it } from "vitest"; +import { resolveGoogleChatAccount } from "./accounts.js"; + +describe("resolveGoogleChatAccount", () => { + it("inherits shared defaults from accounts.default for named accounts", () => { + const cfg: OpenClawConfig = { + channels: { + googlechat: { + accounts: { + default: { + audienceType: "app-url", + audience: "https://example.com/googlechat", + webhookPath: "/googlechat", + }, + andy: { + serviceAccountFile: "/tmp/andy-sa.json", + }, + }, + }, + }, + }; + + const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" }); + expect(resolved.config.audienceType).toBe("app-url"); + expect(resolved.config.audience).toBe("https://example.com/googlechat"); + expect(resolved.config.webhookPath).toBe("/googlechat"); + expect(resolved.config.serviceAccountFile).toBe("/tmp/andy-sa.json"); + }); + + it("prefers top-level and account overrides over accounts.default", () => { + const cfg: OpenClawConfig = { + channels: { + googlechat: { + audienceType: "project-number", + audience: "1234567890", + accounts: { + default: { + audienceType: "app-url", + audience: "https://default.example.com/googlechat", + webhookPath: "/googlechat-default", + }, + april: { + webhookPath: "/googlechat-april", + }, + }, + }, + }, + }; + + const resolved = resolveGoogleChatAccount({ cfg, accountId: "april" }); + expect(resolved.config.audienceType).toBe("project-number"); + expect(resolved.config.audience).toBe("1234567890"); + expect(resolved.config.webhookPath).toBe("/googlechat-april"); + }); + + it("does not inherit disabled state from accounts.default for named accounts", () => { + const cfg: OpenClawConfig = { + channels: { + googlechat: { + accounts: { + default: { + enabled: false, + audienceType: "app-url", + audience: "https://example.com/googlechat", + }, + andy: { + serviceAccountFile: "/tmp/andy-sa.json", + }, + }, + }, + }, + }; + + const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" }); + expect(resolved.enabled).toBe(true); + expect(resolved.config.enabled).toBeUndefined(); + expect(resolved.config.audienceType).toBe("app-url"); + }); + + it("does not inherit default-account credentials into named accounts", () => { + const cfg: OpenClawConfig = { + channels: { + googlechat: { + accounts: { + default: { + serviceAccountRef: { + source: "env", + provider: "test", + id: "default-sa", + }, + audienceType: "app-url", + audience: "https://example.com/googlechat", + }, + andy: { + serviceAccountFile: "/tmp/andy-sa.json", + }, + }, + }, + }, + }; + + const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" }); + expect(resolved.credentialSource).toBe("file"); + expect(resolved.credentialsFile).toBe("/tmp/andy-sa.json"); + expect(resolved.config.audienceType).toBe("app-url"); + }); + + it("does not inherit dangerous name matching from accounts.default", () => { + const cfg: OpenClawConfig = { + channels: { + googlechat: { + accounts: { + default: { + dangerouslyAllowNameMatching: true, + audienceType: "app-url", + audience: "https://example.com/googlechat", + }, + andy: { + serviceAccountFile: "/tmp/andy-sa.json", + }, + }, + }, + }, + }; + + const resolved = resolveGoogleChatAccount({ cfg, accountId: "andy" }); + expect(resolved.config.dangerouslyAllowNameMatching).toBeUndefined(); + expect(resolved.config.audienceType).toBe("app-url"); + }); +}); diff --git a/extensions/googlechat/src/accounts.ts b/extensions/googlechat/src/accounts.ts index 537c898d77e..f597efbece4 100644 --- a/extensions/googlechat/src/accounts.ts +++ b/extensions/googlechat/src/accounts.ts @@ -71,8 +71,22 @@ function mergeGoogleChatAccountConfig( ): GoogleChatAccountConfig { const raw = cfg.channels?.["googlechat"] ?? {}; const { accounts: _ignored, defaultAccount: _ignored2, ...base } = raw; + const defaultAccountConfig = resolveAccountConfig(cfg, DEFAULT_ACCOUNT_ID) ?? {}; const account = resolveAccountConfig(cfg, accountId) ?? {}; - return { ...base, ...account } as GoogleChatAccountConfig; + if (accountId === DEFAULT_ACCOUNT_ID) { + return { ...base, ...defaultAccountConfig } as GoogleChatAccountConfig; + } + const { + enabled: _ignoredEnabled, + dangerouslyAllowNameMatching: _ignoredDangerouslyAllowNameMatching, + serviceAccount: _ignoredServiceAccount, + serviceAccountRef: _ignoredServiceAccountRef, + serviceAccountFile: _ignoredServiceAccountFile, + ...defaultAccountShared + } = defaultAccountConfig; + // In multi-account setups, allow accounts.default to provide shared defaults + // (for example webhook/audience fields) while preserving top-level and account overrides. + return { ...defaultAccountShared, ...base, ...account } as GoogleChatAccountConfig; } function parseServiceAccount(value: unknown): Record | null {