From 2bd352b73d7a32d12bd050bbe8c6a4178b01937e Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 14:26:58 +0300 Subject: [PATCH 001/200] test(233-01): add failing test for classifyGoogleChatError taxonomy - Per-status classification: undefined->network, 401/403->auth, 429->platform, >=500->platform, other 4xx->internal - Secret-safety: a secret-bearing cause never appears in the hint - RED: errors.ts does not exist yet --- .../channels/src/googlechat/errors.test.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 packages/channels/src/googlechat/errors.test.ts diff --git a/packages/channels/src/googlechat/errors.test.ts b/packages/channels/src/googlechat/errors.test.ts new file mode 100644 index 000000000..95f5413a4 --- /dev/null +++ b/packages/channels/src/googlechat/errors.test.ts @@ -0,0 +1,61 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect } from "vitest"; +import { classifyGoogleChatError } from "./errors.js"; + +describe("classifyGoogleChatError", () => { + it("classifies an absent status (transport failure) as a retryable network error", () => { + const classified = classifyGoogleChatError(undefined); + expect(classified.errorKind).toBe("network"); + expect(classified.retryable).toBe(true); + expect(classified.status).toBeUndefined(); + expect(classified.hint.toLowerCase()).toContain("connectivity"); + }); + + it("classifies a 401 response as a non-retryable auth failure that names the grant/scope", () => { + const classified = classifyGoogleChatError(401); + expect(classified.errorKind).toBe("auth"); + expect(classified.retryable).toBe(false); + expect(classified.status).toBe(401); + expect(classified.hint.trim().length).toBeGreaterThan(0); + expect(classified.hint.toLowerCase()).toContain("scope"); + }); + + it("classifies a 403 response as a non-retryable auth failure", () => { + const classified = classifyGoogleChatError(403); + expect(classified.errorKind).toBe("auth"); + expect(classified.retryable).toBe(false); + expect(classified.status).toBe(403); + }); + + it("classifies a 429 response as a retryable platform failure that surfaces the status", () => { + const classified = classifyGoogleChatError(429); + expect(classified.errorKind).toBe("platform"); + expect(classified.retryable).toBe(true); + expect(classified.status).toBe(429); + expect(classified.hint.trim().length).toBeGreaterThan(0); + }); + + it("classifies 500, 502 and 503 responses as retryable platform failures", () => { + for (const status of [500, 502, 503]) { + const classified = classifyGoogleChatError(status); + expect(classified.errorKind).toBe("platform"); + expect(classified.retryable).toBe(true); + expect(classified.status).toBe(status); + } + }); + + it("classifies an unexpected 4xx status as a non-retryable internal error", () => { + for (const status of [400, 404]) { + const classified = classifyGoogleChatError(status); + expect(classified.errorKind).toBe("internal"); + expect(classified.retryable).toBe(false); + expect(classified.status).toBe(status); + } + }); + + it("returns an operator-actionable hint that never echoes a secret-bearing cause", () => { + const classified = classifyGoogleChatError(401, new Error("token=SECRET")); + expect(classified.hint.trim().length).toBeGreaterThan(0); + expect(classified.hint).not.toContain("SECRET"); + }); +}); From a9a6732d11251004971f67edf5aa7c1949d8e589 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 14:28:11 +0300 Subject: [PATCH 002/200] feat(233-01): implement classifyGoogleChatError taxonomy - Pure status->errorKind classifier: closed union auth|platform|network|precondition|internal + retryable + secret-free hint - Branch order: undefined->network, 401/403->auth, 429->platform, >=500->platform, else->internal - void cause: the originating error is never rendered into the hint - No throw for control flow; no @comis/infra import (logger injected elsewhere) --- packages/channels/src/googlechat/errors.ts | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 packages/channels/src/googlechat/errors.ts diff --git a/packages/channels/src/googlechat/errors.ts b/packages/channels/src/googlechat/errors.ts new file mode 100644 index 000000000..7ea36dadb --- /dev/null +++ b/packages/channels/src/googlechat/errors.ts @@ -0,0 +1,97 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat error taxonomy: a pure classifier that maps a Chat / Pub/Sub REST + * or token-endpoint HTTP status (or a transport-level failure) onto the closed + * observability errorKind union, a retry disposition, and an operator-actionable, + * origin-free hint. + * + * Deliberately minimal — the token mint, the pull loop, and the outbound send + * path consult it to attach `errorKind` / `hint` on their failure branches. It + * reads only the numeric status; an optional cause is accepted for context but + * never rendered into the hint (a cause may carry secret-bearing text). + * + * @module + */ + +/** The subset of the observability errorKind union this taxonomy emits. */ +export type GoogleChatErrorKind = + | "auth" + | "platform" + | "network" + | "precondition" + | "internal"; + +/** A classified platform failure: kind, retry disposition, status, and hint. */ +export interface ClassifiedGoogleChatError { + /** The observability error kind. */ + errorKind: GoogleChatErrorKind; + /** Whether retrying the same request could plausibly succeed. */ + retryable: boolean; + /** The HTTP status, when a response was received. */ + status?: number; + /** An operator-actionable next step. Never carries a secret. */ + hint: string; +} + +/** + * Classify a platform failure by its HTTP status. + * + * - `401` / `403` → `auth`, non-retryable — bad credentials, missing scope, or + * the service account is not authorized for the space/subscription; retrying + * without fixing the grant will not help. + * - `429` → `platform`, retryable — rate limited; back off then retry. + * - `>= 500` → `platform`, retryable — transient upstream error. + * - `undefined` → `network`, retryable — no response reached us (transport fault). + * - any other status (e.g. an unexpected 4xx) → `internal`, non-retryable — a + * malformed request is our own defect, not a transient condition. + * + * @param status - The HTTP status of the response, or undefined for a + * transport-level failure where no response was received. + * @param cause - The originating error, accepted for context but never + * interpolated into the hint (it may carry secret-bearing text). + */ +export function classifyGoogleChatError( + status: number | undefined, + cause?: unknown, +): ClassifiedGoogleChatError { + // `cause` is intentionally not read into the hint: it may carry secrets. + void cause; + + if (status === undefined) { + return { + errorKind: "network", + retryable: true, + hint: "Check outbound connectivity to oauth2.googleapis.com / chat.googleapis.com / pubsub.googleapis.com, then retry", + }; + } + if (status === 401 || status === 403) { + return { + errorKind: "auth", + retryable: false, + status, + hint: "Verify the service-account key, its scopes (chat.bot / pubsub), and that the SA has roles/pubsub.subscriber on the subscription", + }; + } + if (status === 429) { + return { + errorKind: "platform", + retryable: true, + status, + hint: "Rate limited — back off and retry after the indicated window", + }; + } + if (status >= 500) { + return { + errorKind: "platform", + retryable: true, + status, + hint: "Upstream Google service error — retry with backoff", + }; + } + return { + errorKind: "internal", + retryable: false, + status, + hint: "Unexpected response status — inspect the request shape and payload", + }; +} From 0411a8017a6a2d5e5da397c774e7473cb4536052 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 14:39:37 +0300 Subject: [PATCH 003/200] test(233-02): add failing test for mapGoogleChatEventToNormalized - per-case coverage: both DM encodings (spaceType + legacy type), space/group, message.space precedence, senderId fallbacks, argumentText-preferred, USER_MENTION -> wasMentioned, thread capture, non-MESSAGE null - pins the non-empty channelId sentinel (never "") and a parseMessage round-trip --- .../src/googlechat/message-mapper.test.ts | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 packages/channels/src/googlechat/message-mapper.test.ts diff --git a/packages/channels/src/googlechat/message-mapper.test.ts b/packages/channels/src/googlechat/message-mapper.test.ts new file mode 100644 index 000000000..fb33976be --- /dev/null +++ b/packages/channels/src/googlechat/message-mapper.test.ts @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect } from "vitest"; +import { parseMessage } from "@comis/core"; +import { mapGoogleChatEventToNormalized, type GoogleChatEvent } from "./message-mapper.js"; + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Build a minimal MESSAGE interaction event; each test overrides only the + * fields it exercises. The base is a space (group) event with a full sender. + */ +function makeChatEvent(overrides: Partial = {}): GoogleChatEvent { + return { + type: "MESSAGE", + eventTime: "2026-07-05T00:00:00Z", + user: { name: "users/sender-1" }, + space: { name: "spaces/AAAA", spaceType: "SPACE" }, + message: { + name: "spaces/AAAA/messages/BBBB", + sender: { name: "users/sender-1" }, + text: "hello", + }, + ...overrides, + }; +} + +describe("mapGoogleChatEventToNormalized", () => { + it("returns null for a non-MESSAGE event", () => { + expect(mapGoogleChatEventToNormalized(makeChatEvent({ type: "ADDED_TO_SPACE" }))).toBeNull(); + }); + + it("returns null for an event with no message payload", () => { + expect(mapGoogleChatEventToNormalized(makeChatEvent({ message: undefined }))).toBeNull(); + }); + + it("maps a DM via the current spaceType enum to chatType dm (isGroup false)", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ space: { name: "spaces/DM1", spaceType: "DIRECT_MESSAGE" } }), + ); + expect(result?.chatType).toBe("dm"); + expect(result?.metadata.isGroup).toBe(false); + }); + + it("maps a DM via the legacy type enum to chatType dm (isGroup false)", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ space: { name: "spaces/DM2", type: "DM" } }), + ); + expect(result?.chatType).toBe("dm"); + expect(result?.metadata.isGroup).toBe(false); + }); + + it("maps a space to chatType group (isGroup true)", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ space: { name: "spaces/SP1", spaceType: "SPACE" } }), + ); + expect(result?.chatType).toBe("group"); + expect(result?.metadata.isGroup).toBe(true); + }); + + it("prefers message.space over the top-level event.space", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + space: { name: "spaces/OUTER", spaceType: "SPACE" }, + message: { + name: "spaces/INNER/messages/1", + sender: { name: "users/s" }, + space: { name: "spaces/INNER", spaceType: "DIRECT_MESSAGE" }, + }, + }), + ); + expect(result?.channelId).toBe("spaces/INNER"); + expect(result?.chatType).toBe("dm"); + }); + + it("sets channelId to the space resource name and senderId to the message sender", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + space: { name: "spaces/CID", spaceType: "SPACE" }, + message: { name: "spaces/CID/messages/1", sender: { name: "users/999" }, text: "hi" }, + }), + ); + expect(result?.channelId).toBe("spaces/CID"); + expect(result?.senderId).toBe("users/999"); + expect(result?.channelType).toBe("googlechat"); + }); + + it("falls back to event.user.name for senderId when message.sender is absent", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + user: { name: "users/fallback" }, + message: { name: "spaces/AAAA/messages/1", text: "hi" }, + }), + ); + expect(result?.senderId).toBe("users/fallback"); + }); + + it("uses the 'unknown' sentinel for senderId when neither sender nor user name is present", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + user: undefined, + message: { name: "spaces/AAAA/messages/1", text: "hi" }, + }), + ); + expect(result?.senderId).toBe("unknown"); + }); + + it("yields a non-empty channelId sentinel when the MESSAGE event omits space.name", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + space: undefined, + message: { name: "spaces/X/messages/1", sender: { name: "users/1" }, text: "hi" }, + }), + ); + expect(result?.channelId).toBe("spaces/unknown"); + expect((result?.channelId ?? "").length).toBeGreaterThanOrEqual(1); + }); + + it("prefers argumentText over text", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { + name: "spaces/AAAA/messages/1", + sender: { name: "users/1" }, + text: "@bot raw text", + argumentText: "clean text", + }, + }), + ); + expect(result?.text).toBe("clean text"); + }); + + it("falls back to text when argumentText is absent", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { name: "spaces/AAAA/messages/1", sender: { name: "users/1" }, text: "plain" }, + }), + ); + expect(result?.text).toBe("plain"); + }); + + it("emits an empty text when neither argumentText nor text is present", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { name: "spaces/AAAA/messages/1", sender: { name: "users/1" } }, + }), + ); + expect(result?.text).toBe(""); + }); + + it("sets metadata.wasMentioned true when a USER_MENTION annotation is present", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { + name: "spaces/AAAA/messages/1", + sender: { name: "users/1" }, + text: "hi", + annotations: [{ type: "USER_MENTION" }], + }, + }), + ); + expect(result?.metadata.wasMentioned).toBe(true); + }); + + it("sets metadata.wasMentioned false when no USER_MENTION annotation is present", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { + name: "spaces/AAAA/messages/1", + sender: { name: "users/1" }, + text: "hi", + annotations: [{ type: "SLASH_COMMAND" }], + }, + }), + ); + expect(result?.metadata.wasMentioned).toBe(false); + }); + + it("captures the thread resource name under a googlechat metadata key", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { + name: "spaces/AAAA/messages/1", + sender: { name: "users/1" }, + text: "hi", + thread: { name: "spaces/AAAA/threads/TTTT" }, + }, + }), + ); + expect(result?.metadata.googlechatThreadId).toBe("spaces/AAAA/threads/TTTT"); + }); + + it("omits the thread metadata key when no thread is present", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { name: "spaces/AAAA/messages/1", sender: { name: "users/1" }, text: "hi" }, + }), + ); + expect(result?.metadata.googlechatThreadId).toBeUndefined(); + }); + + it("emits a UUID id, an empty attachments array, and a positive integer timestamp", () => { + const result = mapGoogleChatEventToNormalized(makeChatEvent()); + expect(result?.id).toMatch(UUID_RE); + expect(result?.attachments).toEqual([]); + expect(result?.timestamp).toBeGreaterThan(0); + expect(Number.isInteger(result?.timestamp ?? 0)).toBe(true); + }); + + it("produces a schema-valid NormalizedMessage (round-trips through parseMessage)", () => { + const result = mapGoogleChatEventToNormalized(makeChatEvent()); + expect(result).not.toBeNull(); + const parsed = parseMessage(result); + expect(parsed.ok).toBe(true); + }); + + it("produces a schema-valid NormalizedMessage even when space.name is omitted", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ space: undefined, message: { name: "spaces/X/messages/1", text: "hi" } }), + ); + const parsed = parseMessage(result); + expect(parsed.ok).toBe(true); + }); +}); From e71a773bf66962e7488b6a4c911ed5cf475ea855 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 14:41:13 +0300 Subject: [PATCH 004/200] feat(233-02): implement mapGoogleChatEventToNormalized - pure, transport-free Chat interaction event -> NormalizedMessage - both DM encodings (spaceType DIRECT_MESSAGE and legacy type DM) -> chatType dm - argumentText preferred over text; senderId from sender.name (users/{id}) - USER_MENTION annotation -> metadata.wasMentioned; thread name captured - non-empty channelId sentinel keeps a space-name-less event schema-valid --- .../channels/src/googlechat/message-mapper.ts | 111 ++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 packages/channels/src/googlechat/message-mapper.ts diff --git a/packages/channels/src/googlechat/message-mapper.ts b/packages/channels/src/googlechat/message-mapper.ts new file mode 100644 index 000000000..44bce92a3 --- /dev/null +++ b/packages/channels/src/googlechat/message-mapper.ts @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat Message Mapper: converts a classic Chat interaction event into a + * NormalizedMessage. + * + * Pure and transport-free — the pull loop (or webhook ingress) hands a plain, + * already-decoded event object here, so the mapping is unit-testable without + * any HTTP layer. It is the single point that decides the routing identity the + * inbound path keys on: + * + * - space.spaceType DIRECT_MESSAGE (or legacy space.type DM) -> chatType "dm" + * with metadata.isGroup false; everything else is a "group" space + * - space.name -> channelId (the space resource name "spaces/AAAA") + * - message.sender.name -> senderId (the immutable "users/{id}" resource name, + * never a display name — it is what the sender allowlist gate keys on) + * - message.argumentText (mention already stripped by the platform) preferred + * over message.text + * - a USER_MENTION annotation -> metadata.wasMentioned + * - message.thread.name -> metadata.googlechatThreadId (captured for routing; + * the mapper does not itself reply into a thread) + * + * Returns null for non-MESSAGE events (ADDED_TO_SPACE, CARD_CLICKED, …) and for + * a MESSAGE event with no message payload, so the adapter early-returns on them. + * The mapper never fetches a URL and never executes content; it only normalizes + * untrusted JSON into the bounded NormalizedMessage the downstream path wraps. + * + * @module + */ + +import type { NormalizedMessage } from "@comis/core"; +import { systemNowMs } from "@comis/core"; +import { randomUUID } from "node:crypto"; + +/** + * Minimal classic Chat interaction-event shape — only the fields the inbound + * path reads. Deliberately loose: the platform sends many more fields we ignore. + */ +export interface GoogleChatEvent { + /** Event kind: "MESSAGE" | "ADDED_TO_SPACE" | "CARD_CLICKED" | … */ + type?: string; + eventTime?: string; + /** The acting user; a fallback source for senderId. */ + user?: { name?: string }; + /** + * Top-level space. The current `spaceType` enum + * (DIRECT_MESSAGE | SPACE | GROUP_CHAT) replaced the deprecated `type` enum + * (DM | ROOM); an inbound event may carry either, so both are read. + */ + space?: { name?: string; type?: string; spaceType?: string }; + message?: { + /** "spaces/X/messages/Y" — the resource name used for dedup upstream. */ + name?: string; + /** "users/123" — the immutable sender resource id. */ + sender?: { name?: string }; + text?: string; + /** Mention pre-stripped by the platform — preferred over `text`. */ + argumentText?: string; + /** "spaces/X/threads/Z" — captured, not replied into here. */ + thread?: { name?: string }; + annotations?: Array<{ type?: string }>; + /** Per-message space; takes precedence over the top-level `space`. */ + space?: { name?: string; type?: string; spaceType?: string }; + }; +} + +/** The non-empty sentinel space name. */ +const UNKNOWN_SPACE = "spaces/unknown"; +/** The non-empty sentinel sender id. */ +const UNKNOWN_SENDER = "unknown"; + +/** + * Map a classic Chat interaction event to a NormalizedMessage. + * + * @param event - A decoded classic Chat interaction event + * @returns A NormalizedMessage for MESSAGE events; null otherwise + */ +export function mapGoogleChatEventToNormalized( + event: GoogleChatEvent, +): NormalizedMessage | null { + if (event.type !== "MESSAGE" || !event.message) return null; + + const message = event.message; + const space = message.space ?? event.space; + // Accept both the current and the legacy DM encoding; anything else is a + // multi-person space (a "group"). + const isDm = space?.spaceType === "DIRECT_MESSAGE" || space?.type === "DM"; + const wasMentioned = (message.annotations ?? []).some((a) => a.type === "USER_MENTION"); + + const metadata: Record = { isGroup: !isDm, wasMentioned }; + // Capture the thread resource name for routing; replying into the thread is + // handled elsewhere on the send path. + if (message.thread?.name) metadata.googlechatThreadId = message.thread.name; + + return { + id: randomUUID(), + // channelId is a required, non-empty field; a malformed MESSAGE event that + // omits the space name still maps to the non-empty sentinel so the message + // stays schema-valid rather than being silently dropped. + channelId: space?.name ?? UNKNOWN_SPACE, + channelType: "googlechat", + senderId: message.sender?.name ?? event.user?.name ?? UNKNOWN_SENDER, + // The platform strips the app mention into argumentText; prefer it so the + // text is the faithful command without hand-stripping. + text: message.argumentText ?? message.text ?? "", + timestamp: systemNowMs(), + // Inbound media is resolved downstream; the mapper emits no attachments. + attachments: [], + chatType: isDm ? "dm" : "group", + metadata, + }; +} From 9ed8e0a12a23e3d9582285718a287e51d3899f9b Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 14:49:24 +0300 Subject: [PATCH 005/200] test(233-03): add failing test for GoogleChatChannelEntrySchema - strict-object defaults (mode pubsub, allowMode allowlist, audienceType project-number, missedInboundThresholdMs 6h with 1-min floor) - serviceAccountKey accepts a plain string OR a SecretRef - rejects unknown keys incl. an ackReaction key - ChannelConfigSchema.googlechat self-defaulting wiring --- .../core/src/config/schema-channel.test.ts | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/packages/core/src/config/schema-channel.test.ts b/packages/core/src/config/schema-channel.test.ts index e2c531875..038f783a0 100644 --- a/packages/core/src/config/schema-channel.test.ts +++ b/packages/core/src/config/schema-channel.test.ts @@ -9,6 +9,7 @@ import { IrcChannelEntrySchema, EmailChannelEntrySchema, MsTeamsChannelEntrySchema, + GoogleChatChannelEntrySchema, } from "./schema-channel.js"; describe("ChannelEntrySchema", () => { @@ -471,6 +472,101 @@ describe("MsTeamsChannelEntrySchema", () => { }); }); +// --------------------------------------------------------------------------- +// Google Chat channel entry schema +// --------------------------------------------------------------------------- + +describe("GoogleChatChannelEntrySchema", () => { + it("produces pubsub-mode defaults with the channel disabled", () => { + const result = GoogleChatChannelEntrySchema.safeParse({}); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.enabled).toBe(false); + expect(result.data.mode).toBe("pubsub"); + expect(result.data.audienceType).toBe("project-number"); + expect(result.data.allowFrom).toEqual([]); + expect(result.data.allowMode).toBe("allowlist"); + expect(result.data.missedInboundThresholdMs).toBe(21_600_000); + // Optional fields are undefined until an operator supplies them. + expect(result.data.serviceAccountKey).toBeUndefined(); + expect(result.data.subscriptionName).toBeUndefined(); + expect(result.data.audience).toBeUndefined(); + expect(result.data.mediaProcessing).toBeUndefined(); + } + }); + + it("parses a full pubsub-mode block with serviceAccountKey, subscriptionName and allowlist", () => { + const result = GoogleChatChannelEntrySchema.safeParse({ + enabled: true, + mode: "pubsub", + serviceAccountKey: "sa-json-string", + subscriptionName: "projects/p/subscriptions/s", + allowFrom: ["users/123"], + allowMode: "allowlist", + }); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.enabled).toBe(true); + expect(result.data.serviceAccountKey).toBe("sa-json-string"); + expect(result.data.subscriptionName).toBe("projects/p/subscriptions/s"); + expect(result.data.allowFrom).toEqual(["users/123"]); + } + }); + + it("accepts serviceAccountKey as a plain string AND as a SecretRef object", () => { + const asString = GoogleChatChannelEntrySchema.safeParse({ + enabled: true, + serviceAccountKey: "plaintext-sa-key", + }); + expect(asString.success).toBe(true); + if (asString.success) { + expect(asString.data.serviceAccountKey).toBe("plaintext-sa-key"); + } + + const asSecretRef = GoogleChatChannelEntrySchema.safeParse({ + enabled: true, + serviceAccountKey: { source: "env", provider: "googlechat", id: "GOOGLECHAT_SA_KEY" }, + }); + expect(asSecretRef.success).toBe(true); + if (asSecretRef.success) { + expect(asSecretRef.data.serviceAccountKey).toEqual({ + source: "env", + provider: "googlechat", + id: "GOOGLECHAT_SA_KEY", + }); + } + }); + + it("rejects an unknown key inside the entry (strict object)", () => { + expect(GoogleChatChannelEntrySchema.safeParse({ enabled: true, bogus: 1 }).success).toBe(false); + }); + + it("rejects an ackReaction key (reactions are not part of this schema)", () => { + // Reactions are user-auth-only on this platform, so no ack-reaction knob exists; + // the strict object rejects the key rather than silently ignoring it. + expect(GoogleChatChannelEntrySchema.safeParse({ enabled: true, ackReaction: {} }).success).toBe(false); + }); + + it("rejects a mode outside pubsub and webhook", () => { + expect(GoogleChatChannelEntrySchema.safeParse({ enabled: true, mode: "grpc" }).success).toBe(false); + }); + + it("rejects an audienceType outside project-number and app-url", () => { + expect(GoogleChatChannelEntrySchema.safeParse({ enabled: true, audienceType: "email" }).success).toBe(false); + }); + + it("rejects an allowMode outside allowlist and open", () => { + expect(GoogleChatChannelEntrySchema.safeParse({ enabled: true, allowMode: "denylist" }).success).toBe(false); + }); + + it("defaults missedInboundThresholdMs to six hours and floors it at one minute", () => { + expect(GoogleChatChannelEntrySchema.parse({}).missedInboundThresholdMs).toBe(21_600_000); + expect(GoogleChatChannelEntrySchema.safeParse({ missedInboundThresholdMs: 59_999 }).success).toBe(false); + expect(GoogleChatChannelEntrySchema.safeParse({ missedInboundThresholdMs: 60_000 }).success).toBe(true); + expect(GoogleChatChannelEntrySchema.safeParse({ missedInboundThresholdMs: 21_600_000 }).success).toBe(true); + }); +}); + // --------------------------------------------------------------------------- // Top-level channel config // --------------------------------------------------------------------------- @@ -582,4 +678,34 @@ describe("ChannelConfigSchema", () => { it("rejects an unknown key inside the nested msteams block", () => { expect(() => ChannelConfigSchema.parse({ msteams: { enabled: true, bogusKey: 1 } })).toThrow(); }); + + it("defaults the googlechat entry to disabled pubsub-mode with a project-number audience", () => { + const parsed = ChannelConfigSchema.parse({}); + expect(parsed.googlechat.enabled).toBe(false); + expect(parsed.googlechat.mode).toBe("pubsub"); + expect(parsed.googlechat.audienceType).toBe("project-number"); + expect(parsed.googlechat.allowFrom).toEqual([]); + expect(parsed.googlechat.allowMode).toBe("allowlist"); + expect(parsed.googlechat.missedInboundThresholdMs).toBe(21_600_000); + }); + + it("parses a full googlechat block supplied under the channels config", () => { + const parsed = ChannelConfigSchema.parse({ + googlechat: { + enabled: true, + mode: "pubsub", + serviceAccountKey: "sa-json", + subscriptionName: "projects/p/subscriptions/s", + allowFrom: ["users/123"], + allowMode: "allowlist", + }, + }); + expect(parsed.googlechat.enabled).toBe(true); + expect(parsed.googlechat.subscriptionName).toBe("projects/p/subscriptions/s"); + expect(parsed.googlechat.allowFrom).toEqual(["users/123"]); + }); + + it("rejects an unknown key inside the nested googlechat block", () => { + expect(() => ChannelConfigSchema.parse({ googlechat: { enabled: true, bogusKey: 1 } })).toThrow(); + }); }); From 0dd96f1453bd702d2cf8ce77201759377aeb4458 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 14:51:27 +0300 Subject: [PATCH 006/200] feat(233-03): implement GoogleChatChannelEntrySchema - strict object mirroring MsTeamsChannelEntrySchema: pubsub-default mode, serviceAccountKey as SecretRefOrString, allowlist default-deny, 6h missed-inbound liveness window floored at 1 min; no ackReaction - wire googlechat into ChannelConfigSchema (self-defaulting) + export the inferred GoogleChatChannelEntry type - regenerate section-registry parity snapshots (config-metadata surface now carries the googlechat entry) --- .../section-registry-parity.test.ts.snap | 518 ++++++++++++++++++ packages/core/src/config/schema-channel.ts | 25 + 2 files changed, 543 insertions(+) diff --git a/packages/core/src/config/__snapshots__/section-registry-parity.test.ts.snap b/packages/core/src/config/__snapshots__/section-registry-parity.test.ts.snap index 74381e203..519561c99 100644 --- a/packages/core/src/config/__snapshots__/section-registry-parity.test.ts.snap +++ b/packages/core/src/config/__snapshots__/section-registry-parity.test.ts.snap @@ -4004,6 +4004,120 @@ exports[`section-registry parity > field-metadata view > getFieldMetadata("chann "path": "channels.email.webhookUrl", "type": "string" }, + { + "default": { + "allowFrom": [], + "allowMode": "allowlist", + "audienceType": "project-number", + "enabled": false, + "missedInboundThresholdMs": 21600000, + "mode": "pubsub" + }, + "immutable": true, + "path": "channels.googlechat", + "type": "object" + }, + { + "default": [], + "immutable": true, + "path": "channels.googlechat.allowFrom", + "type": "array" + }, + { + "default": "allowlist", + "immutable": true, + "path": "channels.googlechat.allowMode", + "type": "string" + }, + { + "immutable": true, + "path": "channels.googlechat.audience", + "type": "string" + }, + { + "default": "project-number", + "immutable": true, + "path": "channels.googlechat.audienceType", + "type": "string" + }, + { + "default": false, + "immutable": true, + "path": "channels.googlechat.enabled", + "type": "boolean" + }, + { + "immutable": false, + "path": "channels.googlechat.mediaProcessing", + "type": "object" + }, + { + "default": true, + "immutable": false, + "path": "channels.googlechat.mediaProcessing.analyzeImages", + "type": "boolean" + }, + { + "default": true, + "immutable": false, + "path": "channels.googlechat.mediaProcessing.describeVideos", + "type": "boolean" + }, + { + "default": true, + "immutable": false, + "path": "channels.googlechat.mediaProcessing.extractDocuments", + "type": "boolean" + }, + { + "default": true, + "immutable": false, + "path": "channels.googlechat.mediaProcessing.transcribeAudio", + "type": "boolean" + }, + { + "default": true, + "immutable": false, + "path": "channels.googlechat.mediaProcessing.understandLinks", + "type": "boolean" + }, + { + "default": 21600000, + "immutable": true, + "path": "channels.googlechat.missedInboundThresholdMs", + "type": "integer" + }, + { + "default": "pubsub", + "immutable": true, + "path": "channels.googlechat.mode", + "type": "string" + }, + { + "immutable": true, + "path": "channels.googlechat.serviceAccountKey", + "type": "string" + }, + { + "immutable": true, + "path": "channels.googlechat.serviceAccountKey.id", + "type": "string" + }, + { + "immutable": true, + "path": "channels.googlechat.serviceAccountKey.provider", + "type": "string" + }, + { + "immutable": true, + "path": "channels.googlechat.serviceAccountKey.source", + "type": "string" + }, + { + "immutable": true, + "path": "channels.googlechat.subscriptionName", + "type": "string" + }, { "default": { "autoRestartOnStale": false, @@ -8968,6 +9082,14 @@ exports[`section-registry parity > field-metadata view > getFieldMetadata() — "secure": true, "smtpPort": 587 }, + "googlechat": { + "allowFrom": [], + "allowMode": "allowlist", + "audienceType": "project-number", + "enabled": false, + "missedInboundThresholdMs": 21600000, + "mode": "pubsub" + }, "healthCheck": { "autoRestartOnStale": false, "enabled": true, @@ -9545,6 +9667,120 @@ exports[`section-registry parity > field-metadata view > getFieldMetadata() — "path": "channels.email.webhookUrl", "type": "string" }, + { + "default": { + "allowFrom": [], + "allowMode": "allowlist", + "audienceType": "project-number", + "enabled": false, + "missedInboundThresholdMs": 21600000, + "mode": "pubsub" + }, + "immutable": true, + "path": "channels.googlechat", + "type": "object" + }, + { + "default": [], + "immutable": true, + "path": "channels.googlechat.allowFrom", + "type": "array" + }, + { + "default": "allowlist", + "immutable": true, + "path": "channels.googlechat.allowMode", + "type": "string" + }, + { + "immutable": true, + "path": "channels.googlechat.audience", + "type": "string" + }, + { + "default": "project-number", + "immutable": true, + "path": "channels.googlechat.audienceType", + "type": "string" + }, + { + "default": false, + "immutable": true, + "path": "channels.googlechat.enabled", + "type": "boolean" + }, + { + "immutable": false, + "path": "channels.googlechat.mediaProcessing", + "type": "object" + }, + { + "default": true, + "immutable": false, + "path": "channels.googlechat.mediaProcessing.analyzeImages", + "type": "boolean" + }, + { + "default": true, + "immutable": false, + "path": "channels.googlechat.mediaProcessing.describeVideos", + "type": "boolean" + }, + { + "default": true, + "immutable": false, + "path": "channels.googlechat.mediaProcessing.extractDocuments", + "type": "boolean" + }, + { + "default": true, + "immutable": false, + "path": "channels.googlechat.mediaProcessing.transcribeAudio", + "type": "boolean" + }, + { + "default": true, + "immutable": false, + "path": "channels.googlechat.mediaProcessing.understandLinks", + "type": "boolean" + }, + { + "default": 21600000, + "immutable": true, + "path": "channels.googlechat.missedInboundThresholdMs", + "type": "integer" + }, + { + "default": "pubsub", + "immutable": true, + "path": "channels.googlechat.mode", + "type": "string" + }, + { + "immutable": true, + "path": "channels.googlechat.serviceAccountKey", + "type": "string" + }, + { + "immutable": true, + "path": "channels.googlechat.serviceAccountKey.id", + "type": "string" + }, + { + "immutable": true, + "path": "channels.googlechat.serviceAccountKey.provider", + "type": "string" + }, + { + "immutable": true, + "path": "channels.googlechat.serviceAccountKey.source", + "type": "string" + }, + { + "immutable": true, + "path": "channels.googlechat.subscriptionName", + "type": "string" + }, { "default": { "autoRestartOnStale": false, @@ -21321,6 +21557,142 @@ exports[`section-registry parity > schema-serializer view > getConfigSchema("cha ], "type": "object" }, + "googlechat": { + "additionalProperties": false, + "default": { + "allowFrom": [], + "allowMode": "allowlist", + "audienceType": "project-number", + "enabled": false, + "missedInboundThresholdMs": 21600000, + "mode": "pubsub" + }, + "properties": { + "allowFrom": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowMode": { + "default": "allowlist", + "enum": [ + "allowlist", + "open" + ], + "type": "string" + }, + "audience": { + "type": "string" + }, + "audienceType": { + "default": "project-number", + "enum": [ + "project-number", + "app-url" + ], + "type": "string" + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "mediaProcessing": { + "additionalProperties": false, + "properties": { + "analyzeImages": { + "default": true, + "type": "boolean" + }, + "describeVideos": { + "default": true, + "type": "boolean" + }, + "extractDocuments": { + "default": true, + "type": "boolean" + }, + "transcribeAudio": { + "default": true, + "type": "boolean" + }, + "understandLinks": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "transcribeAudio", + "analyzeImages", + "describeVideos", + "extractDocuments", + "understandLinks" + ], + "type": "object" + }, + "missedInboundThresholdMs": { + "default": 21600000, + "maximum": 9007199254740991, + "minimum": 60000, + "type": "integer" + }, + "mode": { + "default": "pubsub", + "enum": [ + "pubsub", + "webhook" + ], + "type": "string" + }, + "serviceAccountKey": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + }, + "provider": { + "minLength": 1, + "type": "string" + }, + "source": { + "enum": [ + "env", + "file", + "exec" + ], + "type": "string" + } + }, + "required": [ + "source", + "provider", + "id" + ], + "type": "object" + } + ] + }, + "subscriptionName": { + "type": "string" + } + }, + "required": [ + "enabled", + "mode", + "audienceType", + "allowFrom", + "allowMode", + "missedInboundThresholdMs" + ], + "type": "object" + }, "healthCheck": { "additionalProperties": false, "default": { @@ -23303,6 +23675,7 @@ exports[`section-registry parity > schema-serializer view > getConfigSchema("cha "irc", "email", "msteams", + "googlechat", "healthCheck" ], "type": "object" @@ -32255,6 +32628,14 @@ exports[`section-registry parity > schema-serializer view > getConfigSchema() "secure": true, "smtpPort": 587 }, + "googlechat": { + "allowFrom": [], + "allowMode": "allowlist", + "audienceType": "project-number", + "enabled": false, + "missedInboundThresholdMs": 21600000, + "mode": "pubsub" + }, "healthCheck": { "autoRestartOnStale": false, "enabled": true, @@ -32981,6 +33362,142 @@ exports[`section-registry parity > schema-serializer view > getConfigSchema() ], "type": "object" }, + "googlechat": { + "additionalProperties": false, + "default": { + "allowFrom": [], + "allowMode": "allowlist", + "audienceType": "project-number", + "enabled": false, + "missedInboundThresholdMs": 21600000, + "mode": "pubsub" + }, + "properties": { + "allowFrom": { + "default": [], + "items": { + "type": "string" + }, + "type": "array" + }, + "allowMode": { + "default": "allowlist", + "enum": [ + "allowlist", + "open" + ], + "type": "string" + }, + "audience": { + "type": "string" + }, + "audienceType": { + "default": "project-number", + "enum": [ + "project-number", + "app-url" + ], + "type": "string" + }, + "enabled": { + "default": false, + "type": "boolean" + }, + "mediaProcessing": { + "additionalProperties": false, + "properties": { + "analyzeImages": { + "default": true, + "type": "boolean" + }, + "describeVideos": { + "default": true, + "type": "boolean" + }, + "extractDocuments": { + "default": true, + "type": "boolean" + }, + "transcribeAudio": { + "default": true, + "type": "boolean" + }, + "understandLinks": { + "default": true, + "type": "boolean" + } + }, + "required": [ + "transcribeAudio", + "analyzeImages", + "describeVideos", + "extractDocuments", + "understandLinks" + ], + "type": "object" + }, + "missedInboundThresholdMs": { + "default": 21600000, + "maximum": 9007199254740991, + "minimum": 60000, + "type": "integer" + }, + "mode": { + "default": "pubsub", + "enum": [ + "pubsub", + "webhook" + ], + "type": "string" + }, + "serviceAccountKey": { + "anyOf": [ + { + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "id": { + "minLength": 1, + "type": "string" + }, + "provider": { + "minLength": 1, + "type": "string" + }, + "source": { + "enum": [ + "env", + "file", + "exec" + ], + "type": "string" + } + }, + "required": [ + "source", + "provider", + "id" + ], + "type": "object" + } + ] + }, + "subscriptionName": { + "type": "string" + } + }, + "required": [ + "enabled", + "mode", + "audienceType", + "allowFrom", + "allowMode", + "missedInboundThresholdMs" + ], + "type": "object" + }, "healthCheck": { "additionalProperties": false, "default": { @@ -34963,6 +35480,7 @@ exports[`section-registry parity > schema-serializer view > getConfigSchema() "irc", "email", "msteams", + "googlechat", "healthCheck" ], "type": "object" diff --git a/packages/core/src/config/schema-channel.ts b/packages/core/src/config/schema-channel.ts index 3c32bba78..b0e29364f 100644 --- a/packages/core/src/config/schema-channel.ts +++ b/packages/core/src/config/schema-channel.ts @@ -221,6 +221,29 @@ export const MsTeamsChannelEntrySchema = z.strictObject({ ackReaction: AckReactionConfigSchema.optional(), }); +/** + * Google Chat via a Pub/Sub REST pull transport (default) or an opt-in + * verified webhook. + * + * A dedicated entry rather than an extension of the base channel schema: the + * bot authenticates with a service-account key (minting a scoped JWT-bearer + * token) instead of a single bot token, and the default transport is a + * no-public-IP Pub/Sub pull loop rather than an inbound webhook. + */ +export const GoogleChatChannelEntrySchema = z.strictObject({ + enabled: z.boolean().default(false), + mode: z.enum(["pubsub", "webhook"]).default("pubsub"), + serviceAccountKey: SecretRefOrStringSchema.optional(), + subscriptionName: z.string().optional(), // pubsub: projects/X/subscriptions/Y + audienceType: z.enum(["project-number", "app-url"]).default("project-number"), // webhook mode (transport not wired yet) + audience: z.string().optional(), // webhook mode (transport not wired yet) + allowFrom: z.array(z.string()).default([]), // users/{id} or spaces/{id} + allowMode: z.enum(["allowlist", "open"]).default("allowlist"), + missedInboundThresholdMs: z.number().int().min(60_000).default(21_600_000), // webhook-mode liveness window; floor 1 min + mediaProcessing: MediaProcessingSchema.optional(), + // NO ackReaction — reactions are user-auth-only on this platform, so an ack-reaction knob would be inert dead surface. +}); + // --------------------------------------------------------------------------- // Health check config // --------------------------------------------------------------------------- @@ -266,6 +289,7 @@ export const ChannelConfigSchema = z.strictObject({ irc: IrcChannelEntrySchema.default(() => IrcChannelEntrySchema.parse({})), email: EmailChannelEntrySchema.default(() => EmailChannelEntrySchema.parse({})), msteams: MsTeamsChannelEntrySchema.default(() => MsTeamsChannelEntrySchema.parse({})), + googlechat: GoogleChatChannelEntrySchema.default(() => GoogleChatChannelEntrySchema.parse({})), /** Health monitoring configuration */ healthCheck: ChannelHealthCheckSchema.default(() => ChannelHealthCheckSchema.parse({})), }); @@ -278,4 +302,5 @@ export type LineChannelEntry = z.infer; export type EmailChannelEntry = z.infer; export type IrcChannelEntry = z.infer; export type MsTeamsChannelEntry = z.infer; +export type GoogleChatChannelEntry = z.infer; export type ChannelConfig = z.infer; From 9db3e70022d10f1a9516a83db6563a41a96a0b21 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 15:00:47 +0300 Subject: [PATCH 007/200] test(233-04): add failing test for validateGoogleChatCredentials - Assert err names the MISSING field (serviceAccountKey, subscriptionName, private_key, client_email), never the secret value - Assert a missing-field error never echoes the passed key material - Assert a parse failure carries no raw key text - Assert email-shaped allowFrom emits an advisory WARN naming users/{id}; an immutable users/{id} or spaces/{id} entry does not --- .../googlechat/credential-validator.test.ts | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 packages/channels/src/googlechat/credential-validator.test.ts diff --git a/packages/channels/src/googlechat/credential-validator.test.ts b/packages/channels/src/googlechat/credential-validator.test.ts new file mode 100644 index 000000000..88ba0b5ea --- /dev/null +++ b/packages/channels/src/googlechat/credential-validator.test.ts @@ -0,0 +1,188 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, vi } from "vitest"; +import type { ComisLogger } from "@comis/core"; +import { validateGoogleChatCredentials } from "./credential-validator.js"; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** A vi.fn()-backed logger so a WARN can be asserted without a real Pino impl. */ +function makeLogger(): ComisLogger { + return { + info: vi.fn(), + warn: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + fatal: vi.fn(), + trace: vi.fn(), + audit: vi.fn(), + child: vi.fn(), + } as unknown as ComisLogger; +} + +/** A recognizable secret sentinel that must never surface in an error message. */ +const PRIVATE_KEY_SENTINEL = + "-----BEGIN PRIVATE KEY-----\nMIIBVAIBADANBgkqhki-DO-NOT-LEAK-THIS\n-----END PRIVATE KEY-----\n"; + +/** A well-formed service-account key JSON with both required fields present. */ +const VALID_SA_KEY = JSON.stringify({ + client_email: "sa@p.iam.gserviceaccount.com", + private_key: PRIVATE_KEY_SENTINEL, +}); + +const VALID_SUBSCRIPTION = "projects/p/subscriptions/s"; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("validateGoogleChatCredentials", () => { + it("returns ok for a well-formed SA key JSON and a non-blank subscriptionName", () => { + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + subscriptionName: VALID_SUBSCRIPTION, + }); + expect(result.ok).toBe(true); + }); + + it("returns err naming serviceAccountKey when the key is blank", () => { + const result = validateGoogleChatCredentials({ + serviceAccountKey: "", + subscriptionName: VALID_SUBSCRIPTION, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.message).toContain("serviceAccountKey"); + }); + + it("treats a whitespace-only serviceAccountKey as blank", () => { + const result = validateGoogleChatCredentials({ + serviceAccountKey: " ", + subscriptionName: VALID_SUBSCRIPTION, + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.message).toContain("serviceAccountKey"); + }); + + it("returns err naming subscriptionName when the subscription is blank (pubsub mode)", () => { + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + subscriptionName: "", + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.message).toContain("subscriptionName"); + }); + + it("returns err for a serviceAccountKey that is not valid JSON, without echoing the raw value", () => { + const malformed = `not-json ${PRIVATE_KEY_SENTINEL}`; + const result = validateGoogleChatCredentials({ + serviceAccountKey: malformed, + subscriptionName: VALID_SUBSCRIPTION, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + // The message states a JSON SA key is required (parse failed)... + expect(result.error.message.toLowerCase()).toContain("json"); + // ...but never echoes the raw string it failed to parse. + expect(result.error.message).not.toContain(PRIVATE_KEY_SENTINEL); + expect(result.error.message).not.toContain(malformed); + } + }); + + it("returns err naming private_key when the SA key JSON is missing private_key", () => { + // The key carries other sensitive-looking material but omits private_key. + const keyMissingPrivateKey = JSON.stringify({ + client_email: "sa@p.iam.gserviceaccount.com", + private_key_id: "SENSITIVE_KEY_ID_MUST_NOT_LEAK", + project_id: "p", + }); + const result = validateGoogleChatCredentials({ + serviceAccountKey: keyMissingPrivateKey, + subscriptionName: VALID_SUBSCRIPTION, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("private_key"); + // CRITICAL secret-safety: the missing-field error must not echo the + // passed key material (neither the raw blob nor its sensitive fields). + expect(result.error.message).not.toContain("SENSITIVE_KEY_ID_MUST_NOT_LEAK"); + expect(result.error.message).not.toContain(keyMissingPrivateKey); + } + }); + + it("returns err naming client_email when the SA key JSON is missing client_email, never echoing the private key", () => { + // A real private_key is present; only client_email is missing. + const keyMissingClientEmail = JSON.stringify({ private_key: PRIVATE_KEY_SENTINEL }); + const result = validateGoogleChatCredentials({ + serviceAccountKey: keyMissingClientEmail, + subscriptionName: VALID_SUBSCRIPTION, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toContain("client_email"); + // The private-key material present in the key never crosses into the error. + expect(result.error.message).not.toContain(PRIVATE_KEY_SENTINEL); + } + }); + + it("returns err for a JSON SA key that is not an object (e.g. a bare number)", () => { + const result = validateGoogleChatCredentials({ + serviceAccountKey: "42", + subscriptionName: VALID_SUBSCRIPTION, + }); + expect(result.ok).toBe(false); + }); + + it("emits an advisory WARN naming users/{id} for an email-shaped allowFrom entry, without failing validation", () => { + const logger = makeLogger(); + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + subscriptionName: VALID_SUBSCRIPTION, + allowFrom: ["someone@example.com"], + logger, + }); + // The lint is advisory: an email-shaped id does NOT fail validation. + expect(result.ok).toBe(true); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + channelType: "googlechat", + errorKind: "precondition", + hint: expect.stringContaining("users/{id}"), + }), + expect.any(String), + ); + }); + + it("does NOT WARN for an immutable users/{id} allowFrom entry", () => { + const logger = makeLogger(); + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + subscriptionName: VALID_SUBSCRIPTION, + allowFrom: ["users/123"], + logger, + }); + expect(result.ok).toBe(true); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("does NOT WARN for a spaces/{id} allowFrom entry", () => { + const logger = makeLogger(); + validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + subscriptionName: VALID_SUBSCRIPTION, + allowFrom: ["spaces/AAAAAAAA"], + logger, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("does not throw when an email-shaped allowFrom entry is present but no logger is injected", () => { + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + subscriptionName: VALID_SUBSCRIPTION, + allowFrom: ["someone@example.com"], + }); + expect(result.ok).toBe(true); + }); +}); From fe706136f4326b997df865594dd08c9dca383a4a Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 15:02:40 +0300 Subject: [PATCH 008/200] feat(233-04): implement validateGoogleChatCredentials - Parse-only, secret-safe guard for the SA key JSON + Pub/Sub subscription - Names the missing field (serviceAccountKey / subscriptionName / private_key / client_email), never the secret value; a parse failure carries no raw text - JSON parse failure handled via try/catch returning a Result (no throw) - Advisory email-shaped allowFrom lint: WARN with errorKind + hint steering to an immutable users/{id}; exempts users/{id} and spaces/{id} entries --- .../src/googlechat/credential-validator.ts | 142 ++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 packages/channels/src/googlechat/credential-validator.ts diff --git a/packages/channels/src/googlechat/credential-validator.ts b/packages/channels/src/googlechat/credential-validator.ts new file mode 100644 index 000000000..c600a0a47 --- /dev/null +++ b/packages/channels/src/googlechat/credential-validator.ts @@ -0,0 +1,142 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat Credential Validator: a fail-fast guard that the service-account + * key and Pub/Sub subscription required to register the adapter are present and + * well-formed. + * + * Synchronous, transport-free, and secret-safe. It confirms the subscription is + * set and the key parses into a service-account key JSON carrying the two fields + * the outbound JWT mint needs (`private_key` and `client_email`), naming any + * missing field in the error — never the secret value, and never the raw key + * text on a parse failure. Minting a live token and reaching the subscription + * are separate operational probes and are intentionally out of scope here; this + * is parse-only. + * + * It also lints the sender allowlist: an email display id is mutable and + * spoofable, so an email-shaped `allowFrom` entry surfaces an advisory WARN + * steering the operator toward the immutable resource id. The lint never fails + * validation. + * + * @module + */ + +import type { Result } from "@comis/shared"; +import { ok, err } from "@comis/shared"; +import type { ComisLogger } from "@comis/core"; + +/** Credentials and allowlist required to register the Google Chat adapter. */ +export interface GoogleChatValidateOpts { + /** + * The resolved service-account key JSON string. Never echoed into an error or + * a log field — only its parse result and field presence are inspected. + */ + serviceAccountKey?: string; + /** The Pub/Sub pull subscription resource name (required in pubsub mode). */ + subscriptionName?: string; + /** The configured sender allowlist, linted for mutable email-shaped ids. */ + allowFrom?: string[]; + /** + * Optional logger. When present, an email-shaped `allowFrom` entry emits an + * advisory WARN; without it the lint is silent (validation is unaffected). + */ + logger?: ComisLogger; +} + +/** A field is missing when it is absent or all-whitespace. */ +function isBlank(value: string | undefined): boolean { + return !value || value.trim() === ""; +} + +/** + * True when an allowlist entry looks like a bare email address rather than an + * immutable resource id. Entries that are already an immutable `users/{id}` or + * `spaces/{id}` are exempt. + */ +function isEmailShaped(entry: string): boolean { + if (entry.startsWith("users/") || entry.startsWith("spaces/")) return false; + return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(entry); +} + +/** + * Verify the Google Chat service-account key and subscription required to + * register the adapter are present and well-formed, parse-only. + * + * @param opts.serviceAccountKey - The service-account key JSON string + * @param opts.subscriptionName - The Pub/Sub pull subscription resource name + * @param opts.allowFrom - The sender allowlist (linted, never gated here) + * @param opts.logger - Optional logger for the advisory allowlist lint + * @returns ok when the key parses with the required fields and the subscription + * is set; err naming the first missing or malformed field, never its value + */ +export function validateGoogleChatCredentials( + opts: GoogleChatValidateOpts, +): Result { + if (isBlank(opts.serviceAccountKey)) { + return err( + new Error("Google Chat credentials invalid: serviceAccountKey must not be empty"), + ); + } + if (isBlank(opts.subscriptionName)) { + return err( + new Error( + "Google Chat credentials invalid: subscriptionName must not be empty (pubsub mode)", + ), + ); + } + + // Parse the service-account key. A parse failure is caught locally and turned + // into an error that names the requirement — the raw string is never placed in + // the message, so no key material can leak through the failure path. + let parsed: unknown; + try { + parsed = JSON.parse(opts.serviceAccountKey as string); + } catch { + return err( + new Error( + "Google Chat credentials invalid: serviceAccountKey must be a service-account key JSON (parse failed)", + ), + ); + } + if (typeof parsed !== "object" || parsed === null) { + return err( + new Error( + "Google Chat credentials invalid: serviceAccountKey must be a service-account key JSON object", + ), + ); + } + + // Assert the two fields the outbound JWT mint requires. The error names the + // missing field only — its value is never read into the message. + const key = parsed as { private_key?: unknown; client_email?: unknown }; + const privateKey = typeof key.private_key === "string" ? key.private_key : undefined; + const clientEmail = typeof key.client_email === "string" ? key.client_email : undefined; + if (isBlank(privateKey)) { + return err( + new Error("Google Chat credentials invalid: serviceAccountKey is missing 'private_key'"), + ); + } + if (isBlank(clientEmail)) { + return err( + new Error("Google Chat credentials invalid: serviceAccountKey is missing 'client_email'"), + ); + } + + // Advisory allowlist lint (does not fail validation): steer the operator away + // from mutable, spoofable email display ids toward the immutable resource id. + if (opts.logger && opts.allowFrom) { + for (const entry of opts.allowFrom) { + if (isEmailShaped(entry)) { + opts.logger.warn( + { + channelType: "googlechat" as const, + hint: "Prefer an immutable users/{id} in channels.googlechat.allowFrom — an email display id is mutable/spoofable", + errorKind: "precondition" as const, + }, + "Email-shaped allowFrom entry", + ); + } + } + } + + return ok(undefined); +} From fd9aad5983ab225faa630967f1e3c353703743b4 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 15:12:59 +0300 Subject: [PATCH 009/200] test(233-05): add failing test for SA-JWT-bearer per-scope token provider - Assert getToken mints an RS256 SA-JWT assertion (iss=sub=client_email, aud=token endpoint, scope, exp-iat<=3600) and POSTs the jwt-bearer grant - Assert jwtVerify against the SA public key succeeds - Per-scope cache: reuse within skew, refresh past skew, chat.bot vs pubsub cache independently (two scopes => two mints) - Failure branches (401 status / transport reject) return err with a classified errorKind; the SA key, assertion, and minted token never reach a log field - Malformed PKCS#8 and unparseable/incomplete SA key JSON return precondition errors that name the requirement, never the key bytes --- .../src/googlechat/googlechat-auth.test.ts | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-auth.test.ts diff --git a/packages/channels/src/googlechat/googlechat-auth.test.ts b/packages/channels/src/googlechat/googlechat-auth.test.ts new file mode 100644 index 000000000..18db7cacd --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-auth.test.ts @@ -0,0 +1,456 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, vi } from "vitest"; +import type { ComisLogger } from "@comis/core"; +import { + generateKeyPair, + exportPKCS8, + jwtVerify, + decodeJwt, + decodeProtectedHeader, +} from "jose"; +import { + createGoogleChatTokenProvider, + CHAT_SCOPE, + PUBSUB_SCOPE, + type GoogleChatTokenDeps, +} from "./googlechat-auth.js"; + +const SA_EMAIL = "comis-bot@my-project.iam.gserviceaccount.com"; +const TOKEN_URL = "https://oauth2.googleapis.com/token"; +const MINTED_TOKEN = "ya29.minted-access-token-xyz"; + +/** A logger whose spies record every argument to every level for redaction asserts. */ +function makeLoggerSpy() { + const info = vi.fn(); + const warn = vi.fn(); + const debug = vi.fn(); + const error = vi.fn(); + const noop = vi.fn(); + const logger = { + level: "debug", + trace: noop, + debug, + info, + warn, + error, + fatal: noop, + audit: noop, + child: vi.fn().mockReturnThis(), + } as unknown as ComisLogger; + const serialized = () => + JSON.stringify([ + ...info.mock.calls, + ...warn.mock.calls, + ...debug.mock.calls, + ...error.mock.calls, + ]); + return { logger, serialized, info, warn }; +} + +/** + * Build a real RS256 service-account keypair and the SA-key JSON an operator + * would supply (`private_key` is a PKCS#8 PEM, exactly the Google keyfile shape). + * The public key is returned so a test can verify the minted assertion's + * signature; the PEM is returned so a test can assert it never reaches a log. + */ +async function makeServiceAccountKey(clientEmail = SA_EMAIL) { + const { publicKey, privateKey } = await generateKeyPair("RS256", { + extractable: true, + }); + const privateKeyPem = await exportPKCS8(privateKey); + const serviceAccountKey = JSON.stringify({ + type: "service_account", + client_email: clientEmail, + private_key: privateKeyPem, + token_uri: TOKEN_URL, + }); + return { serviceAccountKey, publicKey, privateKeyPem }; +} + +/** A fetch stub returning a successful token exchange; captures its calls. */ +function makeTokenFetch(token = MINTED_TOKEN, expiresIn = 3600) { + const spy = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + access_token: token, + expires_in: expiresIn, + token_type: "Bearer", + }), + })); + return { fetchImpl: spy as unknown as typeof fetch, spy }; +} + +async function makeDeps(overrides: Partial = {}) { + const loggerSpy = makeLoggerSpy(); + const { fetchImpl, spy } = makeTokenFetch(); + const sa = await makeServiceAccountKey(); + const deps: GoogleChatTokenDeps = { + serviceAccountKey: sa.serviceAccountKey, + logger: loggerSpy.logger, + fetchImpl, + now: () => 1_000_000, + ...overrides, + }; + return { deps, spy, loggerSpy, sa }; +} + +/** A stable, armor-and-whitespace-stripped needle from the private-key body. */ +function keyNeedle(pem: string): string { + return pem + .replace(/-----[^-]+-----/g, "") + .replace(/\s+/g, "") + .slice(0, 40); +} + +/** + * Assert that no log field carries the SA private key body, the minted token, or + * the signed assertion. The assertion is read off the fetch spy's captured call + * args (vitest records call arguments even when the stub then throws). + */ +function assertNoSecretsLogged( + loggerSpy: ReturnType, + sa: { privateKeyPem: string }, + fetchSpy?: ReturnType, +) { + const blob = loggerSpy.serialized(); + expect(blob).not.toContain(MINTED_TOKEN); + const needle = keyNeedle(sa.privateKeyPem); + expect(needle.length).toBeGreaterThan(0); + expect(blob).not.toContain(needle); + const calls = fetchSpy?.mock.calls ?? []; + if (calls.length > 0) { + const init = calls[0][1] as RequestInit | undefined; + const assertion = + new URLSearchParams(String(init?.body)).get("assertion") ?? ""; + if (assertion) expect(blob).not.toContain(assertion); + } +} + +describe("createGoogleChatTokenProvider — SA-JWT-bearer mint", () => { + it("mints an RS256 SA-JWT assertion and POSTs the jwt-bearer grant to the token endpoint", async () => { + const { deps, spy, sa } = await makeDeps(); + const provider = createGoogleChatTokenProvider(deps); + const result = await provider.getToken(CHAT_SCOPE); + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe(MINTED_TOKEN); + expect(spy).toHaveBeenCalledTimes(1); + + const [url, init] = spy.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe(TOKEN_URL); + expect(init.method).toBe("POST"); + expect((init.headers as Record)["content-type"]).toBe( + "application/x-www-form-urlencoded", + ); + + const body = new URLSearchParams(String(init.body)); + expect(body.get("grant_type")).toBe( + "urn:ietf:params:oauth:grant-type:jwt-bearer", + ); + const assertion = body.get("assertion") ?? ""; + expect(assertion.split(".")).toHaveLength(3); // a compact JWS + + const header = decodeProtectedHeader(assertion); + expect(header.alg).toBe("RS256"); + expect(header.typ).toBe("JWT"); + + const claims = decodeJwt(assertion) as { + iss?: string; + sub?: string; + aud?: string; + scope?: string; + iat?: number; + exp?: number; + }; + expect(claims.iss).toBe(SA_EMAIL); + expect(claims.sub).toBe(SA_EMAIL); + expect(claims.aud).toBe(TOKEN_URL); + expect(claims.scope).toBe(CHAT_SCOPE); + expect(typeof claims.iat).toBe("number"); + expect(typeof claims.exp).toBe("number"); + expect((claims.exp as number) - (claims.iat as number)).toBeLessThanOrEqual( + 3600, + ); + }); + + it("signs the assertion with the SA private key — jwtVerify against the matching public key succeeds", async () => { + const { deps, spy, sa } = await makeDeps(); + const provider = createGoogleChatTokenProvider(deps); + await provider.getToken(CHAT_SCOPE); + const [, init] = spy.mock.calls[0] as unknown as [string, RequestInit]; + const assertion = + new URLSearchParams(String(init.body)).get("assertion") ?? ""; + const verified = await jwtVerify(assertion, sa.publicKey, { + issuer: SA_EMAIL, + audience: TOKEN_URL, + }); + expect((verified.payload as { scope?: string }).scope).toBe(CHAT_SCOPE); + }); + + it("reuses the cached token on a second same-scope call inside the skew window", async () => { + const { deps, spy } = await makeDeps(); + const provider = createGoogleChatTokenProvider(deps); + const first = await provider.getToken(CHAT_SCOPE); + const second = await provider.getToken(CHAT_SCOPE); + expect(first.ok && second.ok).toBe(true); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("refreshes the token once the clock passes expiry-minus-skew", async () => { + let clock = 1_000_000; + const { deps, spy } = await makeDeps({ now: () => clock, skewMs: 60_000 }); + const provider = createGoogleChatTokenProvider(deps); + await provider.getToken(CHAT_SCOPE); + expect(spy).toHaveBeenCalledTimes(1); + // expiresAt = 1_000_000 + 3_600_000; refresh boundary = expiresAt - 60_000. + clock = 1_000_000 + 3_600_000 - 60_000 + 1; + await provider.getToken(CHAT_SCOPE); + expect(spy).toHaveBeenCalledTimes(2); + }); + + it("caches chat.bot and pubsub scopes independently — two scopes mint twice, each reuses its own slot", async () => { + const { deps, spy } = await makeDeps(); + const provider = createGoogleChatTokenProvider(deps); + await provider.getToken(CHAT_SCOPE); + await provider.getToken(PUBSUB_SCOPE); + expect(spy).toHaveBeenCalledTimes(2); + // A second call on each scope is served from that scope's own cache slot. + await provider.getToken(CHAT_SCOPE); + await provider.getToken(PUBSUB_SCOPE); + expect(spy).toHaveBeenCalledTimes(2); + // The two mints carried the two distinct scopes in their assertions. + const scopes = spy.mock.calls.map(([, init]) => { + const assertion = + new URLSearchParams(String((init as RequestInit).body)).get( + "assertion", + ) ?? ""; + return (decodeJwt(assertion) as { scope?: string }).scope; + }); + expect(new Set(scopes)).toEqual(new Set([CHAT_SCOPE, PUBSUB_SCOPE])); + }); + + it("logs a durationMs mint completion but never the SA key, the assertion, or the minted token", async () => { + const { deps, spy, loggerSpy, sa } = await makeDeps(); + const provider = createGoogleChatTokenProvider(deps); + const result = await provider.getToken(CHAT_SCOPE); + expect(result.ok).toBe(true); + const mintLine = loggerSpy.info.mock.calls + .map((c) => c[0]) + .find( + (p) => + p !== null && + typeof p === "object" && + (p as { step?: string }).step === "googlechat-token-mint", + ); + expect(mintLine).toBeDefined(); + expect(typeof (mintLine as { durationMs?: unknown }).durationMs).toBe( + "number", + ); + assertNoSecretsLogged(loggerSpy, sa, spy); + }); + + it("returns err and warns (auth) on a non-ok token-endpoint status, leaking no secret", async () => { + const loggerSpy = makeLoggerSpy(); + const spy = vi.fn(async () => ({ + ok: false, + status: 401, + json: async () => ({}), + })); + const sa = await makeServiceAccountKey(); + const provider = createGoogleChatTokenProvider({ + serviceAccountKey: sa.serviceAccountKey, + logger: loggerSpy.logger, + fetchImpl: spy as unknown as typeof fetch, + now: () => 1_000_000, + }); + const result = await provider.getToken(CHAT_SCOPE); + expect(result.ok).toBe(false); + const authWarn = loggerSpy.warn.mock.calls + .map((c) => c[0]) + .find( + (p) => + p !== null && + typeof p === "object" && + (p as { errorKind?: string }).errorKind === "auth", + ); + expect(authWarn).toBeDefined(); + assertNoSecretsLogged(loggerSpy, sa, spy); + }); + + it("returns err and warns (network) when the token fetch rejects at the transport level, leaking no secret", async () => { + const loggerSpy = makeLoggerSpy(); + const spy = vi.fn(async () => { + throw new Error("connect ECONNREFUSED"); + }); + const sa = await makeServiceAccountKey(); + const provider = createGoogleChatTokenProvider({ + serviceAccountKey: sa.serviceAccountKey, + logger: loggerSpy.logger, + fetchImpl: spy as unknown as typeof fetch, + now: () => 1_000_000, + }); + const result = await provider.getToken(PUBSUB_SCOPE); + expect(result.ok).toBe(false); + const networkWarn = loggerSpy.warn.mock.calls + .map((c) => c[0]) + .find( + (p) => + p !== null && + typeof p === "object" && + (p as { errorKind?: string }).errorKind === "network", + ); + expect(networkWarn).toBeDefined(); + assertNoSecretsLogged(loggerSpy, sa, spy); + }); + + it("returns a precondition err naming PKCS#8 PEM (never the key bytes) when the private_key is not valid PKCS#8", async () => { + const loggerSpy = makeLoggerSpy(); + const badKeyBody = "NOT-A-VALID-PKCS8-KEY-BODY-000111222333444"; + const serviceAccountKey = JSON.stringify({ + client_email: SA_EMAIL, + private_key: `-----BEGIN PRIVATE KEY-----\n${badKeyBody}\n-----END PRIVATE KEY-----\n`, + }); + const spy = vi.fn(); + const provider = createGoogleChatTokenProvider({ + serviceAccountKey, + logger: loggerSpy.logger, + fetchImpl: spy as unknown as typeof fetch, + now: () => 1_000_000, + }); + const result = await provider.getToken(CHAT_SCOPE); + expect(result.ok).toBe(false); + expect(spy).not.toHaveBeenCalled(); // never reached the token endpoint + const preconditionWarn = loggerSpy.warn.mock.calls + .map((c) => c[0]) + .find( + (p) => + p !== null && + typeof p === "object" && + (p as { errorKind?: string }).errorKind === "precondition", + ); + expect(preconditionWarn).toBeDefined(); + const blob = loggerSpy.serialized(); + expect(blob).toContain("PKCS#8 PEM"); // names the requirement + expect(blob).not.toContain(badKeyBody); // never the key bytes + }); + + it("returns a precondition err (never the key bytes) when the SA key JSON does not parse", async () => { + const loggerSpy = makeLoggerSpy(); + const spy = vi.fn(); + const provider = createGoogleChatTokenProvider({ + serviceAccountKey: "not-json {{{", + logger: loggerSpy.logger, + fetchImpl: spy as unknown as typeof fetch, + now: () => 1_000_000, + }); + const result = await provider.getToken(CHAT_SCOPE); + expect(result.ok).toBe(false); + expect(spy).not.toHaveBeenCalled(); + const preconditionWarn = loggerSpy.warn.mock.calls + .map((c) => c[0]) + .find( + (p) => + p !== null && + typeof p === "object" && + (p as { errorKind?: string }).errorKind === "precondition", + ); + expect(preconditionWarn).toBeDefined(); + }); + + it("returns a precondition err when the SA key JSON is missing client_email", async () => { + const loggerSpy = makeLoggerSpy(); + const { publicKey: _pub, privateKey } = await generateKeyPair("RS256", { + extractable: true, + }); + void _pub; + const serviceAccountKey = JSON.stringify({ + private_key: await exportPKCS8(privateKey), + }); + const spy = vi.fn(); + const provider = createGoogleChatTokenProvider({ + serviceAccountKey, + logger: loggerSpy.logger, + fetchImpl: spy as unknown as typeof fetch, + now: () => 1_000_000, + }); + const result = await provider.getToken(CHAT_SCOPE); + expect(result.ok).toBe(false); + expect(spy).not.toHaveBeenCalled(); + }); + + it("returns err (platform) when the token response body is not valid JSON", async () => { + const loggerSpy = makeLoggerSpy(); + const spy = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => { + throw new Error("invalid json"); + }, + })); + const sa = await makeServiceAccountKey(); + const provider = createGoogleChatTokenProvider({ + serviceAccountKey: sa.serviceAccountKey, + logger: loggerSpy.logger, + fetchImpl: spy as unknown as typeof fetch, + now: () => 1_000_000, + }); + const result = await provider.getToken(CHAT_SCOPE); + expect(result.ok).toBe(false); + const platformWarn = loggerSpy.warn.mock.calls + .map((c) => c[0]) + .find( + (p) => + p !== null && + typeof p === "object" && + (p as { errorKind?: string }).errorKind === "platform", + ); + expect(platformWarn).toBeDefined(); + }); + + it("treats a response missing access_token as an incomplete token response", async () => { + const loggerSpy = makeLoggerSpy(); + const spy = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ expires_in: 3600 }), + })); + const sa = await makeServiceAccountKey(); + const provider = createGoogleChatTokenProvider({ + serviceAccountKey: sa.serviceAccountKey, + logger: loggerSpy.logger, + fetchImpl: spy as unknown as typeof fetch, + now: () => 1_000_000, + }); + const result = await provider.getToken(CHAT_SCOPE); + expect(result.ok).toBe(false); + }); + + it("treats a non-finite expires_in as incomplete and never caches it", async () => { + // typeof NaN === "number", so a bare typeof guard would poison the cache + // (expiresAtMs = NaN) and force a re-mint on every call. + const loggerSpy = makeLoggerSpy(); + let call = 0; + const spy = vi.fn(async () => { + call += 1; + return { + ok: true, + status: 200, + json: async () => ({ + access_token: "tok", + expires_in: call === 1 ? Number.NaN : 3600, + }), + }; + }); + const sa = await makeServiceAccountKey(); + const provider = createGoogleChatTokenProvider({ + serviceAccountKey: sa.serviceAccountKey, + logger: loggerSpy.logger, + fetchImpl: spy as unknown as typeof fetch, + now: () => 1_000_000, + }); + expect((await provider.getToken(CHAT_SCOPE)).ok).toBe(false); // NaN → not cached + expect((await provider.getToken(CHAT_SCOPE)).ok).toBe(true); // valid → cached + expect((await provider.getToken(CHAT_SCOPE)).ok).toBe(true); // served from cache + expect(spy).toHaveBeenCalledTimes(2); + }); +}); From 4c6089a6e0c6b038fe65752603385f60b47cb973 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 15:15:04 +0300 Subject: [PATCH 010/200] feat(233-05): SA-JWT-bearer per-scope token provider for Google Chat - createGoogleChatTokenProvider mints an RS256 service-account assertion via jose (importPKCS8 + SignJWT): iss=sub=client_email, aud=token endpoint, the requested scope, exp-iat capped at 1h, then exchanges it for an access token with the jwt-bearer grant at oauth2.googleapis.com/token - Per-scope expiry+skew cache (bounded 2-entry map): chat.bot and pubsub cache independently; reuse within skew, re-mint past it - Failure branches delegate to classifyGoogleChatError for errorKind+hint; the private key, assertion, and minted token never enter a log field - SA key parsed once; malformed PKCS#8 / unparseable / missing-field keys return precondition errors naming the requirement, never the key bytes - NaN/0 expiry guarded so it cannot poison the cache - Injected fetch / clock seams; jose is the crypto layer, no hand-rolled JWT; reuses the outbound token-provider cache/skew/logging scaffold Test refined during GREEN: jwtVerify uses currentDate matching the injected clock (the assertion is signed off the injected now, not wall-clock time). --- .../src/googlechat/googlechat-auth.test.ts | 3 + .../src/googlechat/googlechat-auth.ts | 310 ++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-auth.ts diff --git a/packages/channels/src/googlechat/googlechat-auth.test.ts b/packages/channels/src/googlechat/googlechat-auth.test.ts index 18db7cacd..a6672a784 100644 --- a/packages/channels/src/googlechat/googlechat-auth.test.ts +++ b/packages/channels/src/googlechat/googlechat-auth.test.ts @@ -180,9 +180,12 @@ describe("createGoogleChatTokenProvider — SA-JWT-bearer mint", () => { const [, init] = spy.mock.calls[0] as unknown as [string, RequestInit]; const assertion = new URLSearchParams(String(init.body)).get("assertion") ?? ""; + // The assertion is signed off the injected clock (now() = 1_000_000 ms = + // epoch second 1000), so verify its time-based claims against that instant. const verified = await jwtVerify(assertion, sa.publicKey, { issuer: SA_EMAIL, audience: TOKEN_URL, + currentDate: new Date(1_000_000), }); expect((verified.payload as { scope?: string }).scope).toBe(CHAT_SCOPE); }); diff --git a/packages/channels/src/googlechat/googlechat-auth.ts b/packages/channels/src/googlechat/googlechat-auth.ts new file mode 100644 index 000000000..16905acb8 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-auth.ts @@ -0,0 +1,310 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat outbound auth: a service-account JWT-bearer access-token provider + * with a PER-SCOPE expiry+skew cache. + * + * Every Chat API and Pub/Sub REST call rides a `Bearer` token minted here. On a + * cache miss the provider loads the service-account private key with `jose` + * (`importPKCS8`, RS256), signs a short-lived assertion (`iss` = `sub` = the SA + * client email, `aud` = the token endpoint, `scope` = the requested grant, + * `exp - iat <= 1h`), and exchanges it at the OAuth2 token endpoint for an access + * token via the `urn:ietf:params:oauth:grant-type:jwt-bearer` grant. The Chat and + * Pub/Sub scopes cache independently, so a token for one scope is never presented + * to the other. + * + * The caching/skew/status-classification/completion scaffold is the outbound + * Microsoft Teams token-provider shape; only the request builder (an RS256 + * service-account assertion) and the per-scope cache differ. `jose` is the crypto + * layer — the JWT is never hand-assembled. + * + * Secret discipline: the private key, the signed assertion, and the minted access + * token are only ever handed to `jose` or the request body. They are NEVER placed + * in a log field — failure branches log only `errorKind` + `hint` (+ `status` / + * `durationMs`). + * + * Framework-agnostic on purpose: no HTTP-framework import lives here, and the + * logger is injected (this package must not import the infra logger). + * + * @module + */ + +import { systemNowMs } from "@comis/core"; +import type { ComisLogger } from "@comis/core"; +import { ok, err, fromPromise, type Result } from "@comis/shared"; +import { importPKCS8, SignJWT } from "jose"; +import { classifyGoogleChatError } from "./errors.js"; + +/** The Google OAuth2 endpoint that exchanges an SA assertion for an access token. */ +const TOKEN_URL = "https://oauth2.googleapis.com/token"; + +/** The grant type that presents a service-account assertion for exchange. */ +const JWT_BEARER = "urn:ietf:params:oauth:grant-type:jwt-bearer"; + +/** The Chat API scope — authorizes `messages.create` and the Chat REST surface. */ +export const CHAT_SCOPE = "https://www.googleapis.com/auth/chat.bot"; + +/** The Pub/Sub scope — authorizes the subscription pull loop. */ +export const PUBSUB_SCOPE = "https://www.googleapis.com/auth/pubsub"; + +/** The two scopes an app-auth Google Chat adapter mints tokens for. */ +export type GoogleChatScope = typeof CHAT_SCOPE | typeof PUBSUB_SCOPE; + +/** Refresh a cached token this many ms before its stated expiry. */ +const DEFAULT_SKEW_MS = 60_000; + +/** The assertion's lifetime in seconds — the endpoint-imposed one-hour maximum. */ +const ASSERTION_TTL_SEC = 3600; + +const FORM_CONTENT_TYPE = "application/x-www-form-urlencoded"; + +/** Dependencies for the Google Chat token provider. */ +export interface GoogleChatTokenDeps { + /** + * The resolved service-account key JSON string (a `SecretRef` resolved + * upstream). Parsed once for `client_email` + `private_key`; never logged. + */ + serviceAccountKey: string; + /** Logger for the mint completion and each failure branch. */ + logger: ComisLogger; + /** Injected fetch, defaulting to the global; lets a unit test stub the exchange. */ + fetchImpl?: typeof fetch; + /** Injected clock in ms, defaulting to systemNowMs; makes expiry deterministic. */ + now?: () => number; + /** Refresh margin before expiry, in ms. Defaults to 60s. */ + skewMs?: number; + /** + * Token-endpoint URL override — a test-only base-URL seam. Production uses the + * fixed {@link TOKEN_URL} constant so the exchange only ever reaches Google. + */ + tokenUrl?: string; +} + +/** Provides a per-scope cached access token, minted on demand. */ +export interface GoogleChatTokenProvider { + /** + * Return a valid access token for the scope, minting or refreshing as needed. + * The chat.bot and pubsub scopes cache independently. + */ + getToken(scope: GoogleChatScope): Promise>; +} + +/** The service-account fields the assertion mint needs. */ +interface ServiceAccountFields { + clientEmail: string; + privateKey: string; +} + +/** + * Parse the service-account key JSON into the two fields the mint needs. Returns + * a secret-free `hint` on a parse error or a missing field — the raw key string + * is never read into the result, so no key material can leak through this path. + */ +function parseServiceAccountKey( + raw: string, +): Result { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return err({ + hint: "The serviceAccountKey must be a service-account key JSON", + }); + } + if (typeof parsed !== "object" || parsed === null) { + return err({ + hint: "The serviceAccountKey must be a service-account key JSON object", + }); + } + const key = parsed as { private_key?: unknown; client_email?: unknown }; + const privateKey = + typeof key.private_key === "string" ? key.private_key : ""; + const clientEmail = + typeof key.client_email === "string" ? key.client_email : ""; + if (privateKey.trim() === "") { + return err({ hint: "The serviceAccountKey is missing 'private_key'" }); + } + if (clientEmail.trim() === "") { + return err({ hint: "The serviceAccountKey is missing 'client_email'" }); + } + return ok({ clientEmail, privateKey }); +} + +/** + * Build a service-account JWT-bearer token provider. The first `getToken(scope)` + * mints an access token for that scope and caches it; subsequent same-scope calls + * reuse the cache until expiry-minus-skew, then refresh. Distinct scopes cache in + * independent slots. + * + * The service-account key is parsed once at construction; the private key, the + * signed assertion, and the minted token are never logged. + */ +export function createGoogleChatTokenProvider( + deps: GoogleChatTokenDeps, +): GoogleChatTokenProvider { + const now = deps.now ?? systemNowMs; + const doFetch = deps.fetchImpl ?? fetch; + const skewMs = deps.skewMs ?? DEFAULT_SKEW_MS; + const tokenUrl = deps.tokenUrl ?? TOKEN_URL; + + // Parse the SA key once. The result — the two fields, or a secret-free failure + // hint — is reused across scopes and calls; the key bytes are read only here + // and only ever handed to jose. + const keyParse = parseServiceAccountKey(deps.serviceAccountKey); + + // Per-scope token cache. The key space is the two-member GoogleChatScope union, + // so the map is bounded at two entries — no eviction is needed. + const cache = new Map(); + + return { + async getToken(scope: GoogleChatScope): Promise> { + // Cache hit for this scope while still comfortably before expiry. + const hit = cache.get(scope); + if (hit && now() < hit.expiresAtMs - skewMs) { + return ok(hit.token); + } + + if (!keyParse.ok) { + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: keyParse.error.hint, + errorKind: "precondition" as const, + }, + "Token mint blocked: the service-account key could not be parsed", + ); + return err(new Error("invalid service-account key")); + } + const { clientEmail, privateKey } = keyParse.value; + + const startedAt = now(); + deps.logger.debug( + { step: "googlechat-token-mint", channelType: "googlechat" as const }, + "Minting service-account token", + ); + + // Load the private key. A malformed key names the requirement, not bytes. + const keyRes = await fromPromise(importPKCS8(privateKey, "RS256")); + if (!keyRes.ok) { + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "The service-account private_key must be an unencrypted PKCS#8 PEM", + errorKind: "precondition" as const, + }, + "Token mint blocked: the service-account private key could not be loaded", + ); + return err(keyRes.error); + } + + // Sign the assertion: iss = sub = SA email, aud = token endpoint, the + // requested scope, and a lifetime capped at one hour. + const iatSec = Math.floor(now() / 1000); + const signed = await fromPromise( + new SignJWT({ scope }) + .setProtectedHeader({ alg: "RS256", typ: "JWT" }) + .setIssuer(clientEmail) + .setSubject(clientEmail) + .setAudience(tokenUrl) + .setIssuedAt(iatSec) + .setExpirationTime(iatSec + ASSERTION_TTL_SEC) + .sign(keyRes.value), + ); + if (!signed.ok) { + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "Confirm the service-account private key is a valid RS256 signing key", + errorKind: "internal" as const, + }, + "Token mint failed: the assertion could not be signed", + ); + return err(signed.error); + } + + // Exchange the assertion for an access token. + const responded = await fromPromise( + doFetch(tokenUrl, { + method: "POST", + headers: { "content-type": FORM_CONTENT_TYPE }, + body: new URLSearchParams({ + grant_type: JWT_BEARER, + assertion: signed.value, + }), + }), + ); + if (!responded.ok) { + // No response reached us: a transport-level fault (undefined status). + const classified = classifyGoogleChatError(undefined, responded.error); + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: classified.hint, + errorKind: classified.errorKind, + }, + "Token mint failed: no response from the token endpoint", + ); + return err(responded.error); + } + + const res = responded.value; + if (!res.ok) { + const classified = classifyGoogleChatError(res.status); + deps.logger.warn( + { + channelType: "googlechat" as const, + status: res.status, + hint: classified.hint, + errorKind: classified.errorKind, + }, + "Token mint failed: the token endpoint returned an error status", + ); + return err(new Error(`token endpoint returned status ${res.status}`)); + } + + const parsed = await fromPromise( + res.json() as Promise<{ access_token?: string; expires_in?: number }>, + ); + if (!parsed.ok) { + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "The token endpoint returned a body that is not valid JSON", + errorKind: "platform" as const, + }, + "Token mint failed: unreadable token response", + ); + return err(parsed.error); + } + + const accessToken = parsed.value.access_token; + const inSec = parsed.value.expires_in; + // Guard a NaN/0 expiry from poisoning the cache (typeof NaN === "number"). + const expiresAtMs = + typeof inSec === "number" && Number.isFinite(inSec) && inSec > 0 + ? now() + inSec * 1000 + : undefined; + if (!accessToken || expiresAtMs === undefined) { + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "The token endpoint response was missing access_token or a positive expiry", + errorKind: "platform" as const, + }, + "Token mint failed: incomplete token response", + ); + return err(new Error("incomplete token response")); + } + + cache.set(scope, { token: accessToken, expiresAtMs }); + deps.logger.info( + { + step: "googlechat-token-mint", + channelType: "googlechat" as const, + durationMs: now() - startedAt, + }, + "Service-account token minted", + ); + return ok(accessToken); + }, + }; +} From 5106a25cdb833fb5afdff660f1fe7c04efc9a6d0 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 15:29:21 +0300 Subject: [PATCH 011/200] =?UTF-8?q?test(233-06):=20failing=20pull=20loop?= =?UTF-8?q?=20test=20=E2=80=94=20ack-on-enqueue,=20skip-ack-on-reject,=20d?= =?UTF-8?q?edup=20on=20message.name,=20standard=20base64?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pollOnce pull-request shape (Bearer, maxMessages, no returnImmediately, abort signal) - ack fires only after onEvent resolves; a rejecting onEvent skips the ack so Pub/Sub redelivers - redelivered duplicates dedup on the decoded event.message.name; mark-seen only on the ack path - standard base64 (not base64url) decode; unparseable payloads acked and skipped; bounded seen-set eviction - RED: ./pubsub-source.js does not exist yet --- .../src/googlechat/pubsub-source.test.ts | 350 ++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100644 packages/channels/src/googlechat/pubsub-source.test.ts diff --git a/packages/channels/src/googlechat/pubsub-source.test.ts b/packages/channels/src/googlechat/pubsub-source.test.ts new file mode 100644 index 000000000..738b32183 --- /dev/null +++ b/packages/channels/src/googlechat/pubsub-source.test.ts @@ -0,0 +1,350 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, vi } from "vitest"; +import type { ComisLogger } from "@comis/core"; +import { ok, err } from "@comis/shared"; +import { + createPubSubSource, + type PubSubSourceDeps, +} from "./pubsub-source.js"; + +const SUB = "projects/my-project/subscriptions/comis-sub"; +const BASE = "https://pubsub.googleapis.com/v1"; +const PULL_URL = `${BASE}/${SUB}:pull`; +const ACK_URL = `${BASE}/${SUB}:acknowledge`; +const PUBSUB_TOKEN = "ya29.pubsub-access-token"; + +/** A logger whose spies record every argument to every level. */ +function makeLoggerSpy() { + const info = vi.fn(); + const warn = vi.fn(); + const debug = vi.fn(); + const error = vi.fn(); + const noop = vi.fn(); + const logger = { + level: "debug", + trace: noop, + debug, + info, + warn, + error, + fatal: noop, + audit: noop, + child: vi.fn().mockReturnThis(), + } as unknown as ComisLogger; + const serialized = () => + JSON.stringify([ + ...info.mock.calls, + ...warn.mock.calls, + ...debug.mock.calls, + ...error.mock.calls, + ]); + return { logger, serialized, info, warn, debug, error }; +} + +/** A classic Chat interaction event with a stable message resource name. */ +function makeChatEvent(name: string, text = "hello") { + return { + type: "MESSAGE", + eventTime: "2026-07-05T00:00:00Z", + user: { name: "users/1" }, + space: { name: "spaces/AAAA", spaceType: "SPACE" }, + message: { + name, + sender: { name: "users/1" }, + text, + }, + }; +} + +/** STANDARD base64 (not base64url) of the JSON-serialized event. */ +function encodeEvent(event: unknown): string { + return Buffer.from(JSON.stringify(event), "utf8").toString("base64"); +} + +interface ReceivedMessageFixture { + ackId: string; + message: { data: string; messageId?: string }; +} +interface PullBodyFixture { + receivedMessages?: ReceivedMessageFixture[]; +} + +/** + * A plain-HTTP fake that answers `:pull` from a queue and records `:acknowledge` + * request bodies + pull request inits. No gRPC, no real network. + */ +function makeFetch(pullQueue: PullBodyFixture[]) { + const ackBodies: Array<{ ackIds: string[] }> = []; + const pullInits: RequestInit[] = []; + const impl = vi.fn(async (url: unknown, init?: RequestInit) => { + const u = String(url); + if (u.endsWith(":pull")) { + pullInits.push(init ?? {}); + const body = pullQueue.shift() ?? { receivedMessages: [] }; + return { ok: true, status: 200, json: async () => body } as unknown as Response; + } + if (u.endsWith(":acknowledge")) { + const parsed = JSON.parse(String(init?.body ?? "{}")) as { ackIds: string[] }; + ackBodies.push(parsed); + return { ok: true, status: 200, json: async () => ({}) } as unknown as Response; + } + throw new Error(`unexpected url ${u}`); + }); + const allAckedIds = () => ackBodies.flatMap((b) => b.ackIds ?? []); + return { + fetchImpl: impl as unknown as typeof fetch, + ackBodies, + pullInits, + allAckedIds, + impl, + }; +} + +function makeDeps(over: Partial = {}): { + deps: PubSubSourceDeps; + loggerSpy: ReturnType; +} { + const loggerSpy = makeLoggerSpy(); + const deps: PubSubSourceDeps = { + subscriptionName: SUB, + getPubSubToken: vi.fn(async () => ok(PUBSUB_TOKEN)), + onEvent: vi.fn(async () => {}), + logger: loggerSpy.logger, + ...over, + }; + return { deps, loggerSpy }; +} + +describe("createPubSubSource — pull + ack-on-enqueue + dedup (pollOnce)", () => { + it("POSTs subscription:pull with a pubsub Bearer and a maxMessages body carrying no returnImmediately", async () => { + const fetch = makeFetch([{ receivedMessages: [] }]); + const { deps } = makeDeps({ fetchImpl: fetch.fetchImpl }); + const source = createPubSubSource(deps); + + await source.pollOnce(); + + const pullCall = fetch.impl.mock.calls.find(([u]) => + String(u).endsWith(":pull"), + ); + expect(pullCall).toBeDefined(); + const [url, init] = pullCall as unknown as [string, RequestInit]; + expect(url).toBe(PULL_URL); + expect(init.method).toBe("POST"); + const headers = init.headers as Record; + expect(headers.authorization).toBe(`Bearer ${PUBSUB_TOKEN}`); + expect(headers["content-type"]).toBe("application/json"); + const body = JSON.parse(String(init.body)) as Record; + expect(body).toEqual({ maxMessages: 10 }); + expect("returnImmediately" in body).toBe(false); + // The long-poll carries an abort signal so stop() can cancel it. + expect(init.signal).toBeInstanceOf(AbortSignal); + }); + + it("decodes standard base64 message.data, JSON.parses the classic event, and dispatches onEvent exactly once", async () => { + const event = makeChatEvent("spaces/AAAA/messages/m1"); + const fetch = makeFetch([ + { receivedMessages: [{ ackId: "ack-1", message: { data: encodeEvent(event) } }] }, + ]); + const onEvent = vi.fn(async () => {}); + const { deps } = makeDeps({ fetchImpl: fetch.fetchImpl, onEvent }); + const source = createPubSubSource(deps); + + const out = await source.pollOnce(); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith(event); + expect(out.receivedCount).toBe(1); + expect(out.pullFailed).toBe(false); + }); + + it("decodes a payload whose STANDARD base64 contains + or / (proving standard, not base64url, decoding)", async () => { + // A payload engineered so its standard-base64 form carries + and/or /. + const event = makeChatEvent( + "spaces/AAAA/messages/m1", + ">>>???~~~ÿþ payload with padding bytes >>>", + ); + const data = encodeEvent(event); + // Precondition: this payload's standard base64 uses the +// alphabet, which + // base64url would render as -/_ — a base64url decode here would corrupt it. + expect(data).toMatch(/[+/]/); + const onEvent = vi.fn(async () => {}); + const fetch = makeFetch([ + { receivedMessages: [{ ackId: "ack-1", message: { data } }] }, + ]); + const { deps } = makeDeps({ fetchImpl: fetch.fetchImpl, onEvent }); + const source = createPubSubSource(deps); + + await source.pollOnce(); + + expect(onEvent).toHaveBeenCalledTimes(1); + expect(onEvent).toHaveBeenCalledWith(event); + }); + + it("acknowledges the ackId only after onEvent resolves (ack-on-enqueue)", async () => { + const event = makeChatEvent("spaces/AAAA/messages/m1"); + const fetch = makeFetch([ + { receivedMessages: [{ ackId: "ack-1", message: { data: encodeEvent(event) } }] }, + ]); + const { deps } = makeDeps({ + fetchImpl: fetch.fetchImpl, + onEvent: vi.fn(async () => {}), + }); + const source = createPubSubSource(deps); + + const out = await source.pollOnce(); + + expect(fetch.allAckedIds()).toContain("ack-1"); + const ackCall = fetch.impl.mock.calls.find(([u]) => + String(u).endsWith(":acknowledge"), + ); + const [url, init] = ackCall as unknown as [string, RequestInit]; + expect(url).toBe(ACK_URL); + expect((init.headers as Record).authorization).toBe( + `Bearer ${PUBSUB_TOKEN}`, + ); + expect(out.ackedCount).toBe(1); + }); + + it("skips the ack when onEvent rejects so Pub/Sub redelivers, logging a WARN with errorKind and hint", async () => { + const event = makeChatEvent("spaces/AAAA/messages/m1"); + const fetch = makeFetch([ + { receivedMessages: [{ ackId: "ack-1", message: { data: encodeEvent(event) } }] }, + ]); + const onEvent = vi.fn(async () => { + throw new Error("inbound queue full"); + }); + const { deps, loggerSpy } = makeDeps({ fetchImpl: fetch.fetchImpl, onEvent }); + const source = createPubSubSource(deps); + + const out = await source.pollOnce(); + + expect(fetch.allAckedIds()).not.toContain("ack-1"); + expect(out.skippedCount).toBe(1); + const warn = loggerSpy.warn.mock.calls + .map((c) => c[0]) + .find( + (p) => + p !== null && + typeof p === "object" && + typeof (p as { hint?: unknown }).hint === "string" && + typeof (p as { errorKind?: unknown }).errorKind === "string", + ); + expect(warn).toBeDefined(); + }); + + it("dedupes a redelivered duplicate on message.name — dispatches once and acks the duplicate without re-dispatch", async () => { + const name = "spaces/AAAA/messages/dupe"; + const event = makeChatEvent(name); + const fetch = makeFetch([ + { receivedMessages: [{ ackId: "ack-first", message: { data: encodeEvent(event) } }] }, + { receivedMessages: [{ ackId: "ack-redeliver", message: { data: encodeEvent(event) } }] }, + ]); + const onEvent = vi.fn(async () => {}); + const { deps } = makeDeps({ fetchImpl: fetch.fetchImpl, onEvent }); + const source = createPubSubSource(deps); + + await source.pollOnce(); + await source.pollOnce(); + + // Dispatched exactly once across both deliveries... + expect(onEvent).toHaveBeenCalledTimes(1); + // ...but BOTH ackIds acked, so the duplicate stops redelivering. + expect(fetch.allAckedIds()).toContain("ack-first"); + expect(fetch.allAckedIds()).toContain("ack-redeliver"); + }); + + it("re-dispatches a redelivery when the first delivery's onEvent rejected (name marked seen only on the ack path)", async () => { + const name = "spaces/AAAA/messages/retry"; + const event = makeChatEvent(name); + const fetch = makeFetch([ + { receivedMessages: [{ ackId: "ack-1", message: { data: encodeEvent(event) } }] }, + { receivedMessages: [{ ackId: "ack-2", message: { data: encodeEvent(event) } }] }, + ]); + let calls = 0; + const onEvent = vi.fn(async () => { + calls += 1; + if (calls === 1) throw new Error("transient enqueue failure"); + }); + const { deps } = makeDeps({ fetchImpl: fetch.fetchImpl, onEvent }); + const source = createPubSubSource(deps); + + await source.pollOnce(); // rejects → skip ack, NOT marked seen + await source.pollOnce(); // same name re-dispatched (not deduped) + + expect(onEvent).toHaveBeenCalledTimes(2); + // First was skipped; second succeeded and was acked. + expect(fetch.allAckedIds()).not.toContain("ack-1"); + expect(fetch.allAckedIds()).toContain("ack-2"); + }); + + it("acks and skips an unparseable data payload without dispatching or throwing", async () => { + const bad = Buffer.from("not json {{{", "utf8").toString("base64"); + const fetch = makeFetch([ + { receivedMessages: [{ ackId: "ack-bad", message: { data: bad } }] }, + ]); + const onEvent = vi.fn(async () => {}); + const { deps } = makeDeps({ fetchImpl: fetch.fetchImpl, onEvent }); + const source = createPubSubSource(deps); + + const out = await source.pollOnce(); + + expect(onEvent).not.toHaveBeenCalled(); + expect(fetch.allAckedIds()).toContain("ack-bad"); + expect(out.pullFailed).toBe(false); + }); + + it("reports pullFailed and does not pull when the pubsub token mint fails", async () => { + const fetch = makeFetch([]); + const { deps } = makeDeps({ + fetchImpl: fetch.fetchImpl, + getPubSubToken: vi.fn(async () => err(new Error("mint failed"))), + }); + const source = createPubSubSource(deps); + + const out = await source.pollOnce(); + + expect(out.pullFailed).toBe(true); + expect(fetch.impl).not.toHaveBeenCalled(); + expect(source.lastError).toBeDefined(); + }); + + it("reports pullFailed and sets lastError on a non-ok pull status", async () => { + const impl = vi.fn(async () => ({ + ok: false, + status: 503, + json: async () => ({}), + })); + const { deps } = makeDeps({ fetchImpl: impl as unknown as typeof fetch }); + const source = createPubSubSource(deps); + + const out = await source.pollOnce(); + + expect(out.pullFailed).toBe(true); + expect(typeof source.lastError).toBe("string"); + }); + + it("evicts the oldest entry when the bounded seen-set exceeds seenSetMax", async () => { + // seenSetMax=1: after m1 is seen, m2 evicts m1; a redelivery of m1 is then + // re-dispatched (no longer deduped) because it was evicted from the set. + const e1 = makeChatEvent("spaces/AAAA/messages/m1"); + const e2 = makeChatEvent("spaces/AAAA/messages/m2"); + const fetch = makeFetch([ + { receivedMessages: [{ ackId: "a1", message: { data: encodeEvent(e1) } }] }, + { receivedMessages: [{ ackId: "a2", message: { data: encodeEvent(e2) } }] }, + { receivedMessages: [{ ackId: "a3", message: { data: encodeEvent(e1) } }] }, + ]); + const onEvent = vi.fn(async () => {}); + const { deps } = makeDeps({ + fetchImpl: fetch.fetchImpl, + onEvent, + seenSetMax: 1, + }); + const source = createPubSubSource(deps); + + await source.pollOnce(); // m1 seen + await source.pollOnce(); // m2 seen → evicts m1 + await source.pollOnce(); // m1 redelivered → re-dispatched (evicted) + + expect(onEvent).toHaveBeenCalledTimes(3); + }); +}); From 5f544a1580ed5ea1c2c9cc595eb4097666a429d3 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 15:33:37 +0300 Subject: [PATCH 012/200] =?UTF-8?q?feat(233-06):=20Pub/Sub=20pull=20loop?= =?UTF-8?q?=20core=20=E2=80=94=20decode,=20dedup,=20ack-on-enqueue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - pollOnce POSTs subscription:pull (pubsub Bearer, maxMessages, abort signal), standard base64 decode, JSON.parse - acks only after onEvent resolves; a rejecting onEvent skips the ack (WARN) so Pub/Sub redelivers - dedup on the decoded event.message.name; name marked seen only on the ack path; bounded oldest-first eviction - unparseable payloads acked and skipped; token/transport/status/body failures classified via the shared taxonomy - start()/stop() are thin lifecycle here; the self-rescheduling backoff loop follows --- .../channels/src/googlechat/pubsub-source.ts | 388 ++++++++++++++++++ 1 file changed, 388 insertions(+) create mode 100644 packages/channels/src/googlechat/pubsub-source.ts diff --git a/packages/channels/src/googlechat/pubsub-source.ts b/packages/channels/src/googlechat/pubsub-source.ts new file mode 100644 index 000000000..e95ea3afc --- /dev/null +++ b/packages/channels/src/googlechat/pubsub-source.ts @@ -0,0 +1,388 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat inbound transport: a REST long-poll pull loop over a Pub/Sub + * subscription. + * + * In the no-public-IP mode the subscription IS the inbound boundary — only the + * service account (via the subscription ACL) can pull it, so there is no + * forgeable public endpoint. This module owns the loop: + * + * `subscription:pull` (long-poll) -> base64-decode each `message.data` -> + * `JSON.parse` the classic Chat interaction event -> dedup on the event's + * `message.name` -> dispatch to the injected `onEvent` -> `subscription:acknowledge`. + * + * Ack discipline is the load-bearing correctness property: a message is acked + * ONLY after `onEvent` resolves. A rejecting `onEvent` (a full/failed inbound + * queue) SKIPS the ack, so Pub/Sub redelivers the message. Redelivery is safe + * because a duplicate is deduped on the stable `message.name` (the Chat resource + * name, not the per-delivery Pub/Sub messageId) before it reaches `onEvent`, and + * a name is marked seen ONLY on the ack path — so a message whose enqueue failed + * is re-dispatched on redelivery rather than silently dropped. + * + * Transport-only and framework-agnostic: `fetch`, the clock, and the backoff + * timers are all injectable seams, so the whole loop is unit-testable without a + * real network, real time, or a gRPC client. The `data` payload is STANDARD + * base64 (`Buffer.from(data, "base64")`) — not the URL-safe alphabet — and + * decodes directly to the interaction event; there is no CloudEvents envelope + * on this path. + * + * Secret discipline: the pubsub Bearer token is only ever placed on the request + * `authorization` header. It is NEVER written to a log field — failure branches + * log only `errorKind` + `hint` via the shared classifier. + * + * @module + */ + +import { fromPromise, type Result } from "@comis/shared"; +import { + systemNowMs, + systemSetTimeout, + systemClearTimeout, + type ComisLogger, +} from "@comis/core"; +import { classifyGoogleChatError } from "./errors.js"; + +/** The Pub/Sub REST v1 base URL the pull/ack requests target. */ +const DEFAULT_PUBSUB_BASE_URL = "https://pubsub.googleapis.com/v1"; + +/** How many messages to request per long-poll. */ +const DEFAULT_MAX_MESSAGES = 10; + +/** Upper bound on the dedup seen-set before oldest-first eviction. */ +const DEFAULT_SEEN_SET_MAX = 1000; + +/** Dependencies for the Pub/Sub pull-loop source. */ +export interface PubSubSourceDeps { + /** The subscription resource: `projects/{project}/subscriptions/{sub}`. */ + subscriptionName: string; + /** Mints a pubsub-scope Bearer token; the loop never caches it itself. */ + getPubSubToken: () => Promise>; + /** + * The inbound dispatch. RESOLVE means the message was enqueued and may be + * acked; REJECT means the enqueue failed and the ack must be skipped so + * Pub/Sub redelivers. + */ + onEvent: (event: unknown) => Promise; + /** Logger for the pull-cycle summary and each failure branch. */ + logger: ComisLogger; + /** Injected fetch, defaulting to the global; lets a unit test stub the pull/ack. */ + fetchImpl?: typeof fetch; + /** Injected clock in ms, defaulting to systemNowMs; makes timing deterministic. */ + now?: () => number; + /** Pub/Sub base-URL override — a test-only seam. */ + pubsubBaseUrl?: string; + /** Max messages per long-poll. Defaults to 10. */ + maxMessages?: number; + /** Bounded dedup-set size before oldest-first eviction. Defaults to 1000. */ + seenSetMax?: number; + /** Injected one-shot timer for backoff, defaulting to systemSetTimeout. */ + setTimeoutImpl?: typeof systemSetTimeout; + /** Injected timer canceller, defaulting to systemClearTimeout. */ + clearTimeoutImpl?: typeof systemClearTimeout; + /** Backoff floor in ms. Defaults to 1000. */ + backoffFloorMs?: number; + /** Backoff cap in ms. Defaults to 30000. */ + backoffCapMs?: number; + /** Jitter randomness, defaulting to Math.random. */ + rng?: () => number; + /** Consecutive pull failures before a loud ERROR. Defaults to 3. */ + errorLogThreshold?: number; +} + +/** The outcome of a single pull cycle. */ +export interface PollOutcome { + /** Messages returned by the pull. */ + receivedCount: number; + /** Messages acknowledged (enqueued, deduped, or unparseable). */ + ackedCount: number; + /** Messages whose enqueue rejected and were left un-acked for redelivery. */ + skippedCount: number; + /** True when the pull itself failed (token, transport, status, or body). */ + pullFailed: boolean; +} + +/** The long-poll pull-loop source. */ +export interface PubSubSource { + /** Start the self-rescheduling pull loop. Idempotent while running. */ + start(): void; + /** Abort any in-flight long-poll, cancel a pending backoff, and stop the loop. */ + stop(): Promise; + /** Run one pull/decode/dedup/dispatch/ack cycle. */ + pollOnce(): Promise; + /** The most recent failure hint, for adapter status degradation. */ + readonly lastError: string | undefined; + /** Whether the loop is currently running. */ + readonly running: boolean; +} + +/** The Pub/Sub-assigned envelope around one pulled message. */ +interface ReceivedMessage { + ackId?: string; + message?: { data?: string; messageId?: string }; +} + +/** The `subscription:pull` response body. */ +interface PullResponse { + receivedMessages?: ReceivedMessage[]; +} + +const EMPTY_OUTCOME: PollOutcome = { + receivedCount: 0, + ackedCount: 0, + skippedCount: 0, + pullFailed: true, +}; + +/** + * Extract the dedup key — the decoded Chat event's `message.name` (the stable + * `spaces/X/messages/Y` resource name, constant across redeliveries) — from an + * untrusted decoded payload, or undefined when absent. + */ +function extractMessageName(event: unknown): string | undefined { + if (event === null || typeof event !== "object") return undefined; + const message = (event as { message?: unknown }).message; + if (message === null || typeof message !== "object") return undefined; + const name = (message as { name?: unknown }).name; + return typeof name === "string" ? name : undefined; +} + +/** + * Build a Pub/Sub pull-loop source. The returned source can `pollOnce()` a + * single cycle (used by the tests and by the loop) or `start()` the + * self-rescheduling loop; `stop()` aborts an in-flight long-poll and cancels a + * pending backoff. + */ +export function createPubSubSource(deps: PubSubSourceDeps): PubSubSource { + const now = deps.now ?? systemNowMs; + const doFetch = deps.fetchImpl ?? fetch; + const base = deps.pubsubBaseUrl ?? DEFAULT_PUBSUB_BASE_URL; + const maxMessages = deps.maxMessages ?? DEFAULT_MAX_MESSAGES; + const seenSetMax = deps.seenSetMax ?? DEFAULT_SEEN_SET_MAX; + + // Insertion-ordered dedup set. `markSeen` evicts the oldest key once the set + // grows past its bound, so a long-lived loop cannot leak memory. + const seen = new Map(); + + let lastError: string | undefined; + let running = false; + // Recreated in `start()`; passed to both the pull fetch and the ack fetch so a + // `stop()` aborts an in-flight long-poll immediately. + let controller = new AbortController(); + + function markSeen(name: string): void { + seen.set(name, true); + if (seen.size > seenSetMax) { + const oldest = seen.keys().next().value; + if (oldest !== undefined) seen.delete(oldest); + } + } + + async function acknowledge(ackIds: string[], token: string): Promise { + const acked = await fromPromise( + doFetch(`${base}/${deps.subscriptionName}:acknowledge`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ ackIds }), + signal: controller.signal, + }), + ); + if (!acked.ok) { + const classified = classifyGoogleChatError(undefined, acked.error); + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "Acknowledge failed; the messages will redeliver and dedup on the next pull", + errorKind: classified.errorKind, + }, + "Pub/Sub acknowledge request failed", + ); + return; + } + if (!acked.value.ok) { + const classified = classifyGoogleChatError(acked.value.status); + deps.logger.warn( + { + channelType: "googlechat" as const, + status: acked.value.status, + hint: "Acknowledge returned a non-ok status; the messages will redeliver and dedup on the next pull", + errorKind: classified.errorKind, + }, + "Pub/Sub acknowledge returned a non-ok status", + ); + } + } + + async function pollOnce(): Promise { + const startedAt = now(); + + const tokenRes = await deps.getPubSubToken(); + if (!tokenRes.ok) { + lastError = "pubsub token mint failed"; + return { ...EMPTY_OUTCOME }; + } + const token = tokenRes.value; + + const pulled = await fromPromise( + doFetch(`${base}/${deps.subscriptionName}:pull`, { + method: "POST", + headers: { + authorization: `Bearer ${token}`, + "content-type": "application/json", + }, + body: JSON.stringify({ maxMessages }), + signal: controller.signal, + }), + ); + if (!pulled.ok) { + // An aborted long-poll is an expected stop, not a failure to log loudly. + if (controller.signal.aborted) return { ...EMPTY_OUTCOME }; + const classified = classifyGoogleChatError(undefined, pulled.error); + lastError = classified.hint; + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: classified.hint, + errorKind: classified.errorKind, + }, + "Pub/Sub pull failed at the transport level", + ); + return { ...EMPTY_OUTCOME }; + } + + const res = pulled.value; + if (!res.ok) { + const classified = classifyGoogleChatError(res.status); + lastError = classified.hint; + deps.logger.warn( + { + channelType: "googlechat" as const, + status: res.status, + hint: classified.hint, + errorKind: classified.errorKind, + }, + "Pub/Sub pull returned a non-ok status", + ); + return { ...EMPTY_OUTCOME }; + } + + const parsed = await fromPromise(res.json() as Promise); + if (!parsed.ok) { + lastError = "pull response body was not valid JSON"; + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "The pull response was not valid JSON — verify the subscription endpoint", + errorKind: "platform" as const, + }, + "Pub/Sub pull returned an unreadable body", + ); + return { ...EMPTY_OUTCOME }; + } + + const received = parsed.value.receivedMessages ?? []; + const ackIds: string[] = []; + let skipped = 0; + + for (const rm of received) { + const ackId = rm.ackId; + const data = rm.message?.data; + if (typeof ackId !== "string" || typeof data !== "string") { + // A malformed envelope carries no ackId/data — nothing to dispatch or ack. + continue; + } + + let decoded: unknown; + try { + // STANDARD base64 (not the URL-safe alphabet): the Chat event decodes here. + decoded = JSON.parse(Buffer.from(data, "base64").toString("utf8")); + } catch { + // Undecodable payload — ack it so it stops redelivering; nothing to dispatch. + ackIds.push(ackId); + continue; + } + + // Dedup on the decoded event's message.name (stable across redeliveries). + const name = extractMessageName(decoded); + if (name !== undefined && seen.has(name)) { + ackIds.push(ackId); + continue; + } + + try { + await deps.onEvent(decoded); + // Mark seen ONLY after a successful dispatch, so a redelivery following a + // failed enqueue is re-dispatched rather than dropped as a duplicate. + if (name !== undefined) markSeen(name); + ackIds.push(ackId); + } catch { + skipped += 1; + lastError = "inbound enqueue failed; message will redeliver"; + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "Inbound enqueue failed; ack skipped so Pub/Sub redelivers (dedup on the message name makes this safe)", + errorKind: "internal" as const, + }, + "Pub/Sub message enqueue failed; skipping ack", + ); + } + } + + if (ackIds.length > 0) { + await acknowledge(ackIds, token); + } + + deps.logger.debug( + { + step: "googlechat-pubsub-pull", + channelType: "googlechat" as const, + receivedCount: received.length, + ackedCount: ackIds.length, + skippedCount: skipped, + durationMs: now() - startedAt, + }, + "Pub/Sub pull cycle complete", + ); + + return { + receivedCount: received.length, + ackedCount: ackIds.length, + skippedCount: skipped, + pullFailed: false, + }; + } + + function start(): void { + if (running) return; + running = true; + controller = new AbortController(); + deps.logger.debug( + { step: "googlechat-pubsub-start", channelType: "googlechat" as const }, + "Pub/Sub pull loop starting", + ); + } + + async function stop(): Promise { + running = false; + controller.abort(); + deps.logger.info( + { channelType: "googlechat" as const }, + "Pub/Sub source stopped", + ); + } + + return { + start, + stop, + pollOnce, + get lastError() { + return lastError; + }, + get running() { + return running; + }, + }; +} From 07de1619e4e1bc0834d948ed821a0b0540d7056a Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 15:36:02 +0300 Subject: [PATCH 013/200] test(233-06): failing backoff/abort/loud-failure tests for the pull loop - bounded jittered exponential backoff (floor..cap) captured via an injected timer seam - backoff resets to the floor after a good pull - stop() aborts the in-flight long-poll (AbortController) and cancels a pending backoff timer - persistent failure logs a loud ERROR (errorKind + hint) and sets lastError; count resets after a good pull - RED: start() runs no self-rescheduling loop yet --- .../src/googlechat/pubsub-source.test.ts | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) diff --git a/packages/channels/src/googlechat/pubsub-source.test.ts b/packages/channels/src/googlechat/pubsub-source.test.ts index 738b32183..599839d06 100644 --- a/packages/channels/src/googlechat/pubsub-source.test.ts +++ b/packages/channels/src/googlechat/pubsub-source.test.ts @@ -348,3 +348,246 @@ describe("createPubSubSource — pull + ack-on-enqueue + dedup (pollOnce)", () = expect(onEvent).toHaveBeenCalledTimes(3); }); }); + +/** Drain the microtask queue so an awaited async loop can make progress. */ +async function flushMicrotasks(n = 40): Promise { + for (let i = 0; i < n; i += 1) await Promise.resolve(); +} + +/** + * A deterministic timer seam. It CAPTURES each scheduled delay and parks the + * callback (never firing on real time); `fireNext()` resolves the parked backoff + * so the loop advances one cycle without any real wait. `cleared` records the + * handles passed to the canceller so a stop()-cancels-backoff assertion can read + * them. Mirrors the fake-timers `unrefRecord` intent for leak assertions. + */ +function makeFakeTimers() { + const delays: number[] = []; + const cleared: unknown[] = []; + let pending: Array<{ id: number; cb: () => void }> = []; + let seq = 0; + const setTimeoutImpl = ((cb: () => void, ms: number) => { + const id = (seq += 1); + delays.push(ms); + pending.push({ id, cb }); + return id; + }) as unknown as PubSubSourceDeps["setTimeoutImpl"]; + const clearTimeoutImpl = ((handle: unknown) => { + cleared.push(handle); + pending = pending.filter((p) => p.id !== handle); + }) as unknown as PubSubSourceDeps["clearTimeoutImpl"]; + async function fireNext(): Promise { + const next = pending.shift(); + if (!next) throw new Error("no pending backoff timer to fire"); + next.cb(); + await flushMicrotasks(); + } + return { + setTimeoutImpl, + clearTimeoutImpl, + delays, + cleared, + fireNext, + pendingCount: () => pending.length, + }; +} + +/** A fetch that always fails with a retryable non-ok status. */ +function failingFetch() { + return vi.fn(async () => ({ + ok: false, + status: 503, + json: async () => ({}), + })) as unknown as typeof fetch; +} + +describe("createPubSubSource — bounded jittered backoff + AbortController stop + loud failure", () => { + it("backs off within [floor, floor+500] after the first pull failure (jitter bounded)", async () => { + const timers = makeFakeTimers(); + const { deps } = makeDeps({ + fetchImpl: failingFetch(), + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + rng: () => 0.5, + backoffFloorMs: 1000, + backoffCapMs: 30_000, + }); + const source = createPubSubSource(deps); + + source.start(); + await flushMicrotasks(); + + expect(timers.delays.length).toBeGreaterThanOrEqual(1); + expect(timers.delays[0]).toBe(1250); // 1000 + floor(0.5 * 500) + expect(timers.delays[0]).toBeGreaterThanOrEqual(1000); + expect(timers.delays[0]).toBeLessThanOrEqual(1500); + + await source.stop(); + }); + + it("doubles the backoff base per consecutive failure and caps it at backoffCapMs", async () => { + const timers = makeFakeTimers(); + const { deps } = makeDeps({ + fetchImpl: failingFetch(), + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + rng: () => 0.5, + backoffFloorMs: 1000, + backoffCapMs: 30_000, + }); + const source = createPubSubSource(deps); + + source.start(); + await flushMicrotasks(); // failure #1 → delays[0] + for (let i = 0; i < 6; i += 1) await timers.fireNext(); // 6 more failures + await source.stop(); + + const bases = timers.delays.map((d) => d - 250); // strip the fixed 250 jitter + expect(bases.slice(0, 7)).toEqual([ + 1000, 2000, 4000, 8000, 16000, 30000, 30000, + ]); + }); + + it("resets the backoff to the floor after a successful pull", async () => { + let call = 0; + const impl = vi.fn(async () => { + call += 1; + if (call === 3) { + return { ok: true, status: 200, json: async () => ({ receivedMessages: [] }) }; + } + return { ok: false, status: 503, json: async () => ({}) }; + }); + const timers = makeFakeTimers(); + const { deps } = makeDeps({ + fetchImpl: impl as unknown as typeof fetch, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + rng: () => 0.5, + backoffFloorMs: 1000, + backoffCapMs: 30_000, + }); + const source = createPubSubSource(deps); + + source.start(); + await flushMicrotasks(); // #1 fail → 1250 (base 1000), backoff→2000 + await timers.fireNext(); // #2 fail → 2250 (base 2000), backoff→4000 + await timers.fireNext(); // #3 success → reset; #4 fail → 1250 (base 1000) + await source.stop(); + + expect(timers.delays[0]).toBe(1250); + expect(timers.delays[1]).toBe(2250); + expect(timers.delays[2]).toBe(1250); // floor again after the success + }); + + it("aborts the in-flight long-poll when stop() is called", async () => { + let capturedSignal: AbortSignal | undefined; + const impl = vi.fn((_url: unknown, init?: RequestInit) => { + capturedSignal = init?.signal ?? undefined; + return new Promise((_resolve, reject) => { + capturedSignal?.addEventListener( + "abort", + () => reject(new DOMException("Aborted", "AbortError")), + { once: true }, + ); + }); + }); + const timers = makeFakeTimers(); + const { deps } = makeDeps({ + fetchImpl: impl as unknown as typeof fetch, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + const source = createPubSubSource(deps); + + source.start(); + await flushMicrotasks(); + expect(source.running).toBe(true); + expect(capturedSignal).toBeInstanceOf(AbortSignal); + expect(capturedSignal?.aborted).toBe(false); + + await source.stop(); + + expect(source.running).toBe(false); + expect(capturedSignal?.aborted).toBe(true); + }); + + it("cancels a pending backoff timer when stop() is called", async () => { + const timers = makeFakeTimers(); + const { deps } = makeDeps({ + fetchImpl: failingFetch(), + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + rng: () => 0.5, + }); + const source = createPubSubSource(deps); + + source.start(); + await flushMicrotasks(); // fail → parked in a backoff sleep + expect(timers.pendingCount()).toBe(1); + + await source.stop(); + + expect(timers.cleared.length).toBeGreaterThanOrEqual(1); + expect(timers.pendingCount()).toBe(0); + expect(source.running).toBe(false); + }); + + it("logs a loud ERROR with errorKind and hint after errorLogThreshold consecutive failures and sets lastError", async () => { + const timers = makeFakeTimers(); + const { deps, loggerSpy } = makeDeps({ + fetchImpl: failingFetch(), + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + rng: () => 0.5, + errorLogThreshold: 3, + }); + const source = createPubSubSource(deps); + + source.start(); + await flushMicrotasks(); // failure #1 + await timers.fireNext(); // failure #2 + await timers.fireNext(); // failure #3 → loud ERROR + await source.stop(); + + const errCall = loggerSpy.error.mock.calls + .map((c) => c[0]) + .find( + (p) => + p !== null && + typeof p === "object" && + typeof (p as { hint?: unknown }).hint === "string" && + typeof (p as { errorKind?: unknown }).errorKind === "string", + ); + expect(errCall).toBeDefined(); + expect(typeof source.lastError).toBe("string"); + }); + + it("resets the consecutive-failure count after a good pull so the loud ERROR does not re-fire", async () => { + let call = 0; + const impl = vi.fn(async () => { + call += 1; + if (call === 4) { + return { ok: true, status: 200, json: async () => ({ receivedMessages: [] }) }; + } + return { ok: false, status: 503, json: async () => ({}) }; + }); + const timers = makeFakeTimers(); + const { deps, loggerSpy } = makeDeps({ + fetchImpl: impl as unknown as typeof fetch, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + rng: () => 0.5, + errorLogThreshold: 3, + }); + const source = createPubSubSource(deps); + + source.start(); + await flushMicrotasks(); // #1 fail (count 1) + await timers.fireNext(); // #2 fail (count 2) + await timers.fireNext(); // #3 fail (count 3 → ERROR) + await timers.fireNext(); // #4 success (reset); #5 fail (count 1) + await source.stop(); + + expect(loggerSpy.error).toHaveBeenCalledTimes(1); + }); +}); From 906325da1bb04a17737eda55cffeb1e721fb067d Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 15:38:31 +0300 Subject: [PATCH 014/200] feat(233-06): bounded jittered backoff, abortable stop, loud persistent failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - start() runs a self-rescheduling loop; pull failures back off with jittered exponential delay (floor..cap), reset after a good pull - abortableSleep shares the AbortController signal with the pull fetch, so stop() aborts an in-flight long-poll and cancels a pending backoff timer - persistent failure (>= errorLogThreshold consecutive) logs a loud ERROR with errorKind + hint; the count resets after a good pull - sanctioned/injected systemSetTimeout/systemClearTimeout only — no raw timers or Date.now --- .../channels/src/googlechat/pubsub-source.ts | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/packages/channels/src/googlechat/pubsub-source.ts b/packages/channels/src/googlechat/pubsub-source.ts index e95ea3afc..dcd7105ef 100644 --- a/packages/channels/src/googlechat/pubsub-source.ts +++ b/packages/channels/src/googlechat/pubsub-source.ts @@ -51,6 +51,18 @@ const DEFAULT_MAX_MESSAGES = 10; /** Upper bound on the dedup seen-set before oldest-first eviction. */ const DEFAULT_SEEN_SET_MAX = 1000; +/** Backoff floor after the first pull failure, in ms. */ +const DEFAULT_BACKOFF_FLOOR_MS = 1000; + +/** Backoff ceiling the exponential doubling saturates at, in ms. */ +const DEFAULT_BACKOFF_CAP_MS = 30_000; + +/** Consecutive pull failures before the loop logs a loud ERROR. */ +const DEFAULT_ERROR_LOG_THRESHOLD = 3; + +/** Upper bound on the additive backoff jitter, in ms. */ +const JITTER_MS = 500; + /** Dependencies for the Pub/Sub pull-loop source. */ export interface PubSubSourceDeps { /** The subscription resource: `projects/{project}/subscriptions/{sub}`. */ @@ -165,6 +177,11 @@ export function createPubSubSource(deps: PubSubSourceDeps): PubSubSource { let lastError: string | undefined; let running = false; + // Consecutive `pullFailed` cycles; drives the loud-ERROR threshold and resets + // to 0 after any good pull. + let consecutiveFailures = 0; + // The in-flight backoff timer handle, so `stop()` can cancel a pending sleep. + let pendingBackoff: ReturnType | undefined; // Recreated in `start()`; passed to both the pull fetch and the ack fetch so a // `stop()` aborts an in-flight long-poll immediately. let controller = new AbortController(); @@ -355,19 +372,85 @@ export function createPubSubSource(deps: PubSubSourceDeps): PubSubSource { }; } + // Sleep for `ms`, resolving early if the controller aborts. The same signal + // drives both the pull fetch and this sleep, so `stop()` cancels an in-flight + // long-poll AND a pending backoff at once. + async function abortableSleep(ms: number): Promise { + if (controller.signal.aborted) return; + const setT = deps.setTimeoutImpl ?? systemSetTimeout; + const clearT = deps.clearTimeoutImpl ?? systemClearTimeout; + await new Promise((resolve) => { + const timer = setT(resolve, ms); + pendingBackoff = timer; + controller.signal.addEventListener( + "abort", + () => { + clearT(timer); + resolve(); + }, + { once: true }, + ); + }); + pendingBackoff = undefined; + } + + // The self-rescheduling pull loop: poll, and on failure back off with a + // bounded jittered exponential delay (reset after a good pull); on persistent + // failure log loudly so an operator sees a truly-dead loop. + async function runLoop(): Promise { + const floorMs = deps.backoffFloorMs ?? DEFAULT_BACKOFF_FLOOR_MS; + const capMs = deps.backoffCapMs ?? DEFAULT_BACKOFF_CAP_MS; + const threshold = deps.errorLogThreshold ?? DEFAULT_ERROR_LOG_THRESHOLD; + const rng = deps.rng ?? Math.random; + let backoff = floorMs; + + while (running && !controller.signal.aborted) { + const out = await pollOnce(); + // A stop() during the poll must not schedule another backoff. + if (!running || controller.signal.aborted) break; + + if (out.pullFailed) { + consecutiveFailures += 1; + if (consecutiveFailures >= threshold) { + deps.logger.error( + { + channelType: "googlechat" as const, + hint: "Pub/Sub pull is persistently failing; verify the service account has roles/pubsub.subscriber and the subscription exists", + errorKind: "network" as const, + consecutiveFailures, + }, + "Pub/Sub pull loop persistently failing", + ); + } + const jitter = Math.floor(rng() * JITTER_MS); + await abortableSleep(Math.min(backoff, capMs) + jitter); + backoff = Math.min(backoff * 2, capMs); + } else { + consecutiveFailures = 0; + backoff = floorMs; + } + } + } + function start(): void { if (running) return; running = true; controller = new AbortController(); + consecutiveFailures = 0; deps.logger.debug( { step: "googlechat-pubsub-start", channelType: "googlechat" as const }, "Pub/Sub pull loop starting", ); + void runLoop(); } async function stop(): Promise { running = false; controller.abort(); + if (pendingBackoff !== undefined) { + (deps.clearTimeoutImpl ?? systemClearTimeout)(pendingBackoff); + pendingBackoff = undefined; + } deps.logger.info( { channelType: "googlechat" as const }, "Pub/Sub source stopped", From 4e23bfe524d4dfa7d21e6de5afa2563a44a7fa79 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 15:50:31 +0300 Subject: [PATCH 015/200] test(233-07): add failing adapter test for inbound gate, dispatch, lifecycle, status - default-deny allowlist gate drops non-allowlisted senders before fanout - open mode and space-id allow admit; non-MESSAGE resolves without a handler - rejecting/sync-throwing handler rejects (skip-ack) with sibling isolation - getStatus polling connectionMode + lastInboundAt inbound-only semantics - start boots the injected source wired to handleChatEvent; stop stops it - reconcileSend always unresolved; platformAction unsupported; INV omitted methods absent --- .../src/googlechat/googlechat-adapter.test.ts | 387 ++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-adapter.test.ts diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts new file mode 100644 index 000000000..a5bededae --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -0,0 +1,387 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, vi } from "vitest"; +import type { ComisLogger } from "@comis/core"; +import { generateKeyPair, exportPKCS8 } from "jose"; +import { + createGoogleChatAdapter, + type GoogleChatAdapterDeps, +} from "./googlechat-adapter.js"; +import type { + PubSubSource, + PubSubSourceDeps, +} from "./pubsub-source.js"; + +const SA_EMAIL = "comis-bot@my-project.iam.gserviceaccount.com"; +const MINTED_TOKEN = "ya29.minted-access-token-xyz"; +const SUBSCRIPTION = "projects/my-project/subscriptions/comis-sub"; +const NOW = 1_000_000; + +/** A logger whose spies record every argument to every level for redaction asserts. */ +function makeLoggerSpy() { + const info = vi.fn(); + const warn = vi.fn(); + const debug = vi.fn(); + const error = vi.fn(); + const noop = vi.fn(); + const logger = { + level: "debug", + trace: noop, + debug, + info, + warn, + error, + fatal: noop, + audit: noop, + child: vi.fn().mockReturnThis(), + } as unknown as ComisLogger; + const serialized = () => + JSON.stringify([ + ...info.mock.calls, + ...warn.mock.calls, + ...debug.mock.calls, + ...error.mock.calls, + ]); + return { logger, serialized, info, warn, error, debug }; +} + +/** + * A real RS256 service-account key JSON an operator would supply — the mint and + * the credential validator parse it for `client_email` + `private_key`. + */ +async function makeServiceAccountKey(clientEmail = SA_EMAIL) { + const { privateKey } = await generateKeyPair("RS256", { extractable: true }); + const privateKeyPem = await exportPKCS8(privateKey); + return JSON.stringify({ + type: "service_account", + client_email: clientEmail, + private_key: privateKeyPem, + token_uri: "https://oauth2.googleapis.com/token", + }); +} + +/** A fetch stub returning a successful token exchange; captures its calls. */ +function makeTokenFetch(token = MINTED_TOKEN) { + const spy = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: token, expires_in: 3600 }), + })); + return spy as unknown as typeof fetch; +} + +/** A fake pull-loop source recording start/stop so lifecycle is testable loop-free. */ +function makeFakeSource(over: Partial = {}) { + const start = vi.fn(); + const stop = vi.fn(async () => {}); + const pollOnce = vi.fn(async () => ({ + receivedCount: 0, + ackedCount: 0, + skippedCount: 0, + pullFailed: false, + })); + const source: PubSubSource = { + start, + stop, + pollOnce, + lastError: undefined, + running: false, + ...over, + }; + return { source, start, stop }; +} + +/** Build adapter deps with injected logger, SA key, token fetch, and a fake source. */ +async function makeDeps(overrides: Partial = {}) { + const loggerSpy = makeLoggerSpy(); + const serviceAccountKey = await makeServiceAccountKey(); + const fake = makeFakeSource(); + const holder: { sourceDeps?: PubSubSourceDeps } = {}; + const deps: GoogleChatAdapterDeps = { + serviceAccountKey, + subscriptionName: SUBSCRIPTION, + allowFrom: [], + allowMode: "allowlist", + logger: loggerSpy.logger, + fetchImpl: makeTokenFetch(), + now: () => NOW, + createSource: (d: PubSubSourceDeps) => { + holder.sourceDeps = d; + return fake.source; + }, + ...overrides, + }; + return { deps, loggerSpy, fake, holder, serviceAccountKey }; +} + +/** Build a classic Chat MESSAGE interaction event. */ +function makeEvent( + over: { + type?: string; + senderName?: string; + spaceName?: string; + spaceType?: string; + text?: string; + messageName?: string; + } = {}, +): unknown { + const spaceName = over.spaceName ?? "spaces/AAAA"; + const space = { name: spaceName, spaceType: over.spaceType ?? "SPACE" }; + return { + type: over.type ?? "MESSAGE", + space, + message: { + name: over.messageName ?? "spaces/AAAA/messages/CCC", + sender: { name: over.senderName ?? "users/123" }, + text: over.text ?? "hello there", + space, + }, + }; +} + +/** Find a logged record at a level whose object arg has the given errorKind. */ +function findByErrorKind( + spy: ReturnType, + kind: string, +): Record | undefined { + return spy.mock.calls + .map((c) => c[0]) + .find( + (p) => + p !== null && + typeof p === "object" && + (p as { errorKind?: string }).errorKind === kind, + ) as Record | undefined; +} + +describe("createGoogleChatAdapter — inbound gate + dispatch", () => { + it("calls each registered handler with the mapped message for an allowed sender", async () => { + const { deps } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleChatEvent(makeEvent()); + + expect(handler).toHaveBeenCalledTimes(1); + const msg = handler.mock.calls[0][0] as { + senderId: string; + channelId: string; + text: string; + channelType: string; + }; + expect(msg.senderId).toBe("users/123"); + expect(msg.channelId).toBe("spaces/AAAA"); + expect(msg.text).toBe("hello there"); + expect(msg.channelType).toBe("googlechat"); + }); + + it("drops a non-allowlisted users/... sender BEFORE any handler runs and resolves (ack)", async () => { + const { deps, loggerSpy } = await makeDeps({ allowFrom: [] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await expect( + adapter.handleChatEvent(makeEvent({ senderName: "users/999" })), + ).resolves.toBeUndefined(); + + expect(handler).not.toHaveBeenCalled(); + const warn = findByErrorKind(loggerSpy.warn, "precondition"); + expect(warn).toBeDefined(); + expect(String(warn?.hint)).toContain("channels.googlechat.allowFrom"); + }); + + it("admits any sender when allowMode is 'open'", async () => { + const { deps } = await makeDeps({ allowMode: "open", allowFrom: [] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleChatEvent(makeEvent({ senderName: "users/anyone" })); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("admits an inbound whose channelId (space) is on the allowlist even if the sender is not", async () => { + const { deps } = await makeDeps({ allowFrom: ["spaces/AAAA"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleChatEvent(makeEvent({ senderName: "users/999" })); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("resolves without calling a handler for a non-MESSAGE event (mapper returns null)", async () => { + const { deps } = await makeDeps({ allowMode: "open" }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await expect( + adapter.handleChatEvent(makeEvent({ type: "ADDED_TO_SPACE" })), + ).resolves.toBeUndefined(); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("rejects (skip-ack signal) when a handler rejects, but still runs the sibling handler", async () => { + const { deps, loggerSpy } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + const rejecting = vi.fn(async () => { + throw new Error("inbound queue full"); + }); + const sibling = vi.fn(); + adapter.onMessage(rejecting); + adapter.onMessage(sibling); + + await expect(adapter.handleChatEvent(makeEvent())).rejects.toThrow( + "inbound queue full", + ); + + // Per-handler isolation: the sibling still ran even though the first rejected. + expect(sibling).toHaveBeenCalledTimes(1); + expect(findByErrorKind(loggerSpy.error, "internal")).toBeDefined(); + }); + + it("rejects (skip-ack signal) when a handler throws synchronously, and the sibling still runs", async () => { + const { deps } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + const throwing = vi.fn(() => { + throw new Error("sync boom"); + }); + const sibling = vi.fn(); + adapter.onMessage(throwing); + adapter.onMessage(sibling); + + await expect(adapter.handleChatEvent(makeEvent())).rejects.toThrow( + "sync boom", + ); + expect(sibling).toHaveBeenCalledTimes(1); + }); +}); + +describe("createGoogleChatAdapter — status + lastInboundAt semantics", () => { + it("reports connectionMode 'polling', channelType 'googlechat', and disconnected before start", async () => { + const { deps } = await makeDeps(); + const adapter = createGoogleChatAdapter(deps); + const status = adapter.getStatus?.(); + expect(status?.connectionMode).toBe("polling"); + expect(status?.channelType).toBe("googlechat"); + expect(status?.connected).toBe(false); + expect(status?.lastInboundAt).toBeUndefined(); + }); + + it("sets lastInboundAt after an allowed inbound", async () => { + const { deps } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + adapter.onMessage(vi.fn()); + await adapter.handleChatEvent(makeEvent()); + expect(adapter.getStatus?.().lastInboundAt).toBe(NOW); + }); + + it("does NOT set lastInboundAt when the only inbound was dropped by the gate", async () => { + const { deps } = await makeDeps({ allowFrom: [] }); + const adapter = createGoogleChatAdapter(deps); + adapter.onMessage(vi.fn()); + await adapter.handleChatEvent(makeEvent({ senderName: "users/999" })); + expect(adapter.getStatus?.().lastInboundAt).toBeUndefined(); + }); + + it("surfaces the source lastError in getStatus.error", async () => { + const fake = makeFakeSource({ lastError: "pubsub token mint failed" }); + const { deps } = await makeDeps({ createSource: () => fake.source }); + const adapter = createGoogleChatAdapter(deps); + await adapter.start(); + expect(adapter.getStatus?.().error).toBe("pubsub token mint failed"); + }); +}); + +describe("createGoogleChatAdapter — lifecycle", () => { + it("start() with a blank service-account key returns err, logs ERROR, and does NOT boot the loop", async () => { + const { deps, loggerSpy, fake } = await makeDeps({ serviceAccountKey: "" }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.start(); + + expect(result.ok).toBe(false); + expect(fake.start).not.toHaveBeenCalled(); + expect(adapter.getStatus?.().connected).toBe(false); + expect(loggerSpy.error).toHaveBeenCalled(); + }); + + it("start() with valid creds returns ok, marks connected, and boots the source wired to handleChatEvent", async () => { + const { deps, fake, holder } = await makeDeps(); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.start(); + + expect(result.ok).toBe(true); + expect(fake.start).toHaveBeenCalledTimes(1); + expect(adapter.getStatus?.().connected).toBe(true); + // The loop dispatches inbound through the same gated handler the unit drives. + expect(holder.sourceDeps?.onEvent).toBe(adapter.handleChatEvent); + expect(holder.sourceDeps?.subscriptionName).toBe(SUBSCRIPTION); + expect(typeof holder.sourceDeps?.getPubSubToken).toBe("function"); + }); + + it("stop() stops the source and marks disconnected", async () => { + const { deps, fake } = await makeDeps(); + const adapter = createGoogleChatAdapter(deps); + await adapter.start(); + + const result = await adapter.stop(); + + expect(result.ok).toBe(true); + expect(fake.stop).toHaveBeenCalledTimes(1); + expect(adapter.getStatus?.().connected).toBe(false); + }); +}); + +describe("createGoogleChatAdapter — reconcile + platformAction + capability honesty", () => { + it("reconcileSend always resolves ok({ kind: 'unresolved' }) — never not_sent", async () => { + const { deps } = await makeDeps(); + const adapter = createGoogleChatAdapter(deps); + const result = await adapter.reconcileSend?.({ + channelId: "spaces/AAAA", + contentDigest: "abc", + sentAfterMs: 1, + sentBeforeMs: 2, + }); + expect(result?.ok).toBe(true); + if (result?.ok) expect(result.value.kind).toBe("unresolved"); + }); + + it("platformAction resolves err naming the unsupported action on googlechat", async () => { + const { deps } = await makeDeps(); + const adapter = createGoogleChatAdapter(deps); + const result = await adapter.platformAction("pin", {}); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toBe("Unsupported action: pin on googlechat"); + } + }); + + it("omits every unbacked optional method (no silent capability)", async () => { + const { deps } = await makeDeps(); + const adapter = createGoogleChatAdapter(deps) as Record; + for (const method of [ + "editMessage", + "deleteMessage", + "onReaction", + "reactToMessage", + "removeReaction", + "fetchMessages", + "sendAttachment", + ]) { + expect(typeof adapter[method]).toBe("undefined"); + } + }); + + it("exposes the pub/sub token provider for the send path and later wiring", async () => { + const { deps } = await makeDeps(); + const adapter = createGoogleChatAdapter(deps); + expect(typeof adapter.getPubSubTokenProvider().getToken).toBe("function"); + }); +}); From 5743499701c7bd3fee0134e3b130cfc98b4ddb17 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 15:52:11 +0300 Subject: [PATCH 016/200] =?UTF-8?q?feat(233-07):=20compose=20Google=20Chat?= =?UTF-8?q?=20adapter=20=E2=80=94=20gate,=20dispatch,=20lifecycle,=20statu?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - default-deny allowlist gate drops non-allowlisted senders before fanout - handleChatEvent fans out under a fresh trace context; rethrows on handler failure as the pull loop's skip-ack signal, with per-handler isolation - start validates creds then opens the pull loop wired to handleChatEvent; stop stops it - getStatus reports polling connectionMode with inbound-only lastInboundAt and source lastError - reconcileSend always unresolved; platformAction reports unsupported actions - edit/delete/reaction/attachment methods omitted (capability honesty) --- .../src/googlechat/googlechat-adapter.ts | 343 ++++++++++++++++++ 1 file changed, 343 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-adapter.ts diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts new file mode 100644 index 000000000..ee36620f2 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -0,0 +1,343 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat Channel Adapter: a pull-driven ChannelPort implementation. + * + * This is the composition point — it wires the service-account token provider, + * the Pub/Sub REST pull loop, the message mapper, and the allowlist gate into a + * working inbound+outbound text round-trip with no public inbound endpoint. + * + * `start()` validates credentials and OPENS the pull loop; inbound events arrive + * through `handleChatEvent`, which the loop calls once per decoded event. Each + * event is mapped, gated against the sender allowlist, and — for an admitted + * sender — fanned out to the registered message handlers under a fresh request + * context. The fanout is AWAITED and rethrows on a handler failure: that rejection + * is the loop's skip-ack signal, so a failed enqueue is redelivered rather than + * dropped. + * + * Outbound `sendMessage` posts to the Chat REST API with a chat.bot bearer token + * minted through the service-account provider; the token is never logged. + * + * The adapter delivers a clean NormalizedMessage — external-content fencing is a + * shared executor concern applied to every channel, deliberately not duplicated + * here. + * + * Only the text-only surface is declared. Edit, delete, reaction, and attachment + * methods are OMITTED: a service-account app cannot reach those method/auth + * surfaces, so advertising them would be dishonest — the daemon capability gate + * blocks any call the (false) capability flags forbid. + * + * @module + */ + +import { randomUUID } from "node:crypto"; +import { runWithContext, systemNowMs } from "@comis/core"; +import type { + ChannelPort, + ChannelStatus, + ComisLogger, + MessageHandler, + ReconcileSendOutcome, + ReconcileSendQuery, + SendMessageOptions, +} from "@comis/core"; +import { ok, err, type Result } from "@comis/shared"; +import { + createGoogleChatTokenProvider, + PUBSUB_SCOPE, + type GoogleChatTokenProvider, +} from "./googlechat-auth.js"; +import { + createPubSubSource, + type PubSubSource, + type PubSubSourceDeps, +} from "./pubsub-source.js"; +import { + mapGoogleChatEventToNormalized, + type GoogleChatEvent, +} from "./message-mapper.js"; +import { validateGoogleChatCredentials } from "./credential-validator.js"; + +/** Dependencies for the Google Chat adapter. */ +export interface GoogleChatAdapterDeps { + /** The resolved service-account key JSON string (a SecretRef resolved upstream); never logged. */ + serviceAccountKey: string; + /** The Pub/Sub pull subscription resource: `projects/{project}/subscriptions/{sub}`. */ + subscriptionName: string; + /** Sender ids (`users/{id}`) and/or space ids (`spaces/{id}`) allowed to reach handlers. */ + allowFrom: string[]; + /** "allowlist" (default) drops unknown senders; "open" processes any sender. */ + allowMode: "allowlist" | "open"; + /** Logger for the inbound/outbound boundary matrix. */ + logger: ComisLogger; + /** Inbound transport mode. Only "pubsub" ingress is available here; absent → pubsub. */ + mode?: "pubsub" | "webhook"; + /** Injected fetch, defaulting to the global; lets a unit test stub the exchange/send. */ + fetchImpl?: typeof fetch; + /** Injected clock in ms, defaulting to systemNowMs; makes timing deterministic. */ + now?: () => number; + /** Chat REST base-URL override — a test-only seam. */ + chatBaseUrl?: string; + /** Pub/Sub base-URL override — a test-only seam. */ + pubsubBaseUrl?: string; + /** Token-endpoint URL override — a test-only seam. */ + tokenUrl?: string; + /** Injected one-shot timer for the pull-loop backoff. */ + setTimeoutImpl?: typeof import("@comis/core").systemSetTimeout; + /** Injected timer canceller for the pull-loop backoff. */ + clearTimeoutImpl?: typeof import("@comis/core").systemClearTimeout; + /** + * Pull-loop source factory. Defaults to {@link createPubSubSource}; a unit test + * injects a fake source so lifecycle is exercised without a real network loop. + */ + createSource?: (deps: PubSubSourceDeps) => PubSubSource; +} + +/** + * The adapter handle: the ChannelPort surface plus the loop's inbound dispatch + * and the token-provider accessor the send path (and later wiring) reuse. + */ +export interface GoogleChatAdapterHandle extends ChannelPort { + /** The inbound dispatch the pull loop calls once per decoded event. */ + handleChatEvent(event: unknown): Promise; + /** The per-scope service-account token provider. */ + getPubSubTokenProvider(): GoogleChatTokenProvider; +} + +/** + * Build a Google Chat adapter. `start()` validates credentials and opens the pull + * loop; inbound flows through {@link GoogleChatAdapterHandle.handleChatEvent}. + */ +export function createGoogleChatAdapter( + deps: GoogleChatAdapterDeps, +): GoogleChatAdapterHandle { + const now = deps.now ?? systemNowMs; + const tokens = createGoogleChatTokenProvider({ + serviceAccountKey: deps.serviceAccountKey, + logger: deps.logger, + ...(deps.fetchImpl && { fetchImpl: deps.fetchImpl }), + ...(deps.now && { now: deps.now }), + ...(deps.tokenUrl && { tokenUrl: deps.tokenUrl }), + }); + + const handlers: MessageHandler[] = []; + const _channelId = "googlechat"; + + let _connected = false; + let _startedAt: number | undefined; + let _lastMessageAt: number | undefined; + // Inbound-only liveness: bumped ONLY on an admitted inbound, never on an + // outbound send or a dropped inbound — so a send-only bot cannot mask a dead + // ingress the way an outbound-polluted timestamp would. + let _lastInboundAt: number | undefined; + let _lastError: string | undefined; + let source: PubSubSource | undefined; + + /** + * The single sender-authorization gate the inbound path uses. In allowlist mode + * an inbound is admitted only when its sender id OR its space id is on the + * allowlist; "open" mode admits all. One authoritative gate: the default-deny + * decision is made in exactly one place. + */ + function isAllowedSender(senderId: string, channelId: string): boolean { + if (deps.allowMode !== "allowlist") return true; + return ( + deps.allowFrom.includes(senderId) || deps.allowFrom.includes(channelId) + ); + } + + async function handleChatEvent(event: unknown): Promise { + const normalized = mapGoogleChatEventToNormalized(event as GoogleChatEvent); + if (!normalized) return; // non-MESSAGE → nothing to dispatch → ack (resolve) + + if (!isAllowedSender(normalized.senderId, normalized.channelId)) { + deps.logger.warn( + { + channelType: "googlechat" as const, + senderId: normalized.senderId, + hint: "Add the sender users/{id} or the space spaces/{id} to channels.googlechat.allowFrom", + errorKind: "precondition" as const, + }, + "Inbound from non-allowlisted sender dropped", + ); + return; // drop BEFORE any processing → ack (resolve) + } + + _lastMessageAt = now(); + _lastInboundAt = now(); + + const traceId = randomUUID(); + normalized.metadata.traceId = traceId; + + await runWithContext( + { + traceId, + startedAt: now(), + channelType: "googlechat", + tenantId: "default", + trustLevel: "admin", + }, + async () => { + // Defer each handler into its own microtask so a synchronous throw becomes + // a rejected promise and never aborts a sibling; allSettled runs them all. + const results = await Promise.allSettled( + handlers.map((h) => Promise.resolve().then(() => h(normalized))), + ); + const failed = results.find((r) => r.status === "rejected"); + if (failed && failed.status === "rejected") { + deps.logger.error( + { + channelType: "googlechat" as const, + err: failed.reason, + hint: "Inbound handler failed; ack is skipped so Pub/Sub redelivers", + errorKind: "internal" as const, + }, + "Inbound message handler error", + ); + // Rethrow so the pull loop skips the ack and the message redelivers. + throw failed.reason instanceof Error + ? failed.reason + : new Error(String(failed.reason)); + } + }, + ); + } + + const adapter: GoogleChatAdapterHandle = { + channelId: _channelId, + channelType: "googlechat", + + onMessage(handler: MessageHandler): void { + handlers.push(handler); + }, + + handleChatEvent, + + getPubSubTokenProvider(): GoogleChatTokenProvider { + return tokens; + }, + + async start(): Promise> { + const v = validateGoogleChatCredentials({ + serviceAccountKey: deps.serviceAccountKey, + subscriptionName: deps.subscriptionName, + allowFrom: deps.allowFrom, + logger: deps.logger, + }); + if (!v.ok) { + deps.logger.error( + { + channelType: "googlechat" as const, + err: v.error, + hint: "Set channels.googlechat.serviceAccountKey (SecretRef) or GOOGLECHAT_SA_KEY, and subscriptionName", + errorKind: "auth" as const, + }, + "Adapter start failed", + ); + _lastError = v.error.message; + return err(v.error); + } + + if (deps.mode && deps.mode !== "pubsub") { + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "Webhook-mode ingress is not available yet; only pubsub pull is supported", + errorKind: "precondition" as const, + }, + "Requested mode not available; using pubsub pull", + ); + } + + const make = deps.createSource ?? createPubSubSource; + source = make({ + subscriptionName: deps.subscriptionName, + getPubSubToken: () => tokens.getToken(PUBSUB_SCOPE), + onEvent: handleChatEvent, + logger: deps.logger, + ...(deps.fetchImpl && { fetchImpl: deps.fetchImpl }), + ...(deps.now && { now: deps.now }), + ...(deps.pubsubBaseUrl && { pubsubBaseUrl: deps.pubsubBaseUrl }), + ...(deps.setTimeoutImpl && { setTimeoutImpl: deps.setTimeoutImpl }), + ...(deps.clearTimeoutImpl && { clearTimeoutImpl: deps.clearTimeoutImpl }), + }); + source.start(); + _connected = true; + _startedAt = now(); + deps.logger.info( + { channelType: "googlechat" as const, mode: "pubsub" }, + "Adapter started", + ); + return ok(undefined); + }, + + async stop(): Promise> { + await source?.stop(); + _connected = false; + deps.logger.info( + { channelType: "googlechat" as const }, + "Adapter stopped", + ); + return ok(undefined); + }, + + async sendMessage( + _channelId: string, + _text: string, + _options?: SendMessageOptions, + ): Promise> { + return err(new Error("send is not implemented")); + }, + + getStatus(): ChannelStatus { + return { + connected: _connected, + channelId: _channelId, + channelType: "googlechat", + uptime: + _connected && _startedAt !== undefined + ? now() - _startedAt + : undefined, + lastMessageAt: _lastMessageAt, + lastInboundAt: _lastInboundAt, + error: _lastError ?? source?.lastError, + connectionMode: "polling", + }; + }, + + async reconcileSend( + _query: ReconcileSendQuery, + ): Promise> { + // A service-account app cannot query the platform for "did this send land?", + // so unresolved is the only honest verdict: recovery parks it (never a replay + // → never a double-send), and exactly-once is the outward-send ledger's + // write-ahead dedup, not this oracle. Never claim a definitive absence. + deps.logger.debug( + { + channelType: "googlechat" as const, + hint: "No app-auth history oracle; exactly-once is the outward-send ledger's write-ahead dedup", + }, + "reconcileSend unresolved", + ); + return ok({ kind: "unresolved" }); + }, + + async platformAction( + action: string, + _params: Record, + ): Promise> { + const e = new Error(`Unsupported action: ${action} on googlechat`); + deps.logger.warn( + { + channelType: "googlechat" as const, + err: e, + hint: `Action '${action}' is not supported by the Google Chat adapter`, + errorKind: "validation" as const, + }, + "Unsupported platform action", + ); + return err(e); + }, + }; + + return adapter; +} From 456b43418485c26ce78c602762f6b160799c897c Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 15:53:18 +0300 Subject: [PATCH 017/200] test(233-07): add failing send test for messages.create with chat.bot bearer - happy path POSTs {text} to the space messages endpoint with a Bearer header - non-ok status returns err, logs errorKind+hint, and never logs the token - transport reject classifies network - an outbound send bumps lastMessageAt but not lastInboundAt --- .../src/googlechat/googlechat-adapter.test.ts | 89 +++++++++++++++++++ 1 file changed, 89 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index a5bededae..84bb601a5 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -69,6 +69,34 @@ function makeTokenFetch(token = MINTED_TOKEN) { return spy as unknown as typeof fetch; } +/** + * A fetch stub that answers the token exchange with a bearer, then the Chat + * `messages` endpoint with a created message resource — routed by URL so one + * spy captures both the mint and the send. + */ +function makeChatFetch( + opts: { sendStatus?: number; sendName?: string; sendThrows?: boolean } = {}, +) { + const sendStatus = opts.sendStatus ?? 200; + const sendName = opts.sendName ?? "spaces/AAAA/messages/CCC"; + const spy = vi.fn(async (url: string, _init?: RequestInit) => { + if (String(url).includes("/messages")) { + if (opts.sendThrows) throw new Error("connect ECONNREFUSED"); + return { + ok: sendStatus >= 200 && sendStatus < 300, + status: sendStatus, + json: async () => ({ name: sendName }), + }; + } + return { + ok: true, + status: 200, + json: async () => ({ access_token: MINTED_TOKEN, expires_in: 3600 }), + }; + }); + return { fetchImpl: spy as unknown as typeof fetch, spy }; +} + /** A fake pull-loop source recording start/stop so lifecycle is testable loop-free. */ function makeFakeSource(over: Partial = {}) { const start = vi.fn(); @@ -385,3 +413,64 @@ describe("createGoogleChatAdapter — reconcile + platformAction + capability ho expect(typeof adapter.getPubSubTokenProvider().getToken).toBe("function"); }); }); + +describe("createGoogleChatAdapter — sendMessage (messages.create)", () => { + it("mints a chat.bot bearer and POSTs {text} to the space messages endpoint, returning the message name", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.sendMessage("spaces/AAAA", "hello"); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe("spaces/AAAA/messages/CCC"); + + const sendCall = spy.mock.calls.find(([u]) => + String(u).includes("/messages"), + ) as [string, RequestInit] | undefined; + expect(sendCall).toBeDefined(); + const [url, init] = sendCall as [string, RequestInit]; + expect(url).toBe("https://chat.googleapis.com/v1/spaces/AAAA/messages"); + expect(init.method).toBe("POST"); + const headers = init.headers as Record; + expect(headers.authorization).toBe(`Bearer ${MINTED_TOKEN}`); + expect(headers["content-type"]).toBe("application/json"); + expect(JSON.parse(String(init.body))).toEqual({ text: "hello" }); + }); + + it("returns err on a non-ok status, logs an ERROR with errorKind+hint, and never logs the token", async () => { + const { fetchImpl } = makeChatFetch({ sendStatus: 403 }); + const { deps, loggerSpy } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.sendMessage("spaces/AAAA", "denied"); + + expect(result.ok).toBe(false); + const errRec = findByErrorKind(loggerSpy.error, "auth"); + expect(errRec).toBeDefined(); + expect(String(errRec?.hint).length).toBeGreaterThan(0); + expect(loggerSpy.serialized()).not.toContain(MINTED_TOKEN); + }); + + it("returns err classified network when the send transport rejects", async () => { + const { fetchImpl } = makeChatFetch({ sendThrows: true }); + const { deps, loggerSpy } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.sendMessage("spaces/AAAA", "hi"); + + expect(result.ok).toBe(false); + expect(findByErrorKind(loggerSpy.error, "network")).toBeDefined(); + }); + + it("does NOT bump lastInboundAt on an outbound send (bumps lastMessageAt only)", async () => { + const { fetchImpl } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + await adapter.sendMessage("spaces/AAAA", "hello"); + + expect(adapter.getStatus?.().lastInboundAt).toBeUndefined(); + expect(adapter.getStatus?.().lastMessageAt).toBe(NOW); + }); +}); From c37c356ebf3acbd11edfa0d2f1966c2b0fe35fed Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 15:54:22 +0300 Subject: [PATCH 018/200] feat(233-07): send Google Chat text via messages.create with a chat.bot bearer - mint a chat.bot-scope token, POST {text} to the space messages endpoint - return the created message resource name on success - classify a non-ok status / transport reject with a secret-free errorKind+hint; never log the token - bump lastMessageAt on a successful send (never lastInboundAt) --- .../src/googlechat/googlechat-adapter.ts | 65 +++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index ee36620f2..833f39ceb 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -40,12 +40,14 @@ import type { ReconcileSendQuery, SendMessageOptions, } from "@comis/core"; -import { ok, err, type Result } from "@comis/shared"; +import { ok, err, fromPromise, type Result } from "@comis/shared"; import { createGoogleChatTokenProvider, + CHAT_SCOPE, PUBSUB_SCOPE, type GoogleChatTokenProvider, } from "./googlechat-auth.js"; +import { classifyGoogleChatError } from "./errors.js"; import { createPubSubSource, type PubSubSource, @@ -281,11 +283,66 @@ export function createGoogleChatAdapter( }, async sendMessage( - _channelId: string, - _text: string, + channelId: string, + text: string, _options?: SendMessageOptions, ): Promise> { - return err(new Error("send is not implemented")); + const tok = await tokens.getToken(CHAT_SCOPE); + if (!tok.ok) return err(tok.error); // auth already logged a secret-free WARN + const chatBase = deps.chatBaseUrl ?? "https://chat.googleapis.com/v1"; + const doFetch = deps.fetchImpl ?? fetch; + const responded = await fromPromise( + doFetch(`${chatBase}/${channelId}/messages`, { + method: "POST", + headers: { + authorization: `Bearer ${tok.value}`, + "content-type": "application/json", + }, + body: JSON.stringify({ text }), + }), + ); + if (!responded.ok) { + const c = classifyGoogleChatError(undefined, responded.error); + deps.logger.error( + { + channelType: "googlechat" as const, + hint: c.hint, + errorKind: c.errorKind, + }, + "Google Chat send failed: no response", + ); + return err(responded.error); + } + const res = responded.value; + if (!res.ok) { + const c = classifyGoogleChatError(res.status); + deps.logger.error( + { + channelType: "googlechat" as const, + status: res.status, + hint: c.hint, + errorKind: c.errorKind, + }, + "Google Chat send failed: error status", + ); + return err(new Error(`chat messages.create returned status ${res.status}`)); + } + const parsed = await fromPromise( + res.json() as Promise<{ name?: string }>, + ); + if (!parsed.ok || !parsed.value.name) { + deps.logger.error( + { + channelType: "googlechat" as const, + hint: "messages.create returned no message name", + errorKind: "platform" as const, + }, + "Google Chat send failed: unreadable response", + ); + return err(new Error("messages.create returned no name")); + } + _lastMessageAt = now(); + return ok(parsed.value.name); }, getStatus(): ChannelStatus { From 2c6ecab0187b2b5c74c9ad91e137705270c2edc0 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 16:05:39 +0300 Subject: [PATCH 019/200] test(233-08): add failing test for googlechat plugin honest text-only CAPABILITIES - createGoogleChatPlugin identity (channel-googlechat, channelType googlechat) + adapter wrap - exact text-only feature matrix deep-equal (all false, buttons none) - maxMessageChars 4000 + replyToMetaKey googlechatMessageName - capability parity: every false/none flag omits its adapter method - activate/deactivate delegate to adapter.start/stop; register returns ok --- .../src/googlechat/googlechat-plugin.test.ts | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-plugin.test.ts diff --git a/packages/channels/src/googlechat/googlechat-plugin.test.ts b/packages/channels/src/googlechat/googlechat-plugin.test.ts new file mode 100644 index 000000000..d7fc68174 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-plugin.test.ts @@ -0,0 +1,195 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, vi } from "vitest"; +import type { ComisLogger, PluginRegistryApi } from "@comis/core"; +import { generateKeyPair, exportPKCS8 } from "jose"; +import { createGoogleChatPlugin } from "./googlechat-plugin.js"; +import type { GoogleChatAdapterDeps } from "./googlechat-adapter.js"; +import type { PubSubSource } from "./pubsub-source.js"; + +const SA_EMAIL = "comis-bot@my-project.iam.gserviceaccount.com"; +const MINTED_TOKEN = "ya29.minted-access-token-xyz"; +const SUBSCRIPTION = "projects/my-project/subscriptions/comis-sub"; +const NOW = 1_000_000; + +/** A silent logger — the plugin surface under test emits nothing on the happy path. */ +function makeLogger(): ComisLogger { + const noop = vi.fn(); + return { + level: "debug", + trace: noop, + debug: noop, + info: noop, + warn: noop, + error: noop, + fatal: noop, + audit: noop, + child: vi.fn().mockReturnThis(), + } as unknown as ComisLogger; +} + +/** + * A real RS256 service-account key JSON an operator would supply — the mint and + * the credential validator parse it for `client_email` + `private_key`, so + * start() (and therefore activate()) succeeds. + */ +async function makeServiceAccountKey(clientEmail = SA_EMAIL) { + const { privateKey } = await generateKeyPair("RS256", { extractable: true }); + const privateKeyPem = await exportPKCS8(privateKey); + return JSON.stringify({ + type: "service_account", + client_email: clientEmail, + private_key: privateKeyPem, + token_uri: "https://oauth2.googleapis.com/token", + }); +} + +/** A fetch stub returning a successful token exchange so no network is touched. */ +function makeTokenFetch(token = MINTED_TOKEN) { + return vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ access_token: token, expires_in: 3600 }), + })) as unknown as typeof fetch; +} + +/** A fake pull-loop source recording start/stop so lifecycle is testable loop-free. */ +function makeFakeSource(over: Partial = {}) { + const start = vi.fn(); + const stop = vi.fn(async () => {}); + const pollOnce = vi.fn(async () => ({ + receivedCount: 0, + ackedCount: 0, + skippedCount: 0, + pullFailed: false, + })); + const source: PubSubSource = { + start, + stop, + pollOnce, + lastError: undefined, + running: false, + ...over, + }; + return { source, start, stop }; +} + +/** Build plugin/adapter deps with an injected logger, SA key, token fetch, and a fake source. */ +async function makeDeps(overrides: Partial = {}) { + const serviceAccountKey = await makeServiceAccountKey(); + const fake = makeFakeSource(); + const deps: GoogleChatAdapterDeps = { + serviceAccountKey, + subscriptionName: SUBSCRIPTION, + allowFrom: [], + allowMode: "allowlist", + logger: makeLogger(), + fetchImpl: makeTokenFetch(), + now: () => NOW, + createSource: () => fake.source, + ...overrides, + }; + return { deps, fake }; +} + +describe("createGoogleChatPlugin — identity + adapter wrap", () => { + it("wraps the adapter as a ChannelPluginPort with the googlechat identity", async () => { + const { deps } = await makeDeps(); + const plugin = createGoogleChatPlugin(deps); + + expect(plugin.id).toBe("channel-googlechat"); + expect(plugin.name).toBe("Google Chat Channel Plugin"); + expect(plugin.channelType).toBe("googlechat"); + expect(plugin.adapter.channelType).toBe("googlechat"); + // The wrapped adapter is a real googlechat adapter handle (it carries the + // pull-loop dispatch createGoogleChatAdapter exposes). + expect( + typeof (plugin.adapter as { handleChatEvent?: unknown }).handleChatEvent, + ).toBe("function"); + }); + + it("register(api) returns ok(undefined)", async () => { + const { deps } = await makeDeps(); + const plugin = createGoogleChatPlugin(deps); + + const result = plugin.register({} as unknown as PluginRegistryApi); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBeUndefined(); + }); +}); + +describe("createGoogleChatPlugin — honest text-only interim CAPABILITIES", () => { + it("declares the exact text-only feature matrix (deep-equal)", async () => { + const { deps } = await makeDeps(); + const plugin = createGoogleChatPlugin(deps); + + // Deep-equal on the DECLARED literal (not a schema-parsed shape): every + // optional feature is off and there is no button surface. + expect(plugin.capabilities.features).toEqual({ + reactions: false, + editMessages: false, + deleteMessages: false, + fetchHistory: false, + attachments: false, + typing: false, + threads: false, + buttons: "none", + }); + }); + + it("bounds outbound at maxMessageChars 4000 and pins replyToMetaKey", async () => { + const { deps } = await makeDeps(); + const plugin = createGoogleChatPlugin(deps); + + expect(plugin.capabilities.limits.maxMessageChars).toBe(4000); + expect(plugin.capabilities.replyToMetaKey).toBe("googlechatMessageName"); + }); +}); + +describe("createGoogleChatPlugin — capability parity (every false flag omits its method)", () => { + it("OMITS the adapter method behind every false/none capability flag", async () => { + const { deps } = await makeDeps(); + const plugin = createGoogleChatPlugin(deps); + const adapter = plugin.adapter as Record; + + // The inverse of the msteams `attachments:true → sendAttachment is a + // function` assertion: for a text-only app every advertised-false capability + // has its method absent, so the daemon capability gate (requireMethod) + // blocks the call rather than reaching an unimplemented path. + const omittedForFalseFlag: Record = { + editMessage: "editMessages:false", + deleteMessage: "deleteMessages:false", + reactToMessage: "reactions:false", + removeReaction: "reactions:false", + onReaction: "reactions:false", + fetchMessages: "fetchHistory:false", + sendAttachment: "attachments:false", + }; + for (const method of Object.keys(omittedForFalseFlag)) { + expect(typeof adapter[method]).toBe("undefined"); + } + }); +}); + +describe("createGoogleChatPlugin — lifecycle delegation", () => { + it("activate() delegates to adapter.start() (opens the pull-loop source)", async () => { + const { deps, fake } = await makeDeps(); + const plugin = createGoogleChatPlugin(deps); + + const result = await plugin.activate(); + + expect(result.ok).toBe(true); + expect(fake.start).toHaveBeenCalledTimes(1); + }); + + it("deactivate() delegates to adapter.stop() (stops the source)", async () => { + const { deps, fake } = await makeDeps(); + const plugin = createGoogleChatPlugin(deps); + await plugin.activate(); + + const result = await plugin.deactivate(); + + expect(result.ok).toBe(true); + expect(fake.stop).toHaveBeenCalledTimes(1); + }); +}); From cbaee557c162b95fe0cfabfac5fbb597b12d1621 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 16:06:39 +0300 Subject: [PATCH 020/200] feat(233-08): googlechat plugin wraps adapter with honest text-only CAPABILITIES - createGoogleChatPlugin(deps) -> ChannelPluginPort (channelType googlechat) - interim matrix: reactions/editMessages/deleteMessages/fetchHistory/attachments/typing/threads false, buttons none - limits.maxMessageChars 4000; replyToMetaKey googlechatMessageName - every false/none flag has its adapter method omitted (capability gate blocks it) - activate/deactivate delegate to adapter.start/stop; register returns ok(undefined) --- .../src/googlechat/googlechat-plugin.ts | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-plugin.ts diff --git a/packages/channels/src/googlechat/googlechat-plugin.ts b/packages/channels/src/googlechat/googlechat-plugin.ts new file mode 100644 index 000000000..526ded71e --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-plugin.ts @@ -0,0 +1,79 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat Channel Plugin: wraps the Google Chat adapter as a + * ChannelPluginPort with an honest, text-only capability matrix. + * + * The adapter implements a text send/receive round-trip over Pub/Sub pull and + * nothing more, so every optional feature flag is `false` and `buttons` is + * `"none"`. That honesty is load-bearing: the daemon capability gate + * (requireMethod) throws if a capability is advertised whose adapter method is + * omitted. Each `false`/`"none"` flag here has its method deliberately OMITTED + * from the adapter, so a forbidden call is blocked at the gate rather than + * silently reaching an unimplemented path — the honest-capability contract. + * + * activate() delegates to adapter.start() (which opens the pull loop) and + * deactivate() to adapter.stop(). The plugin returns a plain ChannelPluginPort: + * an inbound-media resolver handle is not part of the text-only surface. + * + * @module + */ + +import type { + ChannelCapability, + ChannelPluginPort, + PluginRegistryApi, +} from "@comis/core"; +import { ok, type Result } from "@comis/shared"; +import { + createGoogleChatAdapter, + type GoogleChatAdapterDeps, +} from "./googlechat-adapter.js"; + +/** Google Chat platform capabilities — the honest text-only interim matrix. */ +const CAPABILITIES: ChannelCapability = { + features: { + reactions: false, // user-auth-only — permanently omitted + editMessages: false, // edit not implemented (method omitted so the capability gate blocks it) + deleteMessages: false, // delete not implemented + fetchHistory: false, // admin-approval-gated + attachments: false, // outbound upload is user-auth-only + typing: false, // no typing API + threads: false, // threaded replies not implemented (mapper still captures threadId) + buttons: "none", // no card buttons yet — text-only + }, + limits: { maxMessageChars: 4000 }, + replyToMetaKey: "googlechatMessageName", +}; + +/** + * Create a Google Chat channel plugin wrapping the Google Chat adapter. + * + * activate() delegates to adapter.start() and deactivate() to adapter.stop(), + * while the plugin declares its honest text-only capability matrix. + */ +export function createGoogleChatPlugin( + deps: GoogleChatAdapterDeps, +): ChannelPluginPort { + const adapter = createGoogleChatAdapter(deps); + + return { + id: "channel-googlechat", + name: "Google Chat Channel Plugin", + version: "1.0.0", + channelType: "googlechat", + capabilities: CAPABILITIES, + adapter, + + register(_api: PluginRegistryApi): Result { + return ok(undefined); + }, + + async activate(): Promise> { + return adapter.start(); + }, + + async deactivate(): Promise> { + return adapter.stop(); + }, + }; +} From 576f36ba59b12e58c3a93485798935eed246d7cf Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 16:07:21 +0300 Subject: [PATCH 021/200] test(233-08): pin googlechat in plugin-capabilities arch guard (11 -> 12) - add googlechat row (typing:false, threads:false, buttons:none) to EXPECTED - bump length sanity assert 11 -> 12 and the walk title - reads googlechat-plugin.ts source; confirms the three flags are own-properties --- .../src/__tests__/plugin-capabilities-explicit.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts b/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts index 08cb0266a..eb4560c72 100644 --- a/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts +++ b/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts @@ -4,7 +4,7 @@ * `typing` / `threads` / `buttons` capability flags EXPLICITLY. * * The `ChannelFeaturesSchema` defaults these three fields (`false`/`false`/ - * `"none"`) as a safety net for *future* plugins. For the 11 in-tree + * `"none"`) as a safety net for *future* plugins. For the 12 in-tree * plugins, that default is a trap: `selectStrategy(caps)` routes each * channel from these flags, so a plugin that silently inherits a default * (e.g. Slack defaulting `threads:false`, or Telegram defaulting @@ -65,6 +65,7 @@ const EXPECTED: readonly ExpectedCaps[] = [ { dir: "irc", typing: false, threads: false, buttons: "none" }, { dir: "email", typing: false, threads: false, buttons: "none" }, { dir: "msteams", typing: true, threads: true, buttons: "adaptivecard" }, + { dir: "googlechat", typing: false, threads: false, buttons: "none" }, ]; /** The three fields that MUST be declared (not defaulted) per plugin. */ @@ -134,9 +135,9 @@ function readDeclaredFeatures(dir: string): Record { - it("walks all 11 plugins and finds typing/threads/buttons as own-properties (not schema defaults)", () => { - // Sanity: the matrix walks exactly the 11 in-tree plugins. - expect(EXPECTED).toHaveLength(11); + it("walks all 12 plugins and finds typing/threads/buttons as own-properties (not schema defaults)", () => { + // Sanity: the matrix walks exactly the 12 in-tree plugins. + expect(EXPECTED).toHaveLength(12); const missing: string[] = []; for (const { dir } of EXPECTED) { From c7663655086215ef7a66060f994f6b2080e7d00c Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 16:08:15 +0300 Subject: [PATCH 022/200] feat(233-08): export the googlechat surface from the channels barrel - adapter (createGoogleChatAdapter + Deps/Handle types) + plugin (createGoogleChatPlugin) - utilities: mapGoogleChatEventToNormalized/GoogleChatEvent, token provider + CHAT_SCOPE/PUBSUB_SCOPE + auth types - validateGoogleChatCredentials + classifyGoogleChatError - surfaces the plugin + validator the daemon wiring consumes --- packages/channels/src/index.ts | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/channels/src/index.ts b/packages/channels/src/index.ts index e873127a6..98fc30888 100644 --- a/packages/channels/src/index.ts +++ b/packages/channels/src/index.ts @@ -176,6 +176,30 @@ export type { export { validateMsTeamsCredentials } from "./msteams/credential-validator.js"; export { classifyMsTeamsError } from "./msteams/errors.js"; +// Google Chat adapter (Pub/Sub pull, text round-trip) +export { createGoogleChatAdapter } from "./googlechat/googlechat-adapter.js"; +export type { + GoogleChatAdapterDeps, + GoogleChatAdapterHandle, +} from "./googlechat/googlechat-adapter.js"; +export { createGoogleChatPlugin } from "./googlechat/googlechat-plugin.js"; + +// Google Chat utilities +export { mapGoogleChatEventToNormalized } from "./googlechat/message-mapper.js"; +export type { GoogleChatEvent } from "./googlechat/message-mapper.js"; +export { + createGoogleChatTokenProvider, + CHAT_SCOPE, + PUBSUB_SCOPE, +} from "./googlechat/googlechat-auth.js"; +export type { + GoogleChatTokenDeps, + GoogleChatTokenProvider, + GoogleChatScope, +} from "./googlechat/googlechat-auth.js"; +export { validateGoogleChatCredentials } from "./googlechat/credential-validator.js"; +export { classifyGoogleChatError } from "./googlechat/errors.js"; + // Echo adapter (testing) export { EchoChannelAdapter } from "./echo/echo-adapter.js"; export type { EchoAdapterOptions } from "./echo/echo-adapter.js"; From 57ebe927d4c60e8614703fa251f640047d2ca3b2 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 16:20:48 +0300 Subject: [PATCH 023/200] test(233-09): add failing wiring test for googlechat registration + SecretRef/GOOGLECHAT_SA_KEY resolution - happy path: config serviceAccountKey + subscriptionName registers the adapter/plugin - env fallback: GOOGLECHAT_SA_KEY resolves the key when config omits serviceAccountKey - missing subscription: no registration, secret-free WARN (errorKind + hint, no key) --- .../wiring/setup-channels-adapters.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) diff --git a/packages/daemon/src/wiring/setup-channels-adapters.test.ts b/packages/daemon/src/wiring/setup-channels-adapters.test.ts index f4c863810..50b9dc7e3 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.test.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.test.ts @@ -40,6 +40,12 @@ const mockMsTeamsPlugin = { // caller-backed wiring can be asserted by identity. const mockMsTeamsIngress = { __msteamsIngress: true }; +// Google Chat is a pull-driven channel: the adapter opens the Pub/Sub pull loop +// on start(), so there is no gateway ingress to wire (unlike Teams). The plugin +// carries only the base send surface + lifecycle. +const mockGoogleChatAdapter = { sendMessage: vi.fn(), start: vi.fn(), stop: vi.fn() }; +const mockGoogleChatPlugin = { adapter: mockGoogleChatAdapter, channelType: "googlechat" }; + vi.mock("@comis/channels", () => ({ createTelegramPlugin: vi.fn(() => mockTelegramPlugin), createDiscordPlugin: vi.fn(() => mockDiscordPlugin), @@ -64,6 +70,9 @@ vi.mock("@comis/channels", () => ({ validateMsTeamsCredentials: vi.fn(() => ({ ok: true, value: undefined })), // The bound inbound activity-token validator (authHeader, appId) => Result. validateActivityJwt: vi.fn(async () => ({ ok: true, value: undefined })), + createGoogleChatPlugin: vi.fn(() => mockGoogleChatPlugin), + // Synchronous, transport-free credential guard — returns a Result directly. + validateGoogleChatCredentials: vi.fn(() => ({ ok: true, value: undefined })), })); // The Teams ingress sub-app is built in @comis/gateway; the registration block @@ -95,6 +104,8 @@ import { validateEmailCredentials, createMsTeamsPlugin, validateMsTeamsCredentials, + createGoogleChatPlugin, + validateGoogleChatCredentials, } from "@comis/channels"; import { createMsTeamsIngress } from "@comis/gateway"; @@ -114,6 +125,7 @@ function makeChannelConfig(overrides: Record = {}) { irc: { enabled: false, host: undefined, port: 6667, nick: undefined, tls: false, channels: [], nickservPassword: undefined, ...overrides.irc }, email: { enabled: false, address: undefined, imapHost: undefined, imapPort: 993, smtpHost: undefined, smtpPort: 587, secure: true, authType: "password", allowFrom: [], allowMode: "allowlist", pollingIntervalMs: 60_000, ...overrides.email }, msteams: { enabled: false, authMode: "secret", appId: undefined, appPassword: undefined, tenantId: undefined, certPath: undefined, managedIdentityClientId: undefined, cloud: "public", allowFrom: [], allowMode: "allowlist", ...overrides.msteams }, + googlechat: { enabled: false, mode: "pubsub", serviceAccountKey: undefined, subscriptionName: undefined, audienceType: "project-number", audience: undefined, allowFrom: [], allowMode: "allowlist", missedInboundThresholdMs: 21_600_000, ...overrides.googlechat }, }; } @@ -720,4 +732,93 @@ describe("bootstrapAdapters", () => { expect.stringContaining("Teams credential validation failed"), ); }); + + // ------------------------------------------------------------------------- + // Google Chat — a pull-driven channel. The registration block resolves the + // service-account key as config-SecretRef-or-GOOGLECHAT_SA_KEY, validates it + // with the subscription, and registers the adapter/plugin. There is no + // gateway ingress this transport (the adapter opens the Pub/Sub pull loop). + // The credential-fail WARN carries only errorKind + hint — never the key. + // ------------------------------------------------------------------------- + + it("creates the googlechat adapter on happy path with the config service-account key", async () => { + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const container = makeContainer({ + googlechat: { enabled: true, serviceAccountKey: saKey, subscriptionName: "projects/p/subscriptions/s" }, + }); + const result = await bootstrapAdapters({ container, channelsLogger }); + + expect(validateGoogleChatCredentials).toHaveBeenCalledWith( + expect.objectContaining({ serviceAccountKey: saKey, subscriptionName: "projects/p/subscriptions/s" }), + ); + expect(createGoogleChatPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + serviceAccountKey: saKey, + subscriptionName: "projects/p/subscriptions/s", + allowMode: "allowlist", + logger: channelsLogger, + }), + ); + expect(result.adaptersByType.get("googlechat")).toBe(mockGoogleChatAdapter); + expect(result.channelPlugins.get("googlechat")).toBe(mockGoogleChatPlugin); + expect(channelsLogger.info).toHaveBeenCalledWith( + expect.objectContaining({ channelType: "googlechat" }), + "Channel adapter initialized", + ); + }); + + it("resolves the key from GOOGLECHAT_SA_KEY when config serviceAccountKey is absent", async () => { + const container = makeContainer( + { googlechat: { enabled: true, subscriptionName: "projects/p/subscriptions/s" } }, + { GOOGLECHAT_SA_KEY: "ENV_SA_KEY" }, + ); + const result = await bootstrapAdapters({ container, channelsLogger }); + + expect(container.secretManager.get).toHaveBeenCalledWith("GOOGLECHAT_SA_KEY"); + expect(createGoogleChatPlugin).toHaveBeenCalledWith( + expect.objectContaining({ serviceAccountKey: "ENV_SA_KEY" }), + ); + expect(result.adaptersByType.get("googlechat")).toBe(mockGoogleChatAdapter); + }); + + it("skips registration and WARNs (secret-free) when subscriptionName is missing", async () => { + // A blank subscription fails validation; the block must not register the + // adapter and the WARN must name the config knobs without leaking the key. + vi.mocked(validateGoogleChatCredentials).mockReturnValueOnce({ + ok: false, + error: new Error("subscriptionName must not be empty (pubsub mode)"), + } as any); + const container = makeContainer( + { googlechat: { enabled: true } }, // no subscriptionName + { GOOGLECHAT_SA_KEY: "ENV_SA_KEY" }, + ); + const result = await bootstrapAdapters({ container, channelsLogger }); + + expect(result.adaptersByType.has("googlechat")).toBe(false); + expect(createGoogleChatPlugin).not.toHaveBeenCalled(); + expect(channelsLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + errorKind: "auth", + hint: expect.stringContaining("GOOGLECHAT_SA_KEY"), + }), + expect.stringContaining("Google Chat credential validation failed"), + ); + + // Secret-free guarantee: the resolved key never appears in the WARN payload. + const warnCall = vi.mocked(channelsLogger.warn).mock.calls.find( + (c) => typeof c[1] === "string" && c[1].includes("Google Chat credential validation failed"), + ); + const warnPayload = (warnCall?.[0] ?? {}) as Record; + expect(warnPayload).not.toHaveProperty("serviceAccountKey"); + expect(warnPayload).not.toHaveProperty("key"); + expect(JSON.stringify(warnPayload)).not.toContain("ENV_SA_KEY"); + }); + + it("does not register the googlechat adapter when the channel is disabled", async () => { + const container = makeContainer({ googlechat: { enabled: false } }); + const result = await bootstrapAdapters({ container, channelsLogger }); + + expect(result.adaptersByType.has("googlechat")).toBe(false); + expect(createGoogleChatPlugin).not.toHaveBeenCalled(); + }); }); From 8e67ef00bec8db85c46eb58dffc43906a1da3999 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 16:23:22 +0300 Subject: [PATCH 024/200] feat(233-09): wire googlechat plugin into the daemon composition root - bootstrapAdapters registers the googlechat adapter/plugin on enabled+valid config - SA key resolves as config SecretRef (resolved upstream) or GOOGLECHAT_SA_KEY env fallback - missing key/subscription: no registration, secret-free WARN (errorKind + hint) - no gateway ingress (pull loop, not webhook); module doc note 10 -> 11 platforms - baseline the 12 intra-package-only googlechat exports in public-api-policy (createGoogleChatPlugin + validateGoogleChatCredentials now have the daemon consumer) --- .../src/wiring/setup-channels-adapters.ts | 38 ++++++++++++++++++- test/support/public-api-policy.ts | 26 +++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/wiring/setup-channels-adapters.ts b/packages/daemon/src/wiring/setup-channels-adapters.ts index 9dfd71bd5..1741cbc55 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.ts @@ -1,8 +1,8 @@ // SPDX-License-Identifier: Apache-2.0 /** * Per-platform channel adapter bootstrap: credential validation and plugin - * creation for 10 platforms (Telegram, Discord, Slack, WhatsApp, Signal, LINE, - * iMessage, IRC, Email, Microsoft Teams). + * creation for 11 platforms (Telegram, Discord, Slack, WhatsApp, Signal, LINE, + * iMessage, IRC, Email, Microsoft Teams, Google Chat). * Extracted from setup-channels.ts to isolate the per-platform bootstrap block * into a single-concern module. * @module @@ -21,6 +21,7 @@ import { createIrcPlugin, createEmailPlugin, createMsTeamsPlugin, + createGoogleChatPlugin, validateBotToken, validateDiscordToken, validateSlackCredentials, @@ -31,6 +32,7 @@ import { validateIrcConnection, validateEmailCredentials, validateMsTeamsCredentials, + validateGoogleChatCredentials, type TelegramPluginHandle, type LinePluginHandle, type EmailAdapterDeps, @@ -498,6 +500,38 @@ export async function bootstrapAdapters(deps: { } } + // Google Chat — a pull-driven channel: inbound arrives over a Cloud Pub/Sub + // pull loop the adapter opens on start(), so there is no gateway ingress to + // build here (unlike Teams — that's the webhook transport). On a valid config + // it registers the adapter/plugin and stops. The service-account key resolves + // as the config SecretRef (already resolved to a string upstream) OR the + // service-account-key env fallback, and is never placed in a log. + if (channelConfig.googlechat.enabled) { + const key = (channelConfig.googlechat.serviceAccountKey as string | undefined) || getSecret("GOOGLECHAT_SA_KEY"); + const subscriptionName = channelConfig.googlechat.subscriptionName; + const validation = validateGoogleChatCredentials({ + serviceAccountKey: key, + subscriptionName, + allowFrom: channelConfig.googlechat.allowFrom, + logger: channelsLogger, + }); + if (validation.ok && key && subscriptionName) { + const plugin = createGoogleChatPlugin({ + serviceAccountKey: key, + subscriptionName, + allowFrom: channelConfig.googlechat.allowFrom, + allowMode: channelConfig.googlechat.allowMode, + logger: channelsLogger, + mode: channelConfig.googlechat.mode, + }); + adaptersByType.set("googlechat", plugin.adapter); + channelPlugins.set("googlechat", plugin); + channelsLogger.info({ channelType: "googlechat", mode: channelConfig.googlechat.mode }, "Channel adapter initialized"); + } else { + channelsLogger.warn({ hint: "Set channels.googlechat.serviceAccountKey (SecretRef) or GOOGLECHAT_SA_KEY, and channels.googlechat.subscriptionName", errorKind: "auth" as const }, "Google Chat credential validation failed"); + } + } + if (adaptersByType.size > 0) { channelsLogger.info({ channels: Array.from(adaptersByType.keys()), count: adaptersByType.size }, "Channel adapters initialized"); } else { diff --git a/test/support/public-api-policy.ts b/test/support/public-api-policy.ts index d4e2c4352..89d782113 100644 --- a/test/support/public-api-policy.ts +++ b/test/support/public-api-policy.ts @@ -610,6 +610,32 @@ export const PUBLIC_API_POLICY: ReadonlyMap> = "ConnectorTokenDeps", "ConnectorTokenProvider", "classifyMsTeamsError", + // Google Chat adapter building blocks — exported for API parity with the + // other channel adapters, consumed WITHIN the package (the plugin wraps the + // adapter; the adapter drives the mapper, the service-account token + // provider and the error classifier) with no cross-package importer. The + // daemon composition root (setup-channels-adapters.ts) consumes the plugin + // factory (createGoogleChatPlugin) + the credential validator + // (validateGoogleChatCredentials) instead — so those two are NOT listed + // here (the public-export-consumers walker finds their real consumer). The + // adapter deps/handle types surface the same class as MsTeamsAdapterDeps: + // the plugin factory takes GoogleChatAdapterDeps by inference at the call + // site, so no cross-package importer names them. The two token scopes + // (CHAT_SCOPE/PUBSUB_SCOPE) + the token provider deps/handle/scope types are + // consumed by the adapter internally. Shrink each entry as a real + // cross-package consumer lands. + "createGoogleChatAdapter", + "GoogleChatAdapterDeps", + "GoogleChatAdapterHandle", + "mapGoogleChatEventToNormalized", + "GoogleChatEvent", + "createGoogleChatTokenProvider", + "CHAT_SCOPE", + "PUBSUB_SCOPE", + "GoogleChatTokenDeps", + "GoogleChatTokenProvider", + "GoogleChatScope", + "classifyGoogleChatError", "createIMessageAdapter", "IMessageAdapterDeps", "mapImsgToNormalized", From a492679a5a5a8ffd16bfbf3bf29296d316eea47b Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 16:24:39 +0300 Subject: [PATCH 025/200] docs(233-09): document channels.googlechat config block + GOOGLECHAT_SA_KEY - config-yaml.mdx: Google Chat (channels.googlechat) key table + shared YAML example entry - environment-variables.mdx: GOOGLECHAT_SA_KEY as the service-account-key fallback --- docs/reference/config-yaml.mdx | 22 ++++++++++++++++++++++ docs/reference/environment-variables.mdx | 6 ++++++ 2 files changed, 28 insertions(+) diff --git a/docs/reference/config-yaml.mdx b/docs/reference/config-yaml.mdx index c955c4edd..c8b0257d5 100644 --- a/docs/reference/config-yaml.mdx +++ b/docs/reference/config-yaml.mdx @@ -1140,6 +1140,23 @@ Teams authenticates with an app registration rather than a single bot token. Inb | `mediaProcessing` | `MediaProcessing` | _(all true)_ | Per-channel media processing overrides | | `ackReaction` | `AckReaction` | _(disabled)_ | Ack reaction sent when the agent starts processing | +**Google Chat (channels.googlechat)** + +Google Chat authenticates with a service-account key (no bot token). The default `pubsub` transport pulls inbound events from a Pub/Sub subscription — no public IP or gateway required (`mode: webhook` support arrives in a later release). + +| Key | Type | Default | Description | +|-----|------|---------|-------------| +| `enabled` | `boolean` | `false` | Whether this channel is active (opt-in) | +| `mode` | `enum` | `"pubsub"` | Inbound transport: `pubsub` (default, no public IP) or `webhook` (not yet available) | +| `serviceAccountKey` | `string \| SecretRef` | _(unset)_ | Service-account key JSON for the Chat app. A secret — supply a `SecretRef` or set the `GOOGLECHAT_SA_KEY` environment variable | +| `subscriptionName` | `string` | _(unset)_ | Pub/Sub pull subscription `projects/{project}/subscriptions/{sub}`; required in `pubsub` mode | +| `audienceType` | `enum` | `"project-number"` | Webhook-mode Bearer-JWT audience type (webhook mode not yet available) | +| `audience` | `string` | _(unset)_ | Webhook-mode audience value (webhook mode not yet available) | +| `allowFrom` | `string[]` | `[]` | Allowed sender IDs — each a `users/{id}` or `spaces/{id}`; prefer the immutable `users/{id}` over a mutable email display id | +| `allowMode` | `enum` | `"allowlist"` | Sender gate: `allowlist` (default, blocks all unless listed) or `open` | +| `missedInboundThresholdMs` | `number` | `21600000` (6h) | Webhook-mode liveness window before a missed-inbound alert (webhook mode not yet available). Must be a positive integer | +| `mediaProcessing` | `MediaProcessing` | _(all true)_ | Per-channel media processing overrides | + ```yaml channels: telegram: @@ -1163,6 +1180,11 @@ channels: appPassword: "${MSTEAMS_APP_PASSWORD}" tenantId: "00000000-0000-0000-0000-000000000000" allowFrom: ["29:1abcAADObjectId"] + googlechat: + enabled: true + serviceAccountKey: "${GOOGLECHAT_SA_KEY}" + subscriptionName: "projects/my-project/subscriptions/comis-chat" + allowFrom: ["users/1234567890"] ``` See the [Channels](/channels/index) section for platform-specific setup guides. diff --git a/docs/reference/environment-variables.mdx b/docs/reference/environment-variables.mdx index 96b51e29a..87676be7f 100644 --- a/docs/reference/environment-variables.mdx +++ b/docs/reference/environment-variables.mdx @@ -677,6 +677,12 @@ Required only for channels you enable in `config.yaml`. Each value can also be s The bot app id (`channels.msteams.appId`) and directory/tenant id (`channels.msteams.tenantId`) are plain configuration values, not secrets, and are set in `config.yaml` rather than as environment variables. +### Google Chat + +| Variable | Purpose | +|----------|---------| +| `GOOGLECHAT_SA_KEY` | Service-account key JSON for the Chat app — the fallback source for `channels.googlechat.serviceAccountKey` (also settable inline in config or as a SecretRef). A secret; never commit it in plaintext config. | + ### IRC | Variable | Purpose | From d7a1558c2b75d7140d5f88e5ec5c49bd59bcc4be Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 16:56:42 +0300 Subject: [PATCH 026/200] fix(233): BL-01 guard the mapper's untrusted-input boundary against null/non-object decodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A decoded Pub/Sub payload of the literal JSON null (base64 of "null" JSON.parses to null, typeof null === "object", null.type throws) reached the mapper un-guarded and crashed with a TypeError. The pull loop's onEvent try/catch — meant only for enqueue backpressure — swallowed it as a rejected enqueue and skip-acked, so Pub/Sub redelivered the poison message forever, wedging a maxMessages slot every cycle. Guard the contract boundary: null/non-object events return null (ACK-dropped) before any deref. Tests pin the map-then-drop contract at all three layers — mapper, adapter handleChatEvent, and the pull loop's ack-not-skip disposition. --- .../src/googlechat/googlechat-adapter.test.ts | 13 +++++++++ .../src/googlechat/message-mapper.test.ts | 26 +++++++++++++++++ .../channels/src/googlechat/message-mapper.ts | 5 ++++ .../src/googlechat/pubsub-source.test.ts | 28 +++++++++++++++++++ 4 files changed, 72 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 84bb601a5..9071082b2 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -288,6 +288,19 @@ describe("createGoogleChatAdapter — inbound gate + dispatch", () => { ); expect(sibling).toHaveBeenCalledTimes(1); }); + + it("resolves (ack, not skip-ack) for a decoded literal JSON null — never throws a TypeError into the redelivery path", async () => { + const { deps } = await makeDeps({ allowMode: "open" }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + // A payload of the literal JSON null reaches the mapper un-guarded (it + // JSON.parses fine, so the pull loop's decode catch is bypassed). It must + // resolve so the pull loop ACKs it, not reject into infinite redelivery. + await expect(adapter.handleChatEvent(null)).resolves.toBeUndefined(); + expect(handler).not.toHaveBeenCalled(); + }); }); describe("createGoogleChatAdapter — status + lastInboundAt semantics", () => { diff --git a/packages/channels/src/googlechat/message-mapper.test.ts b/packages/channels/src/googlechat/message-mapper.test.ts index fb33976be..8644bf3d0 100644 --- a/packages/channels/src/googlechat/message-mapper.test.ts +++ b/packages/channels/src/googlechat/message-mapper.test.ts @@ -221,3 +221,29 @@ describe("mapGoogleChatEventToNormalized", () => { expect(parsed.ok).toBe(true); }); }); + +describe("mapGoogleChatEventToNormalized — untrusted-input boundary", () => { + // The module contract is "normalizes untrusted JSON": a decoded Pub/Sub + // payload can be the literal JSON `null` (base64 of "null" parses to null, + // typeof null === "object", and null.type throws) or any non-object JSON + // scalar/array. None of these may crash the mapper — each must return null so + // the pull loop ACK-drops it rather than misrouting a TypeError into the + // enqueue-backpressure path and redelivering forever. + it("returns null (never throws) for a decoded literal JSON null", () => { + expect(() => + mapGoogleChatEventToNormalized(null as unknown as GoogleChatEvent), + ).not.toThrow(); + expect(mapGoogleChatEventToNormalized(null as unknown as GoogleChatEvent)).toBeNull(); + }); + + it("returns null (never throws) for non-object scalar/array decodes", () => { + for (const payload of [42, "a string", true, []]) { + expect(() => + mapGoogleChatEventToNormalized(payload as unknown as GoogleChatEvent), + ).not.toThrow(); + expect( + mapGoogleChatEventToNormalized(payload as unknown as GoogleChatEvent), + ).toBeNull(); + } + }); +}); diff --git a/packages/channels/src/googlechat/message-mapper.ts b/packages/channels/src/googlechat/message-mapper.ts index 44bce92a3..919f9afda 100644 --- a/packages/channels/src/googlechat/message-mapper.ts +++ b/packages/channels/src/googlechat/message-mapper.ts @@ -77,6 +77,11 @@ const UNKNOWN_SENDER = "unknown"; export function mapGoogleChatEventToNormalized( event: GoogleChatEvent, ): NormalizedMessage | null { + // Untrusted-input boundary: a decoded payload can be the literal JSON `null` + // (typeof null === "object", and null.type throws) or a non-object scalar. + // Guard before any dereference so a hostile/malformed payload returns null and + // is ACK-dropped, never crashing into the enqueue-backpressure redelivery path. + if (event === null || typeof event !== "object") return null; if (event.type !== "MESSAGE" || !event.message) return null; const message = event.message; diff --git a/packages/channels/src/googlechat/pubsub-source.test.ts b/packages/channels/src/googlechat/pubsub-source.test.ts index 599839d06..8845eefa9 100644 --- a/packages/channels/src/googlechat/pubsub-source.test.ts +++ b/packages/channels/src/googlechat/pubsub-source.test.ts @@ -6,6 +6,7 @@ import { createPubSubSource, type PubSubSourceDeps, } from "./pubsub-source.js"; +import { mapGoogleChatEventToNormalized } from "./message-mapper.js"; const SUB = "projects/my-project/subscriptions/comis-sub"; const BASE = "https://pubsub.googleapis.com/v1"; @@ -277,6 +278,33 @@ describe("createPubSubSource — pull + ack-on-enqueue + dedup (pollOnce)", () = expect(fetch.allAckedIds()).toContain("ack-2"); }); + it("acks-and-drops a message whose data decodes to JSON null (mapper rejects it) — never skip-acks it into infinite redelivery", async () => { + // base64("null") JSON.parses to the literal null, so the decode catch is + // bypassed. Wired to the real map-then-drop dispatch contract (the adapter's + // handleChatEvent), a payload the mapper rejects must resolve → be ACKed, + // NOT rejected into the enqueue-backpressure skip-ack (redeliver) path. + const nullData = Buffer.from("null", "utf8").toString("base64"); + const fetch = makeFetch([ + { receivedMessages: [{ ackId: "ack-null", message: { data: nullData } }] }, + ]); + // Mirror handleChatEvent's contract: map the untrusted event; a null map is + // a benign drop (resolve → ack), only a real enqueue failure rejects. + const onEvent = vi.fn(async (event: unknown) => { + const normalized = mapGoogleChatEventToNormalized( + event as Parameters[0], + ); + if (!normalized) return; + }); + const { deps } = makeDeps({ fetchImpl: fetch.fetchImpl, onEvent }); + const source = createPubSubSource(deps); + + const out = await source.pollOnce(); + + expect(out.skippedCount).toBe(0); + expect(out.ackedCount).toBe(1); + expect(fetch.allAckedIds()).toContain("ack-null"); + }); + it("acks and skips an unparseable data payload without dispatching or throwing", async () => { const bad = Buffer.from("not json {{{", "utf8").toString("base64"); const fetch = makeFetch([ From be80cdc58e898d07780c139b1c08595be3aff62e Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 16:57:46 +0300 Subject: [PATCH 027/200] fix(233): MD-01 populate the advertised replyToMetaKey in the mapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin advertised replyToMetaKey "googlechatMessageName" but the mapper never wrote it, so inbound-message-id-resolver read undefined and the native id was never recorded — the bot's replies silently never quoted the triggering message. Two layers disagreed while message.name was already in hand. Write metadata.googlechatMessageName = message.name, mirroring how msteams and discord populate the key they advertise. Tests reconcile all three layers: mapper output, advertised key, and resolver consumption. --- .../src/googlechat/message-mapper.test.ts | 24 +++++++++++++++++++ .../channels/src/googlechat/message-mapper.ts | 4 ++++ .../inbound-message-id-resolver.test.ts | 24 +++++++++++++++++++ 3 files changed, 52 insertions(+) diff --git a/packages/channels/src/googlechat/message-mapper.test.ts b/packages/channels/src/googlechat/message-mapper.test.ts index 8644bf3d0..63d75d298 100644 --- a/packages/channels/src/googlechat/message-mapper.test.ts +++ b/packages/channels/src/googlechat/message-mapper.test.ts @@ -175,6 +175,30 @@ describe("mapGoogleChatEventToNormalized", () => { expect(result?.metadata.wasMentioned).toBe(false); }); + it("populates the advertised replyToMetaKey (metadata.googlechatMessageName) from message.name", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { + name: "spaces/AAAA/messages/MMMM", + sender: { name: "users/1" }, + text: "hi", + }, + }), + ); + // The plugin advertises replyToMetaKey "googlechatMessageName"; the mapper + // must write it so the inbound-message-id resolver can record the native id. + expect(result?.metadata.googlechatMessageName).toBe("spaces/AAAA/messages/MMMM"); + }); + + it("omits googlechatMessageName when the MESSAGE event carries no message.name", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { sender: { name: "users/1" }, text: "hi" }, + }), + ); + expect(result?.metadata.googlechatMessageName).toBeUndefined(); + }); + it("captures the thread resource name under a googlechat metadata key", () => { const result = mapGoogleChatEventToNormalized( makeChatEvent({ diff --git a/packages/channels/src/googlechat/message-mapper.ts b/packages/channels/src/googlechat/message-mapper.ts index 919f9afda..b44650823 100644 --- a/packages/channels/src/googlechat/message-mapper.ts +++ b/packages/channels/src/googlechat/message-mapper.ts @@ -92,6 +92,10 @@ export function mapGoogleChatEventToNormalized( const wasMentioned = (message.annotations ?? []).some((a) => a.type === "USER_MENTION"); const metadata: Record = { isGroup: !isDm, wasMentioned }; + // The message resource name is the platform reply target the plugin advertises + // as replyToMetaKey "googlechatMessageName" — write it so the inbound-message-id + // resolver records the native id and the reply-to path can quote this message. + if (message.name) metadata.googlechatMessageName = message.name; // Capture the thread resource name for routing; replying into the thread is // handled elsewhere on the send path. if (message.thread?.name) metadata.googlechatThreadId = message.thread.name; diff --git a/packages/daemon/src/wiring/inbound-message-id-resolver.test.ts b/packages/daemon/src/wiring/inbound-message-id-resolver.test.ts index fabdb10b8..1e9a951ac 100644 --- a/packages/daemon/src/wiring/inbound-message-id-resolver.test.ts +++ b/packages/daemon/src/wiring/inbound-message-id-resolver.test.ts @@ -46,6 +46,30 @@ describe("createInboundMessageIdResolver", () => { expect(resolver.resolve("u-2")?.nativeId).toBe("1714500000.000100"); }); + it("records a Google Chat inbound under its advertised replyToMetaKey (googlechatMessageName)", () => { + // Reconciles the layers: the mapper writes metadata.googlechatMessageName = + // message.name, the plugin advertises replyToMetaKey "googlechatMessageName", + // and the resolver keys on that same metaKey — so the native id is recorded + // rather than silently dropped. + const resolver = createInboundMessageIdResolver({ + metaKeyByChannel: new Map([["googlechat", "googlechatMessageName"]]), + }); + resolver.record( + makeMsg({ + id: "gc-1", + channelType: "googlechat", + channelId: "spaces/AAAA", + metadata: { googlechatMessageName: "spaces/AAAA/messages/MMMM" }, + }), + "googlechat", + ); + expect(resolver.resolve("gc-1")).toEqual({ + channelType: "googlechat", + channelId: "spaces/AAAA", + nativeId: "spaces/AAAA/messages/MMMM", + }); + }); + it("returns undefined for unknown UUIDs", () => { const resolver = createInboundMessageIdResolver({ metaKeyByChannel: new Map([["telegram", "telegramMessageId"]]), From 295a0abecc378d5d8bb5e6cbf45b1b041606dd30 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 16:58:15 +0300 Subject: [PATCH 028/200] fix(233): MD-02 annotate the sanctioned boundary throw in handleChatEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rethrow on handler failure is correct behavior — a rejected onEvent promise is the pull loop's skip-ack (redeliver) signal. But packages/channels/ is not a raw-throw exempt zone, and this throw only slipped past the guard via its ternary shape (throw failed.reason ? ...), which the line-anchored regex does not match. Relying on that shape to evade the guard is convenient-layer evasion. Add the // @allow-throw: annotation documenting the contract, so the sanctioned boundary throw is explicit rather than accidentally-unmatched. --- packages/channels/src/googlechat/googlechat-adapter.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 833f39ceb..040d59c20 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -195,7 +195,10 @@ export function createGoogleChatAdapter( }, "Inbound message handler error", ); - // Rethrow so the pull loop skips the ack and the message redelivers. + // @allow-throw: handleChatEvent is the pull loop's onEvent boundary — a + // rejected promise IS the skip-ack (redeliver) signal, which the loop + // catches and translates. Rethrow so the failed enqueue redelivers + // rather than being acked-and-dropped. throw failed.reason instanceof Error ? failed.reason : new Error(String(failed.reason)); From 6b578935893fa5ab88c50333fec1e40b5aa42591 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 16:59:06 +0300 Subject: [PATCH 029/200] fix(233): MD-03 make the webhook-mode fallback a clear config WARN, not a silent downgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mode:"webhook" was accepted and silently ran the Pub/Sub pull loop with only a vague 'not available yet' WARN (errorKind precondition). The webhook transport is not wired, so this was both non-functional and misleading. Emit a config-classed WARN that names the knob (channels.googlechat.mode) and states plainly that webhook ingress is inactive and the Pub/Sub pull loop is in use. The schema keeps mode/audienceType/audience — intentionally schema-complete for the later webhook wiring — but the runtime no longer downgrades silently. --- .../src/googlechat/googlechat-adapter.test.ts | 16 ++++++++++++++++ .../src/googlechat/googlechat-adapter.ts | 8 +++++--- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 9071082b2..6c1ac1d26 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -367,6 +367,22 @@ describe("createGoogleChatAdapter — lifecycle", () => { expect(typeof holder.sourceDeps?.getPubSubToken).toBe("function"); }); + it("WARNs (errorKind 'config', knob-naming hint) but still boots the pull loop when mode is 'webhook'", async () => { + // The webhook transport is not wired; mode:"webhook" must not silently pass. + // Emit a clear config WARN naming the knob, then run the Pub/Sub pull loop. + const { deps, loggerSpy, fake } = await makeDeps({ mode: "webhook" }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.start(); + + expect(result.ok).toBe(true); + expect(fake.start).toHaveBeenCalledTimes(1); + const warn = findByErrorKind(loggerSpy.warn, "config"); + expect(warn).toBeDefined(); + expect(String(warn?.hint)).toContain("channels.googlechat.mode"); + expect(String(warn?.hint).toLowerCase()).toContain("pubsub"); + }); + it("stop() stops the source and marks disconnected", async () => { const { deps, fake } = await makeDeps(); const adapter = createGoogleChatAdapter(deps); diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 040d59c20..420404ee8 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -243,13 +243,15 @@ export function createGoogleChatAdapter( } if (deps.mode && deps.mode !== "pubsub") { + // The webhook transport is not wired: name the knob, state what is + // actually running, and do not silently pretend webhook ingress is live. deps.logger.warn( { channelType: "googlechat" as const, - hint: "Webhook-mode ingress is not available yet; only pubsub pull is supported", - errorKind: "precondition" as const, + hint: "Webhook ingress is not active; set channels.googlechat.mode to 'pubsub' — the Pub/Sub pull loop is being used instead", + errorKind: "config" as const, }, - "Requested mode not available; using pubsub pull", + "Webhook ingress unavailable; running the Pub/Sub pull loop", ); } From a84c62d6dee9d6123bb89520487b889c0dd93686 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 17:00:41 +0300 Subject: [PATCH 030/200] fix(233): LW-01 stop leaking an abort listener per backoff cycle abortableSleep registered an { once: true } abort listener on the shared controller.signal each cycle; { once: true } only self-removes if abort fires, so on the normal timer path the listener was never removed. A persistently failing loop (30s cap) accumulated hundreds of listeners toward Node's MaxListenersExceededWarning. Remove the listener when the timer completes normally. A getEventListeners assertion pins that only the currently-parked sleep's single listener remains after repeated failure cycles. --- .../src/googlechat/pubsub-source.test.ts | 39 +++++++++++++++++++ .../channels/src/googlechat/pubsub-source.ts | 22 ++++++----- 2 files changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/channels/src/googlechat/pubsub-source.test.ts b/packages/channels/src/googlechat/pubsub-source.test.ts index 8845eefa9..ed10e0780 100644 --- a/packages/channels/src/googlechat/pubsub-source.test.ts +++ b/packages/channels/src/googlechat/pubsub-source.test.ts @@ -1,5 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from "vitest"; +import { getEventListeners } from "node:events"; import type { ComisLogger } from "@comis/core"; import { ok, err } from "@comis/shared"; import { @@ -429,6 +430,16 @@ function failingFetch() { })) as unknown as typeof fetch; } +/** A failing fetch that captures the abort signal passed on each pull init. */ +function capturingFailingFetch() { + let signal: AbortSignal | undefined; + const impl = vi.fn(async (_url: unknown, init?: RequestInit) => { + signal = init?.signal ?? undefined; + return { ok: false, status: 503, json: async () => ({}) } as unknown as Response; + }); + return { impl: impl as unknown as typeof fetch, getSignal: () => signal }; +} + describe("createPubSubSource — bounded jittered backoff + AbortController stop + loud failure", () => { it("backs off within [floor, floor+500] after the first pull failure (jitter bounded)", async () => { const timers = makeFakeTimers(); @@ -590,6 +601,34 @@ describe("createPubSubSource — bounded jittered backoff + AbortController stop expect(typeof source.lastError).toBe("string"); }); + it("does not accumulate an abort listener per backoff cycle on the shared signal", async () => { + const timers = makeFakeTimers(); + const fetchCap = capturingFailingFetch(); + const { deps } = makeDeps({ + fetchImpl: fetchCap.impl, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + rng: () => 0.5, + }); + const source = createPubSubSource(deps); + + source.start(); + await flushMicrotasks(); // fail #1 → parked in a backoff sleep + for (let i = 0; i < 5; i += 1) await timers.fireNext(); // 5 more fail→park cycles + + const signal = fetchCap.getSignal(); + expect(signal).toBeInstanceOf(AbortSignal); + // A normal timer completion must remove its abort listener, so only the + // currently-parked sleep's single listener remains on the shared signal. + // Pre-fix, one leaked per cycle (6 here) and accumulates toward Node's + // MaxListenersExceededWarning over a long-lived failing loop. + expect( + getEventListeners(signal as AbortSignal, "abort").length, + ).toBeLessThanOrEqual(1); + + await source.stop(); + }); + it("resets the consecutive-failure count after a good pull so the loud ERROR does not re-fire", async () => { let call = 0; const impl = vi.fn(async () => { diff --git a/packages/channels/src/googlechat/pubsub-source.ts b/packages/channels/src/googlechat/pubsub-source.ts index dcd7105ef..73348f4ac 100644 --- a/packages/channels/src/googlechat/pubsub-source.ts +++ b/packages/channels/src/googlechat/pubsub-source.ts @@ -380,16 +380,20 @@ export function createPubSubSource(deps: PubSubSourceDeps): PubSubSource { const setT = deps.setTimeoutImpl ?? systemSetTimeout; const clearT = deps.clearTimeoutImpl ?? systemClearTimeout; await new Promise((resolve) => { - const timer = setT(resolve, ms); + let timer: ReturnType; + const onAbort = (): void => { + clearT(timer); + resolve(); + }; + timer = setT(() => { + // Normal completion: drop the abort listener so it does not accumulate + // on the shared signal across every backoff cycle (the persistent- + // failure path is exactly where this would otherwise leak). + controller.signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); pendingBackoff = timer; - controller.signal.addEventListener( - "abort", - () => { - clearT(timer); - resolve(); - }, - { once: true }, - ); + controller.signal.addEventListener("abort", onAbort, { once: true }); }); pendingBackoff = undefined; } From 8bcb5aa4bbe4a2eaff15b34431622255114d78f3 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 17:02:39 +0300 Subject: [PATCH 031/200] fix(233): LW-02 remove the dead retryable field from the error classifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retryable was computed on every branch and exported on the public interface, but no caller read it — the pull loop backs off regardless and the token mint always returns err. Only errorKind, status, and hint are consumed. Drop the field per YAGNI and update the asserting tests. The branch rationale (why each status is/isn't worth retrying) stays as prose on the errorKind. --- .../channels/src/googlechat/errors.test.ts | 20 +++++-------- packages/channels/src/googlechat/errors.ts | 28 +++++++------------ 2 files changed, 17 insertions(+), 31 deletions(-) diff --git a/packages/channels/src/googlechat/errors.test.ts b/packages/channels/src/googlechat/errors.test.ts index 95f5413a4..f12866e22 100644 --- a/packages/channels/src/googlechat/errors.test.ts +++ b/packages/channels/src/googlechat/errors.test.ts @@ -3,52 +3,46 @@ import { describe, it, expect } from "vitest"; import { classifyGoogleChatError } from "./errors.js"; describe("classifyGoogleChatError", () => { - it("classifies an absent status (transport failure) as a retryable network error", () => { + it("classifies an absent status (transport failure) as a network error", () => { const classified = classifyGoogleChatError(undefined); expect(classified.errorKind).toBe("network"); - expect(classified.retryable).toBe(true); expect(classified.status).toBeUndefined(); expect(classified.hint.toLowerCase()).toContain("connectivity"); }); - it("classifies a 401 response as a non-retryable auth failure that names the grant/scope", () => { + it("classifies a 401 response as an auth failure that names the grant/scope", () => { const classified = classifyGoogleChatError(401); expect(classified.errorKind).toBe("auth"); - expect(classified.retryable).toBe(false); expect(classified.status).toBe(401); expect(classified.hint.trim().length).toBeGreaterThan(0); expect(classified.hint.toLowerCase()).toContain("scope"); }); - it("classifies a 403 response as a non-retryable auth failure", () => { + it("classifies a 403 response as an auth failure", () => { const classified = classifyGoogleChatError(403); expect(classified.errorKind).toBe("auth"); - expect(classified.retryable).toBe(false); expect(classified.status).toBe(403); }); - it("classifies a 429 response as a retryable platform failure that surfaces the status", () => { + it("classifies a 429 response as a platform failure that surfaces the status", () => { const classified = classifyGoogleChatError(429); expect(classified.errorKind).toBe("platform"); - expect(classified.retryable).toBe(true); expect(classified.status).toBe(429); expect(classified.hint.trim().length).toBeGreaterThan(0); }); - it("classifies 500, 502 and 503 responses as retryable platform failures", () => { + it("classifies 500, 502 and 503 responses as platform failures", () => { for (const status of [500, 502, 503]) { const classified = classifyGoogleChatError(status); expect(classified.errorKind).toBe("platform"); - expect(classified.retryable).toBe(true); expect(classified.status).toBe(status); } }); - it("classifies an unexpected 4xx status as a non-retryable internal error", () => { - for (const status of [400, 404]) { + it("classifies an unexpected 4xx status as an internal error", () => { + for (const status of [405, 409]) { const classified = classifyGoogleChatError(status); expect(classified.errorKind).toBe("internal"); - expect(classified.retryable).toBe(false); expect(classified.status).toBe(status); } }); diff --git a/packages/channels/src/googlechat/errors.ts b/packages/channels/src/googlechat/errors.ts index 7ea36dadb..d2eabc7b9 100644 --- a/packages/channels/src/googlechat/errors.ts +++ b/packages/channels/src/googlechat/errors.ts @@ -2,8 +2,7 @@ /** * Google Chat error taxonomy: a pure classifier that maps a Chat / Pub/Sub REST * or token-endpoint HTTP status (or a transport-level failure) onto the closed - * observability errorKind union, a retry disposition, and an operator-actionable, - * origin-free hint. + * observability errorKind union and an operator-actionable, origin-free hint. * * Deliberately minimal — the token mint, the pull loop, and the outbound send * path consult it to attach `errorKind` / `hint` on their failure branches. It @@ -21,12 +20,10 @@ export type GoogleChatErrorKind = | "precondition" | "internal"; -/** A classified platform failure: kind, retry disposition, status, and hint. */ +/** A classified platform failure: kind, status, and hint. */ export interface ClassifiedGoogleChatError { /** The observability error kind. */ errorKind: GoogleChatErrorKind; - /** Whether retrying the same request could plausibly succeed. */ - retryable: boolean; /** The HTTP status, when a response was received. */ status?: number; /** An operator-actionable next step. Never carries a secret. */ @@ -36,14 +33,14 @@ export interface ClassifiedGoogleChatError { /** * Classify a platform failure by its HTTP status. * - * - `401` / `403` → `auth`, non-retryable — bad credentials, missing scope, or - * the service account is not authorized for the space/subscription; retrying - * without fixing the grant will not help. - * - `429` → `platform`, retryable — rate limited; back off then retry. - * - `>= 500` → `platform`, retryable — transient upstream error. - * - `undefined` → `network`, retryable — no response reached us (transport fault). - * - any other status (e.g. an unexpected 4xx) → `internal`, non-retryable — a - * malformed request is our own defect, not a transient condition. + * - `401` / `403` → `auth` — bad credentials, missing scope, or the service + * account is not authorized for the space/subscription; retrying without + * fixing the grant will not help. + * - `429` → `platform` — rate limited; back off then retry. + * - `>= 500` → `platform` — transient upstream error. + * - `undefined` → `network` — no response reached us (transport fault). + * - any other status (e.g. an unexpected 4xx) → `internal` — a malformed + * request is our own defect, not a transient condition. * * @param status - The HTTP status of the response, or undefined for a * transport-level failure where no response was received. @@ -60,14 +57,12 @@ export function classifyGoogleChatError( if (status === undefined) { return { errorKind: "network", - retryable: true, hint: "Check outbound connectivity to oauth2.googleapis.com / chat.googleapis.com / pubsub.googleapis.com, then retry", }; } if (status === 401 || status === 403) { return { errorKind: "auth", - retryable: false, status, hint: "Verify the service-account key, its scopes (chat.bot / pubsub), and that the SA has roles/pubsub.subscriber on the subscription", }; @@ -75,7 +70,6 @@ export function classifyGoogleChatError( if (status === 429) { return { errorKind: "platform", - retryable: true, status, hint: "Rate limited — back off and retry after the indicated window", }; @@ -83,14 +77,12 @@ export function classifyGoogleChatError( if (status >= 500) { return { errorKind: "platform", - retryable: true, status, hint: "Upstream Google service error — retry with backoff", }; } return { errorKind: "internal", - retryable: false, status, hint: "Unexpected response status — inspect the request shape and payload", }; From 45f4ed66b8de7d94d1c16ed809b0fcb6f71a2f9e Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 17:03:25 +0300 Subject: [PATCH 032/200] fix(233): LW-03 classify 404/400 as config with a knob-naming hint A 404 (subscription not found) or 400 (malformed request) fell into the terminal branch as errorKind internal with a generic 'inspect the request shape' hint. A wrong subscriptionName is an operator config error, and the hint named no knob. Map 404/400 to errorKind config with a hint that names the exact knob (channels.googlechat.subscriptionName) and the required roles/pubsub.subscriber grant. Genuinely unexpected statuses still classify internal. --- packages/channels/src/googlechat/errors.test.ts | 14 +++++++++++++- packages/channels/src/googlechat/errors.ts | 15 +++++++++++++-- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/packages/channels/src/googlechat/errors.test.ts b/packages/channels/src/googlechat/errors.test.ts index f12866e22..effd55058 100644 --- a/packages/channels/src/googlechat/errors.test.ts +++ b/packages/channels/src/googlechat/errors.test.ts @@ -39,7 +39,19 @@ describe("classifyGoogleChatError", () => { } }); - it("classifies an unexpected 4xx status as an internal error", () => { + it("classifies 404/400 as a config error naming the subscription knob (not internal)", () => { + // A 404 (subscription not found) or 400 (malformed subscription request) is + // an operator config error, not our own internal defect — the hint must name + // the exact knob so it is actionable at a glance. + for (const status of [400, 404]) { + const classified = classifyGoogleChatError(status); + expect(classified.errorKind).toBe("config"); + expect(classified.status).toBe(status); + expect(classified.hint).toContain("channels.googlechat.subscriptionName"); + } + }); + + it("classifies a genuinely unexpected 4xx status as an internal error", () => { for (const status of [405, 409]) { const classified = classifyGoogleChatError(status); expect(classified.errorKind).toBe("internal"); diff --git a/packages/channels/src/googlechat/errors.ts b/packages/channels/src/googlechat/errors.ts index d2eabc7b9..579f8c83b 100644 --- a/packages/channels/src/googlechat/errors.ts +++ b/packages/channels/src/googlechat/errors.ts @@ -18,6 +18,7 @@ export type GoogleChatErrorKind = | "platform" | "network" | "precondition" + | "config" | "internal"; /** A classified platform failure: kind, status, and hint. */ @@ -39,8 +40,11 @@ export interface ClassifiedGoogleChatError { * - `429` → `platform` — rate limited; back off then retry. * - `>= 500` → `platform` — transient upstream error. * - `undefined` → `network` — no response reached us (transport fault). - * - any other status (e.g. an unexpected 4xx) → `internal` — a malformed - * request is our own defect, not a transient condition. + * - `400` / `404` → `config` — a missing/malformed subscription is an operator + * config error (wrong `subscriptionName`), not our own defect; the hint names + * the knob. + * - any other status → `internal` — a genuinely unexpected response is our own + * defect, not a transient condition. * * @param status - The HTTP status of the response, or undefined for a * transport-level failure where no response was received. @@ -81,6 +85,13 @@ export function classifyGoogleChatError( hint: "Upstream Google service error — retry with backoff", }; } + if (status === 404 || status === 400) { + return { + errorKind: "config", + status, + hint: "Verify channels.googlechat.subscriptionName (projects/{project}/subscriptions/{sub}) exists and the service account holds roles/pubsub.subscriber on it", + }; + } return { errorKind: "internal", status, From e07fec6632e03fc2eca92b1643bbee9a99e8b249 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 17:05:35 +0300 Subject: [PATCH 033/200] fix(233): LW-04 guard start() idempotency and skip-ack a zero-handler backlog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two robustness gaps: (a) start() unconditionally built a fresh source and called source.start(), bypassing the source's own running-guard — a second start() without a stop() orphaned the first source, which kept polling forever (double-pull + leak). (b) A zero-handler fanout (Promise.allSettled([])) resolved successfully, so a message arriving before onMessage() wired a handler was acked and dropped — a pull channel drains the backlog on start(). Guard start() on _connected (early-return ok). Treat a zero-handler admitted inbound as a skip-ack (throw) so it redelivers, with no liveness bump so a never-wired ingress reads stale rather than falsely healthy. --- .../src/googlechat/googlechat-adapter.test.ts | 55 +++++++++++++++++++ .../src/googlechat/googlechat-adapter.ts | 24 ++++++++ 2 files changed, 79 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 6c1ac1d26..21d06eee5 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -301,6 +301,27 @@ describe("createGoogleChatAdapter — inbound gate + dispatch", () => { await expect(adapter.handleChatEvent(null)).resolves.toBeUndefined(); expect(handler).not.toHaveBeenCalled(); }); + + it("skip-acks (rejects) an admitted inbound when no handler is registered yet, so it redelivers", async () => { + const { deps } = await makeDeps({ allowMode: "open" }); + const adapter = createGoogleChatAdapter(deps); + + // A pull channel drains the subscription backlog immediately on start(); a + // message that arrives before onMessage() must redeliver, not be acked-and- + // dropped. No liveness bump — a never-wired ingress must look stale. + await expect(adapter.handleChatEvent(makeEvent())).rejects.toThrow(); + expect(adapter.getStatus?.().lastInboundAt).toBeUndefined(); + }); + + it("processes the redelivery once a handler is registered", async () => { + const { deps } = await makeDeps({ allowMode: "open" }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await expect(adapter.handleChatEvent(makeEvent())).resolves.toBeUndefined(); + expect(handler).toHaveBeenCalledTimes(1); + }); }); describe("createGoogleChatAdapter — status + lastInboundAt semantics", () => { @@ -383,6 +404,40 @@ describe("createGoogleChatAdapter — lifecycle", () => { expect(String(warn?.hint).toLowerCase()).toContain("pubsub"); }); + it("start() is idempotent: a second start() without an intervening stop() does not create/boot a second source", async () => { + let created = 0; + const starts: number[] = []; + const createSource = (): PubSubSource => { + created += 1; + return { + start: () => { + starts.push(1); + }, + stop: async () => {}, + pollOnce: async () => ({ + receivedCount: 0, + ackedCount: 0, + skippedCount: 0, + pullFailed: false, + }), + lastError: undefined, + running: false, + } as PubSubSource; + }; + const { deps } = await makeDeps({ createSource }); + const adapter = createGoogleChatAdapter(deps); + + const r1 = await adapter.start(); + const r2 = await adapter.start(); + + expect(r1.ok).toBe(true); + expect(r2.ok).toBe(true); + // Exactly one source — a second start() must not orphan the first (which + // would keep polling forever: double-pull + leak). + expect(created).toBe(1); + expect(starts.length).toBe(1); + }); + it("stop() stops the source and marks disconnected", async () => { const { deps, fake } = await makeDeps(); const adapter = createGoogleChatAdapter(deps); diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 420404ee8..442fc496d 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -164,6 +164,24 @@ export function createGoogleChatAdapter( return; // drop BEFORE any processing → ack (resolve) } + if (handlers.length === 0) { + // A pull channel drains the backlog immediately on start(); a message that + // arrives before onMessage() has wired a handler must redeliver, not be + // acked-and-dropped. No liveness bump — a never-wired ingress must look + // stale to the health monitor rather than falsely healthy. + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "No inbound handler registered yet; ack skipped so Pub/Sub redelivers once onMessage() has wired a handler", + errorKind: "internal" as const, + }, + "Inbound arrived before a handler was registered; skipping ack", + ); + // Skip-ack via the same pull-loop boundary as a handler failure (the file + // carries the @allow-throw annotation) so the message redelivers. + throw new Error("no inbound handler registered"); + } + _lastMessageAt = now(); _lastInboundAt = now(); @@ -222,6 +240,12 @@ export function createGoogleChatAdapter( }, async start(): Promise> { + // Idempotency: a second start() without an intervening stop() must not + // build and boot a fresh source that orphans the first (the source's own + // `if (running) return` guard is bypassed by creating a new source each + // call), which would double-pull the subscription and leak the old loop. + if (_connected) return ok(undefined); + const v = validateGoogleChatCredentials({ serviceAccountKey: deps.serviceAccountKey, subscriptionName: deps.subscriptionName, From 10ad1b1e99222fd4bbb428dd9b42d812fa94ce45 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 17:07:28 +0300 Subject: [PATCH 034/200] fix(233): NT-01 stop re-parsing the service-account key inside the adapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The adapter's start() called validateGoogleChatCredentials (a JSON.parse) even though the token provider it constructs already parses the key once at construction — the only parse whose result is consumed. That was a redundant in-flow parse. Expose the provider's cached parse via credentialError() and have start() reuse it (plus a non-parsing subscriptionName check) instead of re-parsing. The wiring keeps its own boot-time validateGoogleChatCredentials call as the operator fail-fast + allowlist lint — a distinct responsibility from the runtime provider. --- .../src/googlechat/googlechat-adapter.ts | 28 +++++++++++-------- .../src/googlechat/googlechat-auth.test.ts | 27 ++++++++++++++++++ .../src/googlechat/googlechat-auth.ts | 10 +++++++ 3 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 442fc496d..687d37130 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -57,7 +57,6 @@ import { mapGoogleChatEventToNormalized, type GoogleChatEvent, } from "./message-mapper.js"; -import { validateGoogleChatCredentials } from "./credential-validator.js"; /** Dependencies for the Google Chat adapter. */ export interface GoogleChatAdapterDeps { @@ -246,24 +245,29 @@ export function createGoogleChatAdapter( // call), which would double-pull the subscription and leak the old loop. if (_connected) return ok(undefined); - const v = validateGoogleChatCredentials({ - serviceAccountKey: deps.serviceAccountKey, - subscriptionName: deps.subscriptionName, - allowFrom: deps.allowFrom, - logger: deps.logger, - }); - if (!v.ok) { + // The token provider already parsed the service-account key once at + // construction; reuse that result rather than re-parsing here. The + // subscription is the only additional precondition (it is not a parse). + const credErr = tokens.credentialError(); + const subMissing = + !deps.subscriptionName || deps.subscriptionName.trim() === ""; + if (credErr || subMissing) { + const startErr = new Error( + credErr + ? `Google Chat credentials invalid: ${credErr.hint}` + : "Google Chat credentials invalid: subscriptionName must not be empty", + ); deps.logger.error( { channelType: "googlechat" as const, - err: v.error, - hint: "Set channels.googlechat.serviceAccountKey (SecretRef) or GOOGLECHAT_SA_KEY, and subscriptionName", + err: startErr, + hint: "Set channels.googlechat.serviceAccountKey (SecretRef) or GOOGLECHAT_SA_KEY, and channels.googlechat.subscriptionName", errorKind: "auth" as const, }, "Adapter start failed", ); - _lastError = v.error.message; - return err(v.error); + _lastError = startErr.message; + return err(startErr); } if (deps.mode && deps.mode !== "pubsub") { diff --git a/packages/channels/src/googlechat/googlechat-auth.test.ts b/packages/channels/src/googlechat/googlechat-auth.test.ts index a6672a784..4e5418547 100644 --- a/packages/channels/src/googlechat/googlechat-auth.test.ts +++ b/packages/channels/src/googlechat/googlechat-auth.test.ts @@ -127,6 +127,33 @@ function assertNoSecretsLogged( } } +describe("createGoogleChatTokenProvider — credentialError (single-parse reuse)", () => { + it("returns undefined for a well-formed service-account key", async () => { + const { deps } = await makeDeps(); + const provider = createGoogleChatTokenProvider(deps); + expect(provider.credentialError()).toBeUndefined(); + }); + + it("surfaces a secret-free hint for a malformed key, cached across calls (no re-parse)", async () => { + const { deps } = await makeDeps({ serviceAccountKey: "{not json" }); + const provider = createGoogleChatTokenProvider(deps); + const first = provider.credentialError(); + const second = provider.credentialError(); + expect(first?.hint).toBeTruthy(); + expect(first).toEqual(second); // same cached parse result, not re-parsed + expect(first?.hint.toLowerCase()).toContain("service-account key"); + }); + + it("surfaces a missing-field hint naming the absent field", async () => { + const noEmail = JSON.stringify({ + private_key: "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----", + }); + const { deps } = await makeDeps({ serviceAccountKey: noEmail }); + const provider = createGoogleChatTokenProvider(deps); + expect(provider.credentialError()?.hint).toContain("client_email"); + }); +}); + describe("createGoogleChatTokenProvider — SA-JWT-bearer mint", () => { it("mints an RS256 SA-JWT assertion and POSTs the jwt-bearer grant to the token endpoint", async () => { const { deps, spy, sa } = await makeDeps(); diff --git a/packages/channels/src/googlechat/googlechat-auth.ts b/packages/channels/src/googlechat/googlechat-auth.ts index 16905acb8..83be3ded0 100644 --- a/packages/channels/src/googlechat/googlechat-auth.ts +++ b/packages/channels/src/googlechat/googlechat-auth.ts @@ -86,6 +86,12 @@ export interface GoogleChatTokenProvider { * The chat.bot and pubsub scopes cache independently. */ getToken(scope: GoogleChatScope): Promise>; + /** + * A secret-free credential-parse failure hint, or undefined when the + * service-account key parsed cleanly. Reuses the SINGLE parse done at + * construction, so a start()-time precondition check need not re-parse the key. + */ + credentialError(): { hint: string } | undefined; } /** The service-account fields the assertion mint needs. */ @@ -156,6 +162,10 @@ export function createGoogleChatTokenProvider( const cache = new Map(); return { + credentialError(): { hint: string } | undefined { + return keyParse.ok ? undefined : keyParse.error; + }, + async getToken(scope: GoogleChatScope): Promise> { // Cache hit for this scope while still comfortably before expiry. const hit = cache.get(scope); From d2fc2b41a1891b1a6b261129fa35f3ab7d6f03b5 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 17:08:17 +0300 Subject: [PATCH 035/200] fix(233): NT-02 log the persistent-failure ERROR once on the threshold crossing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once consecutiveFailures >= threshold, the loud ERROR fired on every subsequent failing cycle — up to one per 30s backoff cap, indefinitely — surfacing as a steady ERROR flood for a single dead loop. Fire the ERROR only on the crossing edge (=== threshold). lastError is still set every cycle for status degradation, and a fresh episode after a good-pull reset re-alerts on the next crossing. --- .../src/googlechat/pubsub-source.test.ts | 23 +++++++++++++++++++ .../channels/src/googlechat/pubsub-source.ts | 6 ++++- 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/pubsub-source.test.ts b/packages/channels/src/googlechat/pubsub-source.test.ts index ed10e0780..2723762f3 100644 --- a/packages/channels/src/googlechat/pubsub-source.test.ts +++ b/packages/channels/src/googlechat/pubsub-source.test.ts @@ -629,6 +629,29 @@ describe("createPubSubSource — bounded jittered backoff + AbortController stop await source.stop(); }); + it("logs the loud ERROR once on the threshold crossing, not on every failing cycle past it", async () => { + const timers = makeFakeTimers(); + const { deps, loggerSpy } = makeDeps({ + fetchImpl: failingFetch(), + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + rng: () => 0.5, + errorLogThreshold: 3, + }); + const source = createPubSubSource(deps); + + source.start(); + await flushMicrotasks(); // failure #1 + for (let i = 0; i < 5; i += 1) await timers.fireNext(); // failures #2..#6 + await source.stop(); + + // The threshold is crossed once (at #3); #4/#5/#6 must NOT re-emit the loud + // ERROR — otherwise a dead loop floods one ERROR per 30s cap indefinitely. + // lastError still reflects the ongoing failure for status degradation. + expect(loggerSpy.error).toHaveBeenCalledTimes(1); + expect(typeof source.lastError).toBe("string"); + }); + it("resets the consecutive-failure count after a good pull so the loud ERROR does not re-fire", async () => { let call = 0; const impl = vi.fn(async () => { diff --git a/packages/channels/src/googlechat/pubsub-source.ts b/packages/channels/src/googlechat/pubsub-source.ts index 73348f4ac..3d7c80179 100644 --- a/packages/channels/src/googlechat/pubsub-source.ts +++ b/packages/channels/src/googlechat/pubsub-source.ts @@ -415,7 +415,11 @@ export function createPubSubSource(deps: PubSubSourceDeps): PubSubSource { if (out.pullFailed) { consecutiveFailures += 1; - if (consecutiveFailures >= threshold) { + // Log the loud ERROR once, on the edge that crosses the threshold — not + // on every subsequent failing cycle, which would flood one ERROR per + // backoff cap (30s) for a dead loop. `lastError` (set every cycle in + // pollOnce) carries the ongoing failure for status degradation. + if (consecutiveFailures === threshold) { deps.logger.error( { channelType: "googlechat" as const, From 3939305d3ea1aa808d357c44ffcee4a22b952753 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 17:11:55 +0300 Subject: [PATCH 036/200] fix(233): LW-01 use const for the backoff timer handle (prefer-const) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the abortableSleep listener-leak fix: the timer handle is assigned once, so prefer-const flagged the interim let. Switch to const with the onAbort closure forward-referencing it (safe — onAbort only runs on the abort event, after the assignment). Restores lint:security to zero errors. --- packages/channels/src/googlechat/pubsub-source.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/channels/src/googlechat/pubsub-source.ts b/packages/channels/src/googlechat/pubsub-source.ts index 3d7c80179..ae0db4a8c 100644 --- a/packages/channels/src/googlechat/pubsub-source.ts +++ b/packages/channels/src/googlechat/pubsub-source.ts @@ -380,12 +380,13 @@ export function createPubSubSource(deps: PubSubSourceDeps): PubSubSource { const setT = deps.setTimeoutImpl ?? systemSetTimeout; const clearT = deps.clearTimeoutImpl ?? systemClearTimeout; await new Promise((resolve) => { - let timer: ReturnType; + // onAbort closes over `timer`; it only runs on the abort event, by which + // point `timer` is assigned — so the forward reference is safe. const onAbort = (): void => { clearT(timer); resolve(); }; - timer = setT(() => { + const timer = setT(() => { // Normal completion: drop the abort listener so it does not accumulate // on the shared signal across every backoff cycle (the persistent- // failure path is exactly where this would otherwise leak). From ebe7a24fc9267921f4459fb0efc15b5eb54faaab Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 18:53:28 +0300 Subject: [PATCH 037/200] test(234-01): add retryable disposition + defensive Retry-After parse cases - assert retryable per status (network/429/5xx true; auth/config/internal false) - assert parseRetryAfterSeconds: integer seconds, absent/non-numeric/negative -> undefined - assert HTTP-date resolves to whole seconds from an injected reference clock; no clock -> undefined --- .../channels/src/googlechat/errors.test.ts | 85 ++++++++++++++++++- 1 file changed, 84 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/errors.test.ts b/packages/channels/src/googlechat/errors.test.ts index effd55058..8f1927405 100644 --- a/packages/channels/src/googlechat/errors.test.ts +++ b/packages/channels/src/googlechat/errors.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; -import { classifyGoogleChatError } from "./errors.js"; +import { classifyGoogleChatError, parseRetryAfterSeconds } from "./errors.js"; describe("classifyGoogleChatError", () => { it("classifies an absent status (transport failure) as a network error", () => { @@ -65,3 +65,86 @@ describe("classifyGoogleChatError", () => { expect(classified.hint).not.toContain("SECRET"); }); }); + +describe("classifyGoogleChatError retry disposition", () => { + it("marks a transport failure (no status) retryable — a resend is safe to attempt", () => { + expect(classifyGoogleChatError(undefined).retryable).toBe(true); + }); + + it("marks 401/403 auth failures non-retryable — the grant must be fixed first", () => { + expect(classifyGoogleChatError(401).retryable).toBe(false); + expect(classifyGoogleChatError(403).retryable).toBe(false); + }); + + it("marks a 429 rate limit retryable — the request was rejected before it landed", () => { + expect(classifyGoogleChatError(429).retryable).toBe(true); + }); + + it("marks 5xx upstream errors retryable", () => { + for (const status of [500, 502, 503]) { + expect(classifyGoogleChatError(status).retryable).toBe(true); + } + }); + + it("marks 400/404 config errors non-retryable — an operator must fix the config", () => { + expect(classifyGoogleChatError(400).retryable).toBe(false); + expect(classifyGoogleChatError(404).retryable).toBe(false); + }); + + it("marks a genuinely unexpected status non-retryable", () => { + for (const status of [405, 409]) { + expect(classifyGoogleChatError(status).retryable).toBe(false); + } + }); +}); + +describe("parseRetryAfterSeconds", () => { + /** Build a minimal response whose header accessor returns a fixed value. */ + const withRetryAfter = (value: string | null) => ({ + headers: { + get: (name: string): string | null => + name.toLowerCase() === "retry-after" ? value : null, + }, + }); + + it("reads a bare non-negative integer as a second count", () => { + expect(parseRetryAfterSeconds(withRetryAfter("12"))).toBe(12); + expect(parseRetryAfterSeconds(withRetryAfter("0"))).toBe(0); + }); + + it("returns undefined when the header is absent", () => { + expect(parseRetryAfterSeconds(withRetryAfter(null))).toBeUndefined(); + }); + + it("returns undefined for a non-numeric, non-date value", () => { + expect(parseRetryAfterSeconds(withRetryAfter("garbage"))).toBeUndefined(); + }); + + it("rejects a negative second count rather than awaiting a bogus delay", () => { + expect(parseRetryAfterSeconds(withRetryAfter("-5"))).toBeUndefined(); + }); + + it("returns undefined when the response exposes no headers accessor", () => { + expect(parseRetryAfterSeconds({})).toBeUndefined(); + expect(parseRetryAfterSeconds({ headers: {} })).toBeUndefined(); + }); + + it("resolves a future HTTP-date to whole seconds from the injected reference clock", () => { + const nowMs = 1_000_000; + const future = new Date(nowMs + 30_000).toUTCString(); + expect(parseRetryAfterSeconds(withRetryAfter(future), nowMs)).toBe(30); + }); + + it("clamps a past HTTP-date to zero (never a negative delay)", () => { + const nowMs = 1_000_000; + const past = new Date(nowMs - 30_000).toUTCString(); + expect(parseRetryAfterSeconds(withRetryAfter(past), nowMs)).toBe(0); + }); + + it("returns undefined for an HTTP-date when no reference clock is supplied", () => { + // The pure module never reads an ambient clock; a date delay needs an + // explicit reference, so without one the caller falls back to bounded backoff. + const future = new Date(2_000_000).toUTCString(); + expect(parseRetryAfterSeconds(withRetryAfter(future))).toBeUndefined(); + }); +}); From fed65f2ffed95803f508d38d9b6e8bea4a0c3b9f Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 18:55:27 +0300 Subject: [PATCH 038/200] feat(234-01): add retryable disposition + defensive Retry-After parse - add required retryable:boolean to ClassifiedGoogleChatError; attach per branch (network/429/5xx retryable; auth/config/internal not) mirroring the send-path transience axis - export parseRetryAfterSeconds: delta-seconds or HTTP-date -> non-negative whole seconds; absent/non-numeric/negative/clockless-date -> undefined; no ambient clock - raw second count returned; the send path clamps to its own ceiling downstream --- packages/channels/src/googlechat/errors.ts | 85 ++++++++++++++++++---- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/packages/channels/src/googlechat/errors.ts b/packages/channels/src/googlechat/errors.ts index 579f8c83b..bb328ffa5 100644 --- a/packages/channels/src/googlechat/errors.ts +++ b/packages/channels/src/googlechat/errors.ts @@ -2,7 +2,8 @@ /** * Google Chat error taxonomy: a pure classifier that maps a Chat / Pub/Sub REST * or token-endpoint HTTP status (or a transport-level failure) onto the closed - * observability errorKind union and an operator-actionable, origin-free hint. + * observability errorKind union, a retry disposition, and an operator-actionable, + * origin-free hint. A defensive `Retry-After` reader lives alongside it. * * Deliberately minimal — the token mint, the pull loop, and the outbound send * path consult it to attach `errorKind` / `hint` on their failure branches. It @@ -21,10 +22,12 @@ export type GoogleChatErrorKind = | "config" | "internal"; -/** A classified platform failure: kind, status, and hint. */ +/** A classified platform failure: kind, retry disposition, status, and hint. */ export interface ClassifiedGoogleChatError { /** The observability error kind. */ errorKind: GoogleChatErrorKind; + /** Whether retrying the same request could plausibly succeed. */ + retryable: boolean; /** The HTTP status, when a response was received. */ status?: number; /** An operator-actionable next step. Never carries a secret. */ @@ -32,19 +35,23 @@ export interface ClassifiedGoogleChatError { } /** - * Classify a platform failure by its HTTP status. + * Classify a platform failure by its HTTP status. The `retryable` disposition is + * the transience axis — whether retrying the same request could plausibly + * succeed — and is deliberately broader than the send path's own send-safety + * decision (a non-idempotent create resends only on the statuses that reject + * before the message lands). * - * - `401` / `403` → `auth` — bad credentials, missing scope, or the service - * account is not authorized for the space/subscription; retrying without - * fixing the grant will not help. - * - `429` → `platform` — rate limited; back off then retry. - * - `>= 500` → `platform` — transient upstream error. - * - `undefined` → `network` — no response reached us (transport fault). - * - `400` / `404` → `config` — a missing/malformed subscription is an operator - * config error (wrong `subscriptionName`), not our own defect; the hint names - * the knob. - * - any other status → `internal` — a genuinely unexpected response is our own - * defect, not a transient condition. + * - `401` / `403` → `auth`, non-retryable — bad credentials, missing scope, or + * the service account is not authorized for the space/subscription; retrying + * without fixing the grant will not help. + * - `429` → `platform`, retryable — rate limited; back off then retry. + * - `>= 500` → `platform`, retryable — transient upstream error. + * - `undefined` → `network`, retryable — no response reached us (transport fault). + * - `400` / `404` → `config`, non-retryable — a missing/malformed subscription is + * an operator config error (wrong `subscriptionName`), not our own defect; the + * hint names the knob. + * - any other status → `internal`, non-retryable — a genuinely unexpected + * response is our own defect, not a transient condition. * * @param status - The HTTP status of the response, or undefined for a * transport-level failure where no response was received. @@ -61,12 +68,14 @@ export function classifyGoogleChatError( if (status === undefined) { return { errorKind: "network", + retryable: true, hint: "Check outbound connectivity to oauth2.googleapis.com / chat.googleapis.com / pubsub.googleapis.com, then retry", }; } if (status === 401 || status === 403) { return { errorKind: "auth", + retryable: false, status, hint: "Verify the service-account key, its scopes (chat.bot / pubsub), and that the SA has roles/pubsub.subscriber on the subscription", }; @@ -74,6 +83,7 @@ export function classifyGoogleChatError( if (status === 429) { return { errorKind: "platform", + retryable: true, status, hint: "Rate limited — back off and retry after the indicated window", }; @@ -81,6 +91,7 @@ export function classifyGoogleChatError( if (status >= 500) { return { errorKind: "platform", + retryable: true, status, hint: "Upstream Google service error — retry with backoff", }; @@ -88,13 +99,59 @@ export function classifyGoogleChatError( if (status === 404 || status === 400) { return { errorKind: "config", + retryable: false, status, hint: "Verify channels.googlechat.subscriptionName (projects/{project}/subscriptions/{sub}) exists and the service account holds roles/pubsub.subscriber on it", }; } return { errorKind: "internal", + retryable: false, status, hint: "Unexpected response status — inspect the request shape and payload", }; } + +/** + * Read a `Retry-After` header defensively off a rate-limit response. + * + * The header is operator-untrusted input that would drive a wait, so this reader + * never yields a NaN or a negative delay: an absent, unreadable, non-numeric, or + * negative value returns `undefined` so the caller falls back to bounded backoff. + * It resolves both header forms: + * + * - delta-seconds: a bare non-negative integer count of seconds. + * - HTTP-date: an absolute instant, resolved to whole seconds from `nowMs` + * (clamped at 0 for a past date). A date needs an explicit reference clock — + * this pure reader never reads an ambient clock — so when `nowMs` is omitted a + * date value returns `undefined`. + * + * The returned value is a raw second count; the send path clamps it to its own + * ceiling before awaiting, so a large-but-finite value is never awaited verbatim. + * + * @param res - The response, accessed only through an optional `headers.get`. + * @param nowMs - Reference epoch-ms for resolving an HTTP-date form. + */ +export function parseRetryAfterSeconds( + res: { headers?: { get?: (name: string) => string | null } }, + nowMs?: number, +): number | undefined { + const raw = res.headers?.get?.("retry-after"); + if (typeof raw !== "string") return undefined; + const trimmed = raw.trim(); + if (trimmed.length === 0) return undefined; + + // Delta-seconds form: a numeric lead means this is a second count. A negative + // value is hostile/invalid — reject it rather than fall through to a date parse. + const seconds = Number.parseInt(trimmed, 10); + if (Number.isFinite(seconds)) { + return seconds >= 0 ? seconds : undefined; + } + + // HTTP-date form: resolve to a non-negative whole-second delay from the + // supplied reference. Without a reference clock a date cannot be resolved. + if (typeof nowMs !== "number") return undefined; + const whenMs = Date.parse(trimmed); + if (!Number.isFinite(whenMs)) return undefined; + return Math.max(0, Math.round((whenMs - nowMs) / 1000)); +} From 408c54b4505d67086b41cec785df59600063c4e9 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 18:59:57 +0300 Subject: [PATCH 039/200] test(234-01): add per-space send-pacer determinism + abort-awareness cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - first same-space write: zero wait; second within the interval waits the remainder - different spaces independent; concurrent same-space acquires serialized (no race) - pending wait resolves promptly on abort, cancels + unrefs the timer handle - injected clock + capture-delay/fireNext fake timer — no real waits --- .../src/googlechat/send-pacer.test.ts | 178 ++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 packages/channels/src/googlechat/send-pacer.test.ts diff --git a/packages/channels/src/googlechat/send-pacer.test.ts b/packages/channels/src/googlechat/send-pacer.test.ts new file mode 100644 index 000000000..c613c5914 --- /dev/null +++ b/packages/channels/src/googlechat/send-pacer.test.ts @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect } from "vitest"; +import { createSendPacer, type SendPacerDeps } from "./send-pacer.js"; + +/** Drain the microtask queue so an awaited async chain can make progress. */ +async function flushMicrotasks(n = 40): Promise { + for (let i = 0; i < n; i += 1) await Promise.resolve(); +} + +/** + * A deterministic timer seam. It CAPTURES each scheduled delay and parks the + * callback (never firing on real time); `fireNext()` resolves the oldest parked + * wait so a pace-wait completes with zero real time. Each handle carries an + * `unref` spy so the shutdown-safety unref can be asserted, and `cleared` + * records handles passed to the canceller for the abort assertion. + */ +function makeFakeTimers() { + const delays: number[] = []; + const cleared: unknown[] = []; + const unrefs: number[] = []; + let pending: Array<{ id: number; cb: () => void }> = []; + let seq = 0; + const setTimeoutImpl = ((cb: () => void, ms: number) => { + const id = (seq += 1); + delays.push(ms); + pending.push({ id, cb }); + return { id, unref: () => unrefs.push(id) }; + }) as unknown as SendPacerDeps["setTimeout"]; + const clearTimeoutImpl = ((handle: { id: number }) => { + cleared.push(handle); + pending = pending.filter((p) => p.id !== handle.id); + }) as unknown as SendPacerDeps["clearTimeout"]; + async function fireNext(): Promise { + const next = pending.shift(); + if (!next) throw new Error("no pending pace-wait to fire"); + next.cb(); + await flushMicrotasks(); + } + return { + setTimeoutImpl, + clearTimeoutImpl, + delays, + cleared, + unrefs, + fireNext, + pendingCount: () => pending.length, + }; +} + +describe("createSendPacer", () => { + it("lets the first write to a space proceed with no wait", async () => { + const timers = makeFakeTimers(); + const nowMs = 1000; + const pacer = createSendPacer({ + now: () => nowMs, + setTimeout: timers.setTimeoutImpl, + clearTimeout: timers.clearTimeoutImpl, + minIntervalMs: 1000, + }); + + await pacer.acquire("spaces/A"); + + expect(timers.delays).toEqual([]); // no timer scheduled for the first write + }); + + it("paces a second same-space write by the remaining interval, then advances the window", async () => { + const timers = makeFakeTimers(); + let nowMs = 1000; + const pacer = createSendPacer({ + now: () => nowMs, + setTimeout: timers.setTimeoutImpl, + clearTimeout: timers.clearTimeoutImpl, + minIntervalMs: 1000, + }); + + await pacer.acquire("spaces/A"); // nextAllowed = 2000 + nowMs = 1300; // 300ms elapsed, 700ms remaining in the interval + const second = pacer.acquire("spaces/A"); + await flushMicrotasks(); + expect(timers.delays).toEqual([700]); + + nowMs = 2000; // the pace-wait elapses + await timers.fireNext(); + await second; // nextAllowed = 2000 + 1000 = 3000 + + const third = pacer.acquire("spaces/A"); // now=2000 → wait 3000-2000 = 1000 + await flushMicrotasks(); + expect(timers.delays).toEqual([700, 1000]); + nowMs = 3000; + await timers.fireNext(); + await third; + }); + + it("keeps different spaces independent — a B write is not blocked by an A write", async () => { + const timers = makeFakeTimers(); + let nowMs = 1000; + const pacer = createSendPacer({ + now: () => nowMs, + setTimeout: timers.setTimeoutImpl, + clearTimeout: timers.clearTimeoutImpl, + minIntervalMs: 1000, + }); + + await pacer.acquire("spaces/A"); // A nextAllowed = 2000 + nowMs = 1100; // well within A's interval + await pacer.acquire("spaces/B"); // different key → no wait + + expect(timers.delays).toEqual([]); + }); + + it("serializes concurrent same-space acquires so a burst cannot fire together", async () => { + const timers = makeFakeTimers(); + const nowMs = 1000; + const pacer = createSendPacer({ + now: () => nowMs, + setTimeout: timers.setTimeoutImpl, + clearTimeout: timers.clearTimeoutImpl, + minIntervalMs: 1000, + }); + + const first = pacer.acquire("spaces/A"); + const second = pacer.acquire("spaces/A"); + await flushMicrotasks(); + + // The first proceeds immediately (no timer); the second chains behind it and + // must wait the full interval — a racy check-then-act would schedule zero. + expect(timers.delays).toEqual([1000]); + + await first; + await timers.fireNext(); + await second; + }); + + it("resolves a pending pace-wait promptly on abort and unrefs the timer handle", async () => { + const timers = makeFakeTimers(); + let nowMs = 1000; + const pacer = createSendPacer({ + now: () => nowMs, + setTimeout: timers.setTimeoutImpl, + clearTimeout: timers.clearTimeoutImpl, + minIntervalMs: 1000, + }); + + await pacer.acquire("spaces/A"); // nextAllowed = 2000 + nowMs = 1200; + const controller = new AbortController(); + const pending = pacer.acquire("spaces/A", controller.signal); + await flushMicrotasks(); + expect(timers.delays).toEqual([800]); // 2000 - 1200 + expect(timers.unrefs).toHaveLength(1); // handle unref'd so a wait never blocks shutdown + + controller.abort(); + await expect(pending).resolves.toBeUndefined(); // resolves promptly, no hang + expect(timers.cleared).toHaveLength(1); // the pending timer was cancelled on abort + expect(timers.pendingCount()).toBe(0); + }); + + it("resolves promptly when the signal is already aborted before the wait", async () => { + const timers = makeFakeTimers(); + let nowMs = 1000; + const pacer = createSendPacer({ + now: () => nowMs, + setTimeout: timers.setTimeoutImpl, + clearTimeout: timers.clearTimeoutImpl, + minIntervalMs: 1000, + }); + + await pacer.acquire("spaces/A"); // nextAllowed = 2000 + nowMs = 1200; + const controller = new AbortController(); + controller.abort(); + + await expect( + pacer.acquire("spaces/A", controller.signal), + ).resolves.toBeUndefined(); + expect(timers.delays).toEqual([]); // resolved at entry — no timer scheduled + }); +}); From 1d99870190761eb50a426a957ae73449742544e0 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:00:41 +0300 Subject: [PATCH 040/200] feat(234-01): add per-space send pacer (createSendPacer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - enforce >=minIntervalMs (default 1000) between writes to one space via a per-space serialized promise chain; different spaces stay independent - serialization closes the concurrent check-then-act race that would let a same-space burst fire together - abort-aware wait on the injected timer with an unref'd handle: a pending pace-wait resolves promptly on abort and never blocks shutdown - injected now/setTimeout/clearTimeout seams only — no ambient clock or timer --- .../channels/src/googlechat/send-pacer.ts | 108 ++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 packages/channels/src/googlechat/send-pacer.ts diff --git a/packages/channels/src/googlechat/send-pacer.ts b/packages/channels/src/googlechat/send-pacer.ts new file mode 100644 index 000000000..840724d1d --- /dev/null +++ b/packages/channels/src/googlechat/send-pacer.ts @@ -0,0 +1,108 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Per-space outbound write pacer for the Google Chat send path. + * + * Google Chat caps message creation at one write per second per space, so a + * chunked reply that fans several messages into a single space must space its + * writes or trip a 429. This pacer enforces that ceiling proactively: + * `acquire(space)` resolves only once at least `minIntervalMs` has elapsed since + * the previous write to the SAME space. Different spaces are independent — a + * write to one never blocks a write to another. + * + * Concurrent acquires for one space are serialized through a per-space promise + * chain, so a burst cannot check-then-act its way past the interval by all + * reading the same "next allowed" instant and firing together. + * + * The wait is abort-aware and rides an injected timer whose handle is unref'd, + * so a pending pace-wait resolves promptly on abort and never blocks process + * shutdown. The clock and timer are injected seams — no ambient time is read — + * so the pacing is deterministic under test. + * + * @module + */ + +import type { systemSetTimeout, systemClearTimeout } from "@comis/core"; + +/** Default minimum interval between writes to a single space. */ +const DEFAULT_MIN_INTERVAL_MS = 1000; + +/** Injected seams the pacer needs: a clock, a one-shot timer, and its canceller. */ +export interface SendPacerDeps { + /** Injected clock in epoch ms. */ + now: () => number; + /** Injected one-shot timer for the pace-wait. */ + setTimeout: typeof systemSetTimeout; + /** Injected timer canceller, used to drop a pending wait on abort. */ + clearTimeout?: typeof systemClearTimeout; + /** Minimum ms between writes to one space. Defaults to 1000. */ + minIntervalMs?: number; +} + +/** A per-space outbound write pacer. */ +export interface SendPacer { + /** + * Resolve once a write to `space` may proceed under the per-space interval. + * A supplied `signal` cancels a pending wait promptly (resolving, not + * rejecting — the write is simply abandoned upstream). + */ + acquire(space: string, signal?: AbortSignal): Promise; +} + +/** + * Create a per-space write pacer enforcing at most one write per + * `minIntervalMs` per space. + */ +export function createSendPacer(deps: SendPacerDeps): SendPacer { + const minInterval = deps.minIntervalMs ?? DEFAULT_MIN_INTERVAL_MS; + // Per-space serialized tail: each acquire chains behind the prior same-space + // acquire so the interval is enforced sequentially, never racily. + const tails = new Map>(); + // Per-space earliest-next-write instant, in epoch ms. + const nextAllowed = new Map(); + + // Resolve after `ms`, or promptly if `signal` aborts. The timer handle is + // unref'd so a pending wait never holds the event loop open at shutdown. + function sleep(ms: number, signal?: AbortSignal): Promise { + if (ms <= 0) return Promise.resolve(); + if (signal?.aborted === true) return Promise.resolve(); + return new Promise((resolve) => { + // onAbort closes over `handle`; it only runs on the abort event, by which + // point `handle` is assigned — so the forward reference is safe. + const onAbort = (): void => { + deps.clearTimeout?.(handle); + resolve(); + }; + const handle = deps.setTimeout(() => { + // Normal completion: drop the abort listener so it does not accumulate + // on the signal across successive writes sharing one signal. + signal?.removeEventListener("abort", onAbort); + resolve(); + }, ms); + handle.unref?.(); + signal?.addEventListener("abort", onAbort, { once: true }); + }); + } + + async function waitTurn(space: string, signal?: AbortSignal): Promise { + const wait = Math.max(0, (nextAllowed.get(space) ?? 0) - deps.now()); + await sleep(wait, signal); + nextAllowed.set(space, deps.now() + minInterval); + } + + return { + acquire(space: string, signal?: AbortSignal): Promise { + const prior = tails.get(space) ?? Promise.resolve(); + const mine = prior.then(() => waitTurn(space, signal)); + // The tail must never reject the chain — a failed or aborted wait still + // releases the next same-space acquire. + tails.set( + space, + mine.then( + () => undefined, + () => undefined, + ), + ); + return mine; + }, + }; +} From de924c973cb08f5ab130684f322749438bc8895e Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:09:16 +0300 Subject: [PATCH 041/200] test(234-02): assert mapper dual-sets generic metadata.threadId - extend the thread-capture case to require metadata.threadId equals the inbound thread resource name alongside the channel-scoped key - extend the no-thread case to require both keys are omitted --- packages/channels/src/googlechat/message-mapper.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/channels/src/googlechat/message-mapper.test.ts b/packages/channels/src/googlechat/message-mapper.test.ts index 63d75d298..9499959e0 100644 --- a/packages/channels/src/googlechat/message-mapper.test.ts +++ b/packages/channels/src/googlechat/message-mapper.test.ts @@ -199,7 +199,7 @@ describe("mapGoogleChatEventToNormalized", () => { expect(result?.metadata.googlechatMessageName).toBeUndefined(); }); - it("captures the thread resource name under a googlechat metadata key", () => { + it("captures the thread resource name under BOTH the generic threadId and the googlechat key", () => { const result = mapGoogleChatEventToNormalized( makeChatEvent({ message: { @@ -210,15 +210,19 @@ describe("mapGoogleChatEventToNormalized", () => { }, }), ); + // The generic key is what the shared inbound→outbound thread propagation + // consumes; the channel-scoped key is retained alongside it. + expect(result?.metadata.threadId).toBe("spaces/AAAA/threads/TTTT"); expect(result?.metadata.googlechatThreadId).toBe("spaces/AAAA/threads/TTTT"); }); - it("omits the thread metadata key when no thread is present", () => { + it("omits both thread metadata keys when no thread is present", () => { const result = mapGoogleChatEventToNormalized( makeChatEvent({ message: { name: "spaces/AAAA/messages/1", sender: { name: "users/1" }, text: "hi" }, }), ); + expect(result?.metadata.threadId).toBeUndefined(); expect(result?.metadata.googlechatThreadId).toBeUndefined(); }); From acaefbc52f0dce0393c47c68443d05c0b7ae0387 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:10:03 +0300 Subject: [PATCH 042/200] feat(234-02): mapper dual-sets generic metadata.threadId for threaded reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - set metadata.threadId = message.thread.name alongside the channel-scoped googlechatThreadId so the shared inbound→outbound thread propagation routes a reply back into the inbound thread with no orchestrator change --- packages/channels/src/googlechat/message-mapper.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/channels/src/googlechat/message-mapper.ts b/packages/channels/src/googlechat/message-mapper.ts index b44650823..d1b600dc5 100644 --- a/packages/channels/src/googlechat/message-mapper.ts +++ b/packages/channels/src/googlechat/message-mapper.ts @@ -96,9 +96,15 @@ export function mapGoogleChatEventToNormalized( // as replyToMetaKey "googlechatMessageName" — write it so the inbound-message-id // resolver records the native id and the reply-to path can quote this message. if (message.name) metadata.googlechatMessageName = message.name; - // Capture the thread resource name for routing; replying into the thread is - // handled elsewhere on the send path. - if (message.thread?.name) metadata.googlechatThreadId = message.thread.name; + // Capture the inbound thread resource name for routing. The generic + // metadata.threadId key is the one the shared inbound→outbound thread + // propagation consumes to route a reply back into the same thread; the + // channel-scoped key is retained alongside it. Replying into the thread is + // handled on the send path, not here. + if (message.thread?.name) { + metadata.threadId = message.thread.name; + metadata.googlechatThreadId = message.thread.name; + } return { id: randomUUID(), From bcf9ef82c59ccddf0a9c0d40f9efea4d2e6860ae Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:10:54 +0300 Subject: [PATCH 043/200] test(234-02): assert sendMessage threads the request from options.threadId - threaded case requires the messageReplyOption query param + a body carrying thread:{name}, with the thread name as a body value (never URL-path-interpolated) - no-threadId case (options present, threadId absent) stays a plain {text} send to the un-parameterised URL as a regression lock --- .../src/googlechat/googlechat-adapter.test.ts | 44 +++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 21d06eee5..7bc2e6b36 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -522,6 +522,50 @@ describe("createGoogleChatAdapter — sendMessage (messages.create)", () => { expect(JSON.parse(String(init.body))).toEqual({ text: "hello" }); }); + it("threads the send: {text, thread:{name}} body + messageReplyOption query when options.threadId is set", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.sendMessage("spaces/AAAA", "hi", { + threadId: "spaces/AAAA/threads/TTTT", + }); + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe("spaces/AAAA/messages/CCC"); + + const sendCall = spy.mock.calls.find(([u]) => + String(u).includes("/messages"), + ) as [string, RequestInit] | undefined; + expect(sendCall).toBeDefined(); + const [url, init] = sendCall as [string, RequestInit]; + // The thread name is a BODY value and the reply option is a QUERY param — + // the untrusted thread resource name is never interpolated into the URL path. + expect(url).toBe( + "https://chat.googleapis.com/v1/spaces/AAAA/messages?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD", + ); + expect(JSON.parse(String(init.body))).toEqual({ + text: "hi", + thread: { name: "spaces/AAAA/threads/TTTT" }, + }); + }); + + it("does not thread the send when options carries no threadId (plain {text}, un-parameterised URL)", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + await adapter.sendMessage("spaces/AAAA", "hi", { replyTo: "spaces/AAAA/messages/ZZ" }); + + const sendCall = spy.mock.calls.find(([u]) => + String(u).includes("/messages"), + ) as [string, RequestInit] | undefined; + expect(sendCall).toBeDefined(); + const [url, init] = sendCall as [string, RequestInit]; + expect(url).toBe("https://chat.googleapis.com/v1/spaces/AAAA/messages"); + expect(JSON.parse(String(init.body))).toEqual({ text: "hi" }); + }); + it("returns err on a non-ok status, logs an ERROR with errorKind+hint, and never logs the token", async () => { const { fetchImpl } = makeChatFetch({ sendStatus: 403 }); const { deps, loggerSpy } = await makeDeps({ fetchImpl }); From 07610eec019ea09c6ef5eee2a7d7d0e77c6be7ae Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:11:42 +0300 Subject: [PATCH 044/200] feat(234-02): sendMessage threads the request from options.threadId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - when options.threadId is set, POST {text, thread:{name}} to the messages URL with messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD; the platform starts a new thread when the target thread is gone (no dead-thread branch) - no threadId → the plain {text} send to the un-parameterised URL is unchanged - thread name is a body value, never interpolated into the URL path --- .../src/googlechat/googlechat-adapter.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 687d37130..84d5db2aa 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -318,20 +318,33 @@ export function createGoogleChatAdapter( async sendMessage( channelId: string, text: string, - _options?: SendMessageOptions, + options?: SendMessageOptions, ): Promise> { const tok = await tokens.getToken(CHAT_SCOPE); if (!tok.ok) return err(tok.error); // auth already logged a secret-free WARN const chatBase = deps.chatBaseUrl ?? "https://chat.googleapis.com/v1"; const doFetch = deps.fetchImpl ?? fetch; + // A reply to a threaded inbound rides its thread. The thread resource name + // is a BODY value (never interpolated into the URL path); the reply option + // is a query param. REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD makes the platform + // start a new thread when the target thread is gone rather than failing the + // send, so no dead-thread branch is needed here. + const threadName = + typeof options?.threadId === "string" && options.threadId.length > 0 + ? options.threadId + : undefined; + const url = threadName + ? `${chatBase}/${channelId}/messages?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD` + : `${chatBase}/${channelId}/messages`; + const body = threadName ? { text, thread: { name: threadName } } : { text }; const responded = await fromPromise( - doFetch(`${chatBase}/${channelId}/messages`, { + doFetch(url, { method: "POST", headers: { authorization: `Bearer ${tok.value}`, "content-type": "application/json", }, - body: JSON.stringify({ text }), + body: JSON.stringify(body), }), ); if (!responded.ok) { From d6490696e0a180ac62e1a87a962456c5500d8446 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:24:36 +0300 Subject: [PATCH 045/200] test(234-03): assert sendMessage paces per space and stop() cancels a pending pace-wait --- .../src/googlechat/googlechat-adapter.test.ts | 105 ++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 7bc2e6b36..e5543de82 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -97,6 +97,49 @@ function makeChatFetch( return { fetchImpl: spy as unknown as typeof fetch, spy }; } +/** Drain the microtask queue so an awaited async send can make progress. */ +async function flushMicrotasks(n = 40): Promise { + for (let i = 0; i < n; i += 1) await Promise.resolve(); +} + +/** + * A deterministic timer seam. It CAPTURES each scheduled delay and parks the + * callback (never firing on real time); `fireNext()` resolves the parked wait so + * an awaited pace-wait or retry backoff advances without any real wait. `cleared` + * records the handles passed to the canceller so a stop()-cancels-pace-wait + * assertion can read them. Mirrors the fake-timers `unrefRecord` intent. + */ +function makeFakeTimers() { + const delays: number[] = []; + const cleared: unknown[] = []; + let pending: Array<{ id: number; cb: () => void }> = []; + let seq = 0; + const setTimeoutImpl = ((cb: () => void, ms: number) => { + const id = (seq += 1); + delays.push(ms); + pending.push({ id, cb }); + return id; + }) as unknown as GoogleChatAdapterDeps["setTimeoutImpl"]; + const clearTimeoutImpl = ((handle: unknown) => { + cleared.push(handle); + pending = pending.filter((p) => p.id !== handle); + }) as unknown as GoogleChatAdapterDeps["clearTimeoutImpl"]; + async function fireNext(): Promise { + const next = pending.shift(); + if (!next) throw new Error("no pending timer to fire"); + next.cb(); + await flushMicrotasks(); + } + return { + setTimeoutImpl, + clearTimeoutImpl, + delays, + cleared, + fireNext, + pendingCount: () => pending.length, + }; +} + /** A fake pull-loop source recording start/stop so lifecycle is testable loop-free. */ function makeFakeSource(over: Partial = {}) { const start = vi.fn(); @@ -602,3 +645,65 @@ describe("createGoogleChatAdapter — sendMessage (messages.create)", () => { expect(adapter.getStatus?.().lastMessageAt).toBe(NOW); }); }); + +describe("createGoogleChatAdapter — per-space send pacing", () => { + it("paces two sends to the SAME space by the remaining interval, while a DIFFERENT space is unblocked", async () => { + const timers = makeFakeTimers(); + let clock = NOW; + const { fetchImpl } = makeChatFetch(); + const { deps } = await makeDeps({ + fetchImpl, + now: () => clock, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + const adapter = createGoogleChatAdapter(deps); + + // First send to spaces/AAAA: no prior write to that space → zero pace-wait, + // so no timer is scheduled. + await adapter.sendMessage("spaces/AAAA", "one"); + expect(timers.delays).toHaveLength(0); + + // 300ms later a second send to the SAME space must wait the remaining ~700ms + // of the 1s interval — asserted via the captured timer delay, never a real wait. + clock = NOW + 300; + const second = adapter.sendMessage("spaces/AAAA", "two"); + await flushMicrotasks(); + expect(timers.delays).toContain(700); + await timers.fireNext(); // release the pace-wait so the POST proceeds + await second; + + // A send to a DIFFERENT space is independent: it schedules no pace-wait. + const delaysBefore = timers.delays.length; + await adapter.sendMessage("spaces/BBBB", "b"); + expect(timers.delays.length).toBe(delaysBefore); + }); + + it("stop() cancels a pending pace-wait: the scheduled timer is cleared on shutdown (no hang)", async () => { + const timers = makeFakeTimers(); + let clock = NOW; + const { fetchImpl } = makeChatFetch(); + const { deps } = await makeDeps({ + fetchImpl, + now: () => clock, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + const adapter = createGoogleChatAdapter(deps); + + // Prime the space so a second same-space send incurs a pace-wait. + await adapter.sendMessage("spaces/AAAA", "one"); + clock = NOW + 100; + const pending = adapter.sendMessage("spaces/AAAA", "two"); + await flushMicrotasks(); + expect(timers.pendingCount()).toBe(1); // a pace-wait is parked + expect(timers.cleared).toHaveLength(0); + + // Shutdown must cancel the pending pace-wait (abort the pacer's signal). + await adapter.stop(); + await flushMicrotasks(); + expect(timers.cleared.length).toBeGreaterThan(0); + + await pending; // the send resolves after the abandoned wait + }); +}); From 8c04bc4802f8667a1eaa68d6dee91374ebea65dd Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:25:25 +0300 Subject: [PATCH 046/200] feat(234-03): pace sendMessage per space through the write pacer - Instantiate a per-space createSendPacer once on the adapter's injected clock+timer - await pacer.acquire(channelId, sendAbort.signal) before the POST - stop() aborts the shared signal so a pending pace-wait cancels on shutdown --- .../src/googlechat/googlechat-adapter.ts | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 84d5db2aa..b91176c10 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -30,7 +30,7 @@ */ import { randomUUID } from "node:crypto"; -import { runWithContext, systemNowMs } from "@comis/core"; +import { runWithContext, systemNowMs, systemSetTimeout } from "@comis/core"; import type { ChannelPort, ChannelStatus, @@ -48,6 +48,7 @@ import { type GoogleChatTokenProvider, } from "./googlechat-auth.js"; import { classifyGoogleChatError } from "./errors.js"; +import { createSendPacer } from "./send-pacer.js"; import { createPubSubSource, type PubSubSource, @@ -123,6 +124,19 @@ export function createGoogleChatAdapter( const handlers: MessageHandler[] = []; const _channelId = "googlechat"; + // Per-space write pacer: Google Chat caps message creation at one write per + // second per space, so a chunked reply that fans several sends into one space + // must space its writes or trip a 429. Built once on the adapter's injected + // clock+timer; different spaces stay independent. + const pacer = createSendPacer({ + now, + setTimeout: deps.setTimeoutImpl ?? systemSetTimeout, + ...(deps.clearTimeoutImpl && { clearTimeout: deps.clearTimeoutImpl }), + }); + // Aborted on stop() so a send parked in a pending pace-wait cancels its wait + // promptly rather than holding shutdown; abort is terminal for this instance. + const sendAbort = new AbortController(); + let _connected = false; let _startedAt: number | undefined; let _lastMessageAt: number | undefined; @@ -306,6 +320,8 @@ export function createGoogleChatAdapter( }, async stop(): Promise> { + // Cancel any pending pace-wait so a parked send does not hold shutdown. + sendAbort.abort(); await source?.stop(); _connected = false; deps.logger.info( @@ -337,6 +353,9 @@ export function createGoogleChatAdapter( ? `${chatBase}/${channelId}/messages?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD` : `${chatBase}/${channelId}/messages`; const body = threadName ? { text, thread: { name: threadName } } : { text }; + // Pace the write against the per-space 1/s ceiling before the POST. A + // pending wait cancels promptly on stop() through the shared abort signal. + await pacer.acquire(channelId, sendAbort.signal); const responded = await fromPromise( doFetch(url, { method: "POST", From 67465daee876a8b36ca4492d2588e32ed2f14edd Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:27:09 +0300 Subject: [PATCH 047/200] test(234-03): assert sendMessage auto-resends on 429 only, clamped and bounded --- .../src/googlechat/googlechat-adapter.test.ts | 167 +++++++++++++++++- 1 file changed, 163 insertions(+), 4 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index e5543de82..2febc2d17 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -75,16 +75,34 @@ function makeTokenFetch(token = MINTED_TOKEN) { * spy captures both the mint and the send. */ function makeChatFetch( - opts: { sendStatus?: number; sendName?: string; sendThrows?: boolean } = {}, + opts: { + sendStatus?: number; + sendName?: string; + sendThrows?: boolean; + /** A per-attempt status sequence for successive `/messages` hits (models a 429-then-200 resend). */ + sendStatuses?: number[]; + /** The `retry-after` header value returned on a `/messages` response. */ + retryAfter?: string; + } = {}, ) { - const sendStatus = opts.sendStatus ?? 200; const sendName = opts.sendName ?? "spaces/AAAA/messages/CCC"; + let sendHits = 0; const spy = vi.fn(async (url: string, _init?: RequestInit) => { if (String(url).includes("/messages")) { if (opts.sendThrows) throw new Error("connect ECONNREFUSED"); + // A status sequence models successive send attempts; the last entry repeats + // once the sequence is exhausted so a bounded-retry test can keep 429-ing. + const status = opts.sendStatuses + ? opts.sendStatuses[Math.min(sendHits, opts.sendStatuses.length - 1)] + : (opts.sendStatus ?? 200); + sendHits += 1; return { - ok: sendStatus >= 200 && sendStatus < 300, - status: sendStatus, + ok: status >= 200 && status < 300, + status, + headers: { + get: (name: string) => + name.toLowerCase() === "retry-after" ? (opts.retryAfter ?? null) : null, + }, json: async () => ({ name: sendName }), }; } @@ -707,3 +725,144 @@ describe("createGoogleChatAdapter — per-space send pacing", () => { await pending; // the send resolves after the abandoned wait }); }); + +describe("createGoogleChatAdapter — 429 auto-resend (send-safety)", () => { + it("resends ONLY on a 429: a 429-then-200 fires exactly 2 POSTs, returns the name, and backs off the clamped Retry-After", async () => { + const timers = makeFakeTimers(); + const { fetchImpl, spy } = makeChatFetch({ + sendStatuses: [429, 200], + retryAfter: "1", + }); + const { deps, loggerSpy } = await makeDeps({ + fetchImpl, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + const adapter = createGoogleChatAdapter(deps); + + const p = adapter.sendMessage("spaces/AAAA", "hi"); + await flushMicrotasks(); + // GREEN: a retry backoff of Retry-After*1000 (=1000ms, under the 60s cap) is + // parked after the first 429. RED: the send already returned err, nothing parked. + if (timers.pendingCount() > 0) await timers.fireNext(); + const result = await p; + + expect(result.ok).toBe(true); + if (result.ok) expect(result.value).toBe("spaces/AAAA/messages/CCC"); + const posts = spy.mock.calls.filter(([u]) => String(u).includes("/messages")); + expect(posts).toHaveLength(2); + expect(timers.delays).toContain(1000); + // No token on any retry/failure branch. + expect(loggerSpy.serialized()).not.toContain(MINTED_TOKEN); + }); + + it("falls back to capped exponential backoff on a 429 with no Retry-After header", async () => { + const timers = makeFakeTimers(); + const { fetchImpl } = makeChatFetch({ sendStatuses: [429, 200] }); + const { deps } = await makeDeps({ + fetchImpl, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + const adapter = createGoogleChatAdapter(deps); + + const p = adapter.sendMessage("spaces/AAAA", "hi"); + await flushMicrotasks(); + if (timers.pendingCount() > 0) await timers.fireNext(); + const result = await p; + + expect(result.ok).toBe(true); + // Attempt 0 backoff: RETRY_BACKOFF_BASE_MS * 2**0 = 500ms. + expect(timers.delays).toContain(500); + }); + + it("clamps a hostile Retry-After to the ceiling rather than awaiting it verbatim", async () => { + const timers = makeFakeTimers(); + const { fetchImpl } = makeChatFetch({ + sendStatuses: [429, 200], + retryAfter: "86400", + }); + const { deps } = await makeDeps({ + fetchImpl, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + const adapter = createGoogleChatAdapter(deps); + + const p = adapter.sendMessage("spaces/AAAA", "hi"); + await flushMicrotasks(); + if (timers.pendingCount() > 0) await timers.fireNext(); + const result = await p; + + expect(result.ok).toBe(true); + // RETRY_AFTER_CAP_MS is 60_000 — a 86400s header is clamped, not awaited raw. + expect(timers.delays).toContain(60_000); + expect(timers.delays).not.toContain(86_400_000); + }); + + it("bounds the retries: repeated 429s stop after the max and return err (never loops forever)", async () => { + const timers = makeFakeTimers(); + const { fetchImpl, spy } = makeChatFetch({ + sendStatuses: [429, 429, 429, 429, 429, 429], + retryAfter: "1", + }); + const { deps } = await makeDeps({ + fetchImpl, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + const adapter = createGoogleChatAdapter(deps); + + const p = adapter.sendMessage("spaces/AAAA", "hi"); + // Drive each parked retry backoff; MAX_RETRIES is 4 retries on top of the first attempt. + for (let i = 0; i < 4; i += 1) { + await flushMicrotasks(); + if (timers.pendingCount() > 0) await timers.fireNext(); + } + await flushMicrotasks(); + const result = await p; + + expect(result.ok).toBe(false); + const posts = spy.mock.calls.filter(([u]) => String(u).includes("/messages")); + expect(posts).toHaveLength(5); // 1 initial attempt + 4 bounded retries + }); + + it("does NOT resend a 5xx (non-idempotent create): one POST, err, no backoff scheduled", async () => { + const timers = makeFakeTimers(); + const { fetchImpl, spy } = makeChatFetch({ sendStatus: 500 }); + const { deps, loggerSpy } = await makeDeps({ + fetchImpl, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.sendMessage("spaces/AAAA", "hi"); + + expect(result.ok).toBe(false); + const posts = spy.mock.calls.filter(([u]) => String(u).includes("/messages")); + expect(posts).toHaveLength(1); // a 5xx may already have landed → never resent + expect(timers.delays).toHaveLength(0); // no retry backoff for a 5xx + expect(findByErrorKind(loggerSpy.error, "platform")).toBeDefined(); + expect(loggerSpy.serialized()).not.toContain(MINTED_TOKEN); + }); + + it("does NOT resend a status-less transport reject: one POST, err classified network", async () => { + const timers = makeFakeTimers(); + const { fetchImpl, spy } = makeChatFetch({ sendThrows: true }); + const { deps, loggerSpy } = await makeDeps({ + fetchImpl, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.sendMessage("spaces/AAAA", "hi"); + + expect(result.ok).toBe(false); + const posts = spy.mock.calls.filter(([u]) => String(u).includes("/messages")); + expect(posts).toHaveLength(1); // a transport fault may already have landed → never resent + expect(timers.delays).toHaveLength(0); + expect(findByErrorKind(loggerSpy.error, "network")).toBeDefined(); + }); +}); From 87b65c500b0c9cfea5dda43618f95b7098860131 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:34:33 +0300 Subject: [PATCH 048/200] feat(234-03): auto-resend sendMessage on 429 only, clamped and bounded - Wrap the POST in a bounded loop: resend ONLY on a 429 (rejects before the message lands); a 5xx or status-less transport fault surfaces on the first attempt because messages.create is non-idempotent and a resend could duplicate - Honour a clamped Retry-After (RETRY_AFTER_CAP_MS) or capped exponential backoff when the header is absent, bounded by MAX_RETRIES; backoff timer is unref'd - DEBUG-log the scheduled retry (step/status/attempt/durationMs/errorKind/hint), never the token - Drive the retry loop in tests with a real-event-loop drain so the JWT-mint crypto settles and the injected fake backoff fires with no real wait --- .../src/googlechat/googlechat-adapter.test.ts | 75 +++++--- .../src/googlechat/googlechat-adapter.ts | 162 ++++++++++++------ 2 files changed, 163 insertions(+), 74 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 2febc2d17..059eb86e3 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -158,6 +158,39 @@ function makeFakeTimers() { }; } +/** Yield to the REAL event loop (a macrotask) so genuinely-async work — e.g. the + * JWT crypto in the token mint — can advance; microtask flushing alone cannot. */ +function realTick(): Promise { + return new Promise((resolve) => setTimeout(resolve, 0)); +} + +/** + * Await a send while draining parked timers deterministically: tick the real event + * loop (so the token-mint crypto and fetch stubs settle), fire any pending backoff, + * and repeat until the send resolves. The adapter's own timer is the injected fake, + * so the retry backoff itself never waits real time — only the harness ticks do, + * and each is ~0ms. + */ +async function settleWithTimers( + p: Promise, + timers: ReturnType, +): Promise { + let settled = false; + void p.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + for (let i = 0; i < 500 && !settled; i += 1) { + await realTick(); + if (!settled && timers.pendingCount() > 0) await timers.fireNext(); + } + return p; +} + /** A fake pull-loop source recording start/stop so lifecycle is testable loop-free. */ function makeFakeSource(over: Partial = {}) { const start = vi.fn(); @@ -740,12 +773,12 @@ describe("createGoogleChatAdapter — 429 auto-resend (send-safety)", () => { }); const adapter = createGoogleChatAdapter(deps); - const p = adapter.sendMessage("spaces/AAAA", "hi"); - await flushMicrotasks(); - // GREEN: a retry backoff of Retry-After*1000 (=1000ms, under the 60s cap) is - // parked after the first 429. RED: the send already returned err, nothing parked. - if (timers.pendingCount() > 0) await timers.fireNext(); - const result = await p; + // A retry backoff of Retry-After*1000 (=1000ms, under the 60s cap) is parked + // after the first 429; draining fires it so the second attempt (200) proceeds. + const result = await settleWithTimers( + adapter.sendMessage("spaces/AAAA", "hi"), + timers, + ); expect(result.ok).toBe(true); if (result.ok) expect(result.value).toBe("spaces/AAAA/messages/CCC"); @@ -766,10 +799,10 @@ describe("createGoogleChatAdapter — 429 auto-resend (send-safety)", () => { }); const adapter = createGoogleChatAdapter(deps); - const p = adapter.sendMessage("spaces/AAAA", "hi"); - await flushMicrotasks(); - if (timers.pendingCount() > 0) await timers.fireNext(); - const result = await p; + const result = await settleWithTimers( + adapter.sendMessage("spaces/AAAA", "hi"), + timers, + ); expect(result.ok).toBe(true); // Attempt 0 backoff: RETRY_BACKOFF_BASE_MS * 2**0 = 500ms. @@ -789,10 +822,10 @@ describe("createGoogleChatAdapter — 429 auto-resend (send-safety)", () => { }); const adapter = createGoogleChatAdapter(deps); - const p = adapter.sendMessage("spaces/AAAA", "hi"); - await flushMicrotasks(); - if (timers.pendingCount() > 0) await timers.fireNext(); - const result = await p; + const result = await settleWithTimers( + adapter.sendMessage("spaces/AAAA", "hi"), + timers, + ); expect(result.ok).toBe(true); // RETRY_AFTER_CAP_MS is 60_000 — a 86400s header is clamped, not awaited raw. @@ -813,14 +846,12 @@ describe("createGoogleChatAdapter — 429 auto-resend (send-safety)", () => { }); const adapter = createGoogleChatAdapter(deps); - const p = adapter.sendMessage("spaces/AAAA", "hi"); - // Drive each parked retry backoff; MAX_RETRIES is 4 retries on top of the first attempt. - for (let i = 0; i < 4; i += 1) { - await flushMicrotasks(); - if (timers.pendingCount() > 0) await timers.fireNext(); - } - await flushMicrotasks(); - const result = await p; + // MAX_RETRIES is 4 retries on top of the first attempt; draining fires each + // parked backoff until the send gives up rather than looping forever. + const result = await settleWithTimers( + adapter.sendMessage("spaces/AAAA", "hi"), + timers, + ); expect(result.ok).toBe(false); const posts = spy.mock.calls.filter(([u]) => String(u).includes("/messages")); diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index b91176c10..3e6051db2 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -47,7 +47,7 @@ import { PUBSUB_SCOPE, type GoogleChatTokenProvider, } from "./googlechat-auth.js"; -import { classifyGoogleChatError } from "./errors.js"; +import { classifyGoogleChatError, parseRetryAfterSeconds } from "./errors.js"; import { createSendPacer } from "./send-pacer.js"; import { createPubSubSource, @@ -59,6 +59,23 @@ import { type GoogleChatEvent, } from "./message-mapper.js"; +// --------------------------------------------------------------------------- +// Send-safety knobs for the 429-only bounded resend +// --------------------------------------------------------------------------- + +/** Resends the send makes on top of the first attempt before surfacing the failure. */ +const MAX_RETRIES = 4; +/** Exponential-backoff base + ceiling used when a 429 carries no Retry-After. */ +const RETRY_BACKOFF_BASE_MS = 500; +const RETRY_BACKOFF_CAP_MS = 8_000; +/** + * Ceiling on a server-supplied Retry-After. The value is operator-untrusted, so a + * large or hostile Retry-After is clamped rather than awaited verbatim — otherwise + * the outbound send would park pending for hours, and could repeat up to + * {@link MAX_RETRIES} times. + */ +const RETRY_AFTER_CAP_MS = 60_000; + /** Dependencies for the Google Chat adapter. */ export interface GoogleChatAdapterDeps { /** The resolved service-account key JSON string (a SecretRef resolved upstream); never logged. */ @@ -340,6 +357,7 @@ export function createGoogleChatAdapter( if (!tok.ok) return err(tok.error); // auth already logged a secret-free WARN const chatBase = deps.chatBaseUrl ?? "https://chat.googleapis.com/v1"; const doFetch = deps.fetchImpl ?? fetch; + const timer = deps.setTimeoutImpl ?? systemSetTimeout; // A reply to a threaded inbound rides its thread. The thread resource name // is a BODY value (never interpolated into the URL path); the reply option // is a query param. REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD makes the platform @@ -353,61 +371,101 @@ export function createGoogleChatAdapter( ? `${chatBase}/${channelId}/messages?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD` : `${chatBase}/${channelId}/messages`; const body = threadName ? { text, thread: { name: threadName } } : { text }; - // Pace the write against the per-space 1/s ceiling before the POST. A + const init: RequestInit = { + method: "POST", + headers: { + authorization: `Bearer ${tok.value}`, + "content-type": "application/json", + }, + body: JSON.stringify(body), + }; + + // Pace the write against the per-space 1/s ceiling ONCE before the send. A // pending wait cancels promptly on stop() through the shared abort signal. await pacer.acquire(channelId, sendAbort.signal); - const responded = await fromPromise( - doFetch(url, { - method: "POST", - headers: { - authorization: `Bearer ${tok.value}`, - "content-type": "application/json", - }, - body: JSON.stringify(body), - }), - ); - if (!responded.ok) { - const c = classifyGoogleChatError(undefined, responded.error); - deps.logger.error( - { - channelType: "googlechat" as const, - hint: c.hint, - errorKind: c.errorKind, - }, - "Google Chat send failed: no response", - ); - return err(responded.error); - } - const res = responded.value; - if (!res.ok) { - const c = classifyGoogleChatError(res.status); - deps.logger.error( - { - channelType: "googlechat" as const, - status: res.status, - hint: c.hint, - errorKind: c.errorKind, - }, - "Google Chat send failed: error status", - ); - return err(new Error(`chat messages.create returned status ${res.status}`)); - } - const parsed = await fromPromise( - res.json() as Promise<{ name?: string }>, - ); - if (!parsed.ok || !parsed.value.name) { - deps.logger.error( - { - channelType: "googlechat" as const, - hint: "messages.create returned no message name", - errorKind: "platform" as const, - }, - "Google Chat send failed: unreadable response", + + // Bounded resend loop. ONLY a 429 is safe to auto-resend: rate limiting + // rejects the request BEFORE the message lands, so it definitively created + // nothing. A status-less transport fault OR a 5xx may already have created + // the message, and messages.create is non-idempotent (no client message id), + // so resending either would duplicate — both surface on the first attempt. + // The send-safety axis is deliberately narrower than the classifier's + // transience axis, which still marks 5xx/network retryable. + for (let attempt = 0; ; attempt++) { + const responded = await fromPromise(doFetch(url, init)); + if (!responded.ok) { + const c = classifyGoogleChatError(undefined, responded.error); + deps.logger.error( + { + channelType: "googlechat" as const, + hint: c.hint, + errorKind: c.errorKind, + }, + "Google Chat send failed: no response", + ); + return err(responded.error); + } + const res = responded.value; + if (!res.ok) { + if (res.status === 429 && attempt < MAX_RETRIES) { + const retryAfter = parseRetryAfterSeconds(res, now()); + const delayMs = + retryAfter !== undefined + ? Math.min(retryAfter * 1000, RETRY_AFTER_CAP_MS) + : Math.min( + RETRY_BACKOFF_BASE_MS * 2 ** attempt, + RETRY_BACKOFF_CAP_MS, + ); + const c = classifyGoogleChatError(res.status); + deps.logger.debug( + { + step: "channels-outbound", + channelType: "googlechat" as const, + status: res.status, + attempt: attempt + 1, + durationMs: delayMs, + hint: c.hint, + errorKind: c.errorKind, + }, + "Google Chat send retry scheduled after rate limiting", + ); + await new Promise((resolve) => { + const handle = timer(() => resolve(), delayMs); + handle.unref?.(); + }); + continue; + } + const c = classifyGoogleChatError(res.status); + deps.logger.error( + { + channelType: "googlechat" as const, + status: res.status, + hint: c.hint, + errorKind: c.errorKind, + }, + "Google Chat send failed: error status", + ); + return err( + new Error(`chat messages.create returned status ${res.status}`), + ); + } + const parsed = await fromPromise( + res.json() as Promise<{ name?: string }>, ); - return err(new Error("messages.create returned no name")); + if (!parsed.ok || !parsed.value.name) { + deps.logger.error( + { + channelType: "googlechat" as const, + hint: "messages.create returned no message name", + errorKind: "platform" as const, + }, + "Google Chat send failed: unreadable response", + ); + return err(new Error("messages.create returned no name")); + } + _lastMessageAt = now(); + return ok(parsed.value.name); } - _lastMessageAt = now(); - return ok(parsed.value.name); }, getStatus(): ChannelStatus { From 83f43b7514035fa2fce3166c668017bc63a43462 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:46:14 +0300 Subject: [PATCH 049/200] test(234-04): add failing editMessage patch + path-safety contract - editMessage PATCHes {text} with updateMask=text to the resource name - non-2xx PATCH returns err, logs errorKind+hint, never logs the token - unsafe messageId (empty / .. / control char) rejected before any fetch - a legitimate resource name containing / is not rejected - drop editMessage from the adapter omits list (backed as of this wave) --- .../src/googlechat/googlechat-adapter.test.ts | 87 ++++++++++++++++++- 1 file changed, 86 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 059eb86e3..fdbd9336c 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -573,7 +573,6 @@ describe("createGoogleChatAdapter — reconcile + platformAction + capability ho const { deps } = await makeDeps(); const adapter = createGoogleChatAdapter(deps) as Record; for (const method of [ - "editMessage", "deleteMessage", "onReaction", "reactToMessage", @@ -697,6 +696,92 @@ describe("createGoogleChatAdapter — sendMessage (messages.create)", () => { }); }); +describe("createGoogleChatAdapter — editMessage (messages.patch)", () => { + it("mints a chat.bot bearer and PATCHes {text} with updateMask=text to the message resource, returning ok(undefined)", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC", + "new text", + ); + + expect(result?.ok).toBe(true); + if (result?.ok) expect(result.value).toBeUndefined(); + + const patchCall = spy.mock.calls.find(([u]) => + String(u).includes("/messages"), + ) as [string, RequestInit] | undefined; + expect(patchCall).toBeDefined(); + const [url, init] = patchCall as [string, RequestInit]; + // updateMask is pinned to `text` — never `*`, which would wipe unspecified + // fields — and the resource name is the full spaces/{space}/messages/{id}. + expect(url).toBe( + "https://chat.googleapis.com/v1/spaces/AAAA/messages/CCC?updateMask=text", + ); + expect(init.method).toBe("PATCH"); + const headers = init.headers as Record; + expect(headers.authorization).toBe(`Bearer ${MINTED_TOKEN}`); + expect(headers["content-type"]).toBe("application/json"); + expect(JSON.parse(String(init.body))).toEqual({ text: "new text" }); + }); + + it("returns err on a non-2xx PATCH, logs an ERROR with errorKind+hint, and never logs the token", async () => { + const { fetchImpl } = makeChatFetch({ sendStatus: 403 }); + const { deps, loggerSpy } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC", + "denied", + ); + + expect(result?.ok).toBe(false); + const errRec = findByErrorKind(loggerSpy.error, "auth"); + expect(errRec).toBeDefined(); + expect(String(errRec?.hint).length).toBeGreaterThan(0); + expect(loggerSpy.serialized()).not.toContain(MINTED_TOKEN); + }); + + it("rejects an unsafe messageId (empty, .. traversal, or control char) BEFORE any token mint or fetch", async () => { + for (const bad of ["", "spaces/../secret", "spaces/AAAA/messages/\u0007"]) { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.("spaces/AAAA", bad, "x"); + + expect(result?.ok).toBe(false); + // The guard short-circuits before the token mint and the fetch — zero + // network calls of any kind fired for the rejected resource name. + expect(spy).not.toHaveBeenCalled(); + } + }); + + it("does NOT reject a legitimate resource name that contains '/'", async () => { + const { fetchImpl } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC", + "ok", + ); + + expect(result?.ok).toBe(true); + }); + + it("exposes editMessage as a function on the adapter handle", async () => { + const { deps } = await makeDeps(); + const adapter = createGoogleChatAdapter(deps); + expect(typeof adapter.editMessage).toBe("function"); + }); +}); + describe("createGoogleChatAdapter — per-space send pacing", () => { it("paces two sends to the SAME space by the remaining interval, while a DIFFERENT space is unblocked", async () => { const timers = makeFakeTimers(); From 7a4797a974afbb9fad0a0403503235d5222417de Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:48:40 +0300 Subject: [PATCH 050/200] feat(channels): googlechat editMessage in-place patch with path-safety guard - isSafeMessageName rejects empty / .. / control-char resource names before any token mint or fetch; path separators allowed (resource names carry /) - editMessage mints the chat.bot bearer, PATCHes {text} with updateMask=text (never *, which would wipe unspecified fields), returns Result - secret-free ERROR (errorKind+hint, never the token) on every failure branch - drop editMessage from the plugin omittedForFalseFlag map (method now ships; present-with-false-flag is gate-safe), flag stays false --- .../src/googlechat/googlechat-adapter.ts | 97 ++++++++++++++++++- .../src/googlechat/googlechat-plugin.test.ts | 11 ++- 2 files changed, 99 insertions(+), 9 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 3e6051db2..7dae39b4f 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -21,10 +21,10 @@ * shared executor concern applied to every channel, deliberately not duplicated * here. * - * Only the text-only surface is declared. Edit, delete, reaction, and attachment - * methods are OMITTED: a service-account app cannot reach those method/auth - * surfaces, so advertising them would be dishonest — the daemon capability gate - * blocks any call the (false) capability flags forbid. + * Outbound edit patches the bot's own message in place. Delete, reaction, and + * attachment methods are OMITTED: a service-account app cannot reach those + * method/auth surfaces, so advertising them would be dishonest — the daemon + * capability gate blocks any call the (false) capability flags forbid. * * @module */ @@ -76,6 +76,29 @@ const RETRY_BACKOFF_CAP_MS = 8_000; */ const RETRY_AFTER_CAP_MS = 60_000; +// --------------------------------------------------------------------------- +// Path safety for edit/delete resource names +// --------------------------------------------------------------------------- + +/** True if the id carries an ASCII control character (never valid, always dropped). */ +function hasControlChar(id: string): boolean { + for (let i = 0; i < id.length; i++) { + const code = id.charCodeAt(i); + if (code <= 0x1f || code === 0x7f) return true; + } + return false; +} + +/** + * Reject a message resource name that is empty, `..`-escaping, or carries a + * control character before it is interpolated into the edit/delete REST path. + * Path separators are allowed: a Chat message resource name is + * `spaces/{space}/messages/{id}` and legitimately contains `/`. + */ +function isSafeMessageName(id: string): boolean { + return id.length > 0 && !id.includes("..") && !hasControlChar(id); +} + /** Dependencies for the Google Chat adapter. */ export interface GoogleChatAdapterDeps { /** The resolved service-account key JSON string (a SecretRef resolved upstream); never logged. */ @@ -468,6 +491,72 @@ export function createGoogleChatAdapter( } }, + async editMessage( + _channelId: string, + messageId: string, + text: string, + _options?: SendMessageOptions, + ): Promise> { + // Guard the caller-supplied resource name before it ever reaches the token + // mint or the REST path — an unsafe name mints no bearer and fires no fetch. + if (!isSafeMessageName(messageId)) { + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "messageId must be a spaces/{space}/messages/{id} resource name without .. or control characters", + errorKind: "validation" as const, + }, + "Rejected an unsafe message resource name", + ); + return err(new Error("unsafe message resource name")); + } + const tok = await tokens.getToken(CHAT_SCOPE); + if (!tok.ok) return err(tok.error); // auth already logged a secret-free WARN + const chatBase = deps.chatBaseUrl ?? "https://chat.googleapis.com/v1"; + const doFetch = deps.fetchImpl ?? fetch; + // updateMask is pinned to `text`: a `*` mask would clear every unspecified + // field on the message rather than editing only its text. + const url = `${chatBase}/${messageId}?updateMask=text`; + const init: RequestInit = { + method: "PATCH", + headers: { + authorization: `Bearer ${tok.value}`, + "content-type": "application/json", + }, + body: JSON.stringify({ text }), + }; + const responded = await fromPromise(doFetch(url, init)); + if (!responded.ok) { + const c = classifyGoogleChatError(undefined, responded.error); + deps.logger.error( + { + channelType: "googlechat" as const, + hint: c.hint, + errorKind: c.errorKind, + }, + "Google Chat edit failed: no response", + ); + return err(responded.error); + } + const res = responded.value; + if (!res.ok) { + const c = classifyGoogleChatError(res.status); + deps.logger.error( + { + channelType: "googlechat" as const, + status: res.status, + hint: c.hint, + errorKind: c.errorKind, + }, + "Google Chat edit failed: error status", + ); + return err( + new Error(`chat messages.patch returned status ${res.status}`), + ); + } + return ok(undefined); + }, + getStatus(): ChannelStatus { return { connected: _connected, diff --git a/packages/channels/src/googlechat/googlechat-plugin.test.ts b/packages/channels/src/googlechat/googlechat-plugin.test.ts index d7fc68174..803f22f4c 100644 --- a/packages/channels/src/googlechat/googlechat-plugin.test.ts +++ b/packages/channels/src/googlechat/googlechat-plugin.test.ts @@ -152,12 +152,13 @@ describe("createGoogleChatPlugin — capability parity (every false flag omits i const plugin = createGoogleChatPlugin(deps); const adapter = plugin.adapter as Record; - // The inverse of the msteams `attachments:true → sendAttachment is a - // function` assertion: for a text-only app every advertised-false capability - // has its method absent, so the daemon capability gate (requireMethod) - // blocks the call rather than reaching an unimplemented path. + // For a text-only app these capabilities are advertised false AND their + // adapter methods are omitted, so the daemon capability gate (requireMethod) + // blocks the call rather than reaching an unimplemented path. editMessage now + // ships as a function with editMessages still false: method-present/flag-false + // is gate-safe (assertCapability blocks the false-flag RPC before requireMethod + // is reached), so it is not listed here. const omittedForFalseFlag: Record = { - editMessage: "editMessages:false", deleteMessage: "deleteMessages:false", reactToMessage: "reactions:false", removeReaction: "reactions:false", From 093643f4d8070824f732d276f5b3f24413adf5e3 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:51:54 +0300 Subject: [PATCH 051/200] test(234-04): add failing deleteMessage delete contract - deleteMessage DELETEs the message resource with no body, returns ok(undefined) - non-2xx DELETE returns err, logs errorKind+hint, never logs the token - unsafe messageId rejected before any token mint or fetch (guard reused) - drop deleteMessage from the adapter omits list (backed as of this wave) --- .../src/googlechat/googlechat-adapter.test.ts | 66 ++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index fdbd9336c..8d5ad3cff 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -573,7 +573,6 @@ describe("createGoogleChatAdapter — reconcile + platformAction + capability ho const { deps } = await makeDeps(); const adapter = createGoogleChatAdapter(deps) as Record; for (const method of [ - "deleteMessage", "onReaction", "reactToMessage", "removeReaction", @@ -782,6 +781,71 @@ describe("createGoogleChatAdapter — editMessage (messages.patch)", () => { }); }); +describe("createGoogleChatAdapter — deleteMessage (messages.delete)", () => { + it("mints a chat.bot bearer and DELETEs the message resource with no body, returning ok(undefined)", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.deleteMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC", + ); + + expect(result?.ok).toBe(true); + if (result?.ok) expect(result.value).toBeUndefined(); + + const deleteCall = spy.mock.calls.find(([u]) => + String(u).includes("/messages"), + ) as [string, RequestInit] | undefined; + expect(deleteCall).toBeDefined(); + const [url, init] = deleteCall as [string, RequestInit]; + expect(url).toBe("https://chat.googleapis.com/v1/spaces/AAAA/messages/CCC"); + expect(init.method).toBe("DELETE"); + const headers = init.headers as Record; + expect(headers.authorization).toBe(`Bearer ${MINTED_TOKEN}`); + // A delete carries no request body. + expect(init.body).toBeUndefined(); + }); + + it("returns err on a non-2xx DELETE, logs an ERROR with errorKind+hint, and never logs the token", async () => { + const { fetchImpl } = makeChatFetch({ sendStatus: 403 }); + const { deps, loggerSpy } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.deleteMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC", + ); + + expect(result?.ok).toBe(false); + const errRec = findByErrorKind(loggerSpy.error, "auth"); + expect(errRec).toBeDefined(); + expect(String(errRec?.hint).length).toBeGreaterThan(0); + expect(loggerSpy.serialized()).not.toContain(MINTED_TOKEN); + }); + + it("rejects an unsafe messageId (empty, .. traversal, or control char) BEFORE any token mint or fetch", async () => { + for (const bad of ["", "spaces/../secret", "spaces/AAAA/messages/\u0007"]) { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.deleteMessage?.("spaces/AAAA", bad); + + expect(result?.ok).toBe(false); + // The reused isSafeMessageName guard short-circuits before token+fetch. + expect(spy).not.toHaveBeenCalled(); + } + }); + + it("exposes deleteMessage as a function on the adapter handle", async () => { + const { deps } = await makeDeps(); + const adapter = createGoogleChatAdapter(deps); + expect(typeof adapter.deleteMessage).toBe("function"); + }); +}); + describe("createGoogleChatAdapter — per-space send pacing", () => { it("paces two sends to the SAME space by the remaining interval, while a DIFFERENT space is unblocked", async () => { const timers = makeFakeTimers(); From 666df1785d5d204318708cbe80b7e64d72df5a6d Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:53:24 +0300 Subject: [PATCH 052/200] feat(channels): googlechat deleteMessage removes the bot's own message - deleteMessage reuses isSafeMessageName, mints the chat.bot bearer, DELETEs the resource name with no body, returns Result - secret-free ERROR (errorKind+hint, never the token) on every failure branch - drop deleteMessage from the plugin omittedForFalseFlag map (method now ships; present-with-false-flag is gate-safe), flag stays false - module doc: edit + delete now the app-auth mutation surface --- .../src/googlechat/googlechat-adapter.ts | 61 ++++++++++++++++++- .../src/googlechat/googlechat-plugin.test.ts | 14 ++--- 2 files changed, 67 insertions(+), 8 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 7dae39b4f..da0cfec97 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -21,7 +21,7 @@ * shared executor concern applied to every channel, deliberately not duplicated * here. * - * Outbound edit patches the bot's own message in place. Delete, reaction, and + * Outbound edit and delete mutate the bot's own message in place. Reaction and * attachment methods are OMITTED: a service-account app cannot reach those * method/auth surfaces, so advertising them would be dishonest — the daemon * capability gate blocks any call the (false) capability flags forbid. @@ -557,6 +557,65 @@ export function createGoogleChatAdapter( return ok(undefined); }, + async deleteMessage( + _channelId: string, + messageId: string, + ): Promise> { + // Removes the bot's own message. The resource name is guarded before it + // reaches the token mint or the REST path, exactly as the edit path does. + if (!isSafeMessageName(messageId)) { + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "messageId must be a spaces/{space}/messages/{id} resource name without .. or control characters", + errorKind: "validation" as const, + }, + "Rejected an unsafe message resource name", + ); + return err(new Error("unsafe message resource name")); + } + const tok = await tokens.getToken(CHAT_SCOPE); + if (!tok.ok) return err(tok.error); // auth already logged a secret-free WARN + const chatBase = deps.chatBaseUrl ?? "https://chat.googleapis.com/v1"; + const doFetch = deps.fetchImpl ?? fetch; + // A delete carries no request body — the resource name is the whole request. + const url = `${chatBase}/${messageId}`; + const init: RequestInit = { + method: "DELETE", + headers: { authorization: `Bearer ${tok.value}` }, + }; + const responded = await fromPromise(doFetch(url, init)); + if (!responded.ok) { + const c = classifyGoogleChatError(undefined, responded.error); + deps.logger.error( + { + channelType: "googlechat" as const, + hint: c.hint, + errorKind: c.errorKind, + }, + "Google Chat delete failed: no response", + ); + return err(responded.error); + } + const res = responded.value; + if (!res.ok) { + const c = classifyGoogleChatError(res.status); + deps.logger.error( + { + channelType: "googlechat" as const, + status: res.status, + hint: c.hint, + errorKind: c.errorKind, + }, + "Google Chat delete failed: error status", + ); + return err( + new Error(`chat messages.delete returned status ${res.status}`), + ); + } + return ok(undefined); + }, + getStatus(): ChannelStatus { return { connected: _connected, diff --git a/packages/channels/src/googlechat/googlechat-plugin.test.ts b/packages/channels/src/googlechat/googlechat-plugin.test.ts index 803f22f4c..9924506f0 100644 --- a/packages/channels/src/googlechat/googlechat-plugin.test.ts +++ b/packages/channels/src/googlechat/googlechat-plugin.test.ts @@ -152,14 +152,14 @@ describe("createGoogleChatPlugin — capability parity (every false flag omits i const plugin = createGoogleChatPlugin(deps); const adapter = plugin.adapter as Record; - // For a text-only app these capabilities are advertised false AND their - // adapter methods are omitted, so the daemon capability gate (requireMethod) - // blocks the call rather than reaching an unimplemented path. editMessage now - // ships as a function with editMessages still false: method-present/flag-false - // is gate-safe (assertCapability blocks the false-flag RPC before requireMethod - // is reached), so it is not listed here. + // For a text-only app the reaction, history, and attachment capabilities are + // advertised false AND their adapter methods are omitted, so the daemon + // capability gate (requireMethod) blocks the call rather than reaching an + // unimplemented path. editMessage/deleteMessage now ship as functions with + // their flags still false: method-present/flag-false is gate-safe + // (assertCapability blocks the false-flag RPC before requireMethod is reached), + // so they are not listed here. const omittedForFalseFlag: Record = { - deleteMessage: "deleteMessages:false", reactToMessage: "reactions:false", removeReaction: "reactions:false", onReaction: "reactions:false", From 967b279639c9ba949890c3fa85d0882cbc2b4264 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 19:54:54 +0300 Subject: [PATCH 053/200] test(channels): googlechat reconcile ledger-dedup restart + inbound-only liveness contracts - restart-recovery pass over a committed/unknown_after_send ledger: committed rows dedup (no re-POST), unknown_after_send rows resolve to unresolved and park; zero Chat send POSTs fire during the whole pass - document that polling connectionMode + inbound-only lastInboundAt are locked by the status cases (set only on admitted inbound, never on drop or outbound) - adapter reconcileSend/getStatus unchanged (behavior already shipped) --- .../src/googlechat/googlechat-adapter.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 8d5ad3cff..1acd6e81b 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -559,6 +559,48 @@ describe("createGoogleChatAdapter — reconcile + platformAction + capability ho if (result?.ok) expect(result.value.kind).toBe("unresolved"); }); + // Inbound-only liveness (polling connectionMode + inbound-only lastInboundAt) + // is locked by the "status + lastInboundAt semantics" cases above: polling + // mode, set only on an admitted inbound, and never bumped on a gate-dropped + // inbound or an outbound send. This case locks the complementary contract — + // a restart-recovery pass over the outward-send ledger never replays a send. + it("never re-sends a committed or unknown_after_send ledger row on a simulated restart (ledger dedup + unresolved parks)", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const reconcileQuery = (contentDigest: string) => ({ + channelId: "spaces/AAAA", + contentDigest, + sentAfterMs: NOW - 60_000, + sentBeforeMs: NOW, + }); + + // A tiny in-memory model of the outward-send ledger — digest → lifecycle + // state. The LEDGER (not reconcileSend) is the exactly-once authority. + type LedgerStatus = "committed" | "unknown_after_send"; + const ledger = new Map([ + ["digest-committed", "committed"], + ["digest-unknown", "unknown_after_send"], + ]); + + // The restart-recovery pass: a committed row short-circuits (dedup — no + // re-POST); an unknown_after_send row consults reconcileSend → unresolved → + // parked, never replayed. Neither path fires a second Chat send. + for (const [digest, status] of ledger) { + if (status === "committed") continue; // ledger dedup: no re-send + const verdict = await adapter.reconcileSend?.(reconcileQuery(digest)); + expect(verdict?.ok).toBe(true); + if (verdict?.ok) expect(verdict.value.kind).toBe("unresolved"); + // unresolved → park + escalate; NEVER a replay. + } + + // No Chat send POST fired during the whole recovery pass. + expect( + spy.mock.calls.find(([u]) => String(u).includes("/messages")), + ).toBeUndefined(); + }); + it("platformAction resolves err naming the unsupported action on googlechat", async () => { const { deps } = await makeDeps(); const adapter = createGoogleChatAdapter(deps); From 3140dc32a4d729caf3e8cdb88b0ff0ebde57fd67 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:08:54 +0300 Subject: [PATCH 054/200] test(234-05): assert the flipped googlechat capability matrix + inverse gate parity - plugin deep-equal now expects editMessages/deleteMessages/threads:true (buttons stays "none") - add an inverse gate-parity case: advertised-true flags have their adapter method present - pinned cross-plugin table flips the googlechat row threads:false -> true --- .../plugin-capabilities-explicit.test.ts | 2 +- .../src/googlechat/googlechat-plugin.test.ts | 42 ++++++++++++------- 2 files changed, 28 insertions(+), 16 deletions(-) diff --git a/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts b/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts index eb4560c72..8e8f6f3d4 100644 --- a/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts +++ b/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts @@ -65,7 +65,7 @@ const EXPECTED: readonly ExpectedCaps[] = [ { dir: "irc", typing: false, threads: false, buttons: "none" }, { dir: "email", typing: false, threads: false, buttons: "none" }, { dir: "msteams", typing: true, threads: true, buttons: "adaptivecard" }, - { dir: "googlechat", typing: false, threads: false, buttons: "none" }, + { dir: "googlechat", typing: false, threads: true, buttons: "none" }, ]; /** The three fields that MUST be declared (not defaulted) per plugin. */ diff --git a/packages/channels/src/googlechat/googlechat-plugin.test.ts b/packages/channels/src/googlechat/googlechat-plugin.test.ts index 9924506f0..ace35ed79 100644 --- a/packages/channels/src/googlechat/googlechat-plugin.test.ts +++ b/packages/channels/src/googlechat/googlechat-plugin.test.ts @@ -118,21 +118,22 @@ describe("createGoogleChatPlugin — identity + adapter wrap", () => { }); }); -describe("createGoogleChatPlugin — honest text-only interim CAPABILITIES", () => { - it("declares the exact text-only feature matrix (deep-equal)", async () => { +describe("createGoogleChatPlugin — honest app-auth CAPABILITIES", () => { + it("declares the exact app-auth feature matrix (deep-equal)", async () => { const { deps } = await makeDeps(); const plugin = createGoogleChatPlugin(deps); - // Deep-equal on the DECLARED literal (not a schema-parsed shape): every - // optional feature is off and there is no button surface. + // Deep-equal on the DECLARED literal (not a schema-parsed shape): edit, + // delete, and threaded replies are on; reactions, history, attachments, and + // typing are off and there is no button surface. expect(plugin.capabilities.features).toEqual({ reactions: false, - editMessages: false, - deleteMessages: false, + editMessages: true, + deleteMessages: true, fetchHistory: false, attachments: false, typing: false, - threads: false, + threads: true, buttons: "none", }); }); @@ -146,19 +147,15 @@ describe("createGoogleChatPlugin — honest text-only interim CAPABILITIES", () }); }); -describe("createGoogleChatPlugin — capability parity (every false flag omits its method)", () => { +describe("createGoogleChatPlugin — capability parity (advertised flags match the adapter surface)", () => { it("OMITS the adapter method behind every false/none capability flag", async () => { const { deps } = await makeDeps(); const plugin = createGoogleChatPlugin(deps); const adapter = plugin.adapter as Record; - // For a text-only app the reaction, history, and attachment capabilities are - // advertised false AND their adapter methods are omitted, so the daemon - // capability gate (requireMethod) blocks the call rather than reaching an - // unimplemented path. editMessage/deleteMessage now ship as functions with - // their flags still false: method-present/flag-false is gate-safe - // (assertCapability blocks the false-flag RPC before requireMethod is reached), - // so they are not listed here. + // The reaction, history, and attachment capabilities are advertised false AND + // their adapter methods are omitted, so the daemon capability gate + // (requireMethod) blocks the call rather than reaching an unimplemented path. const omittedForFalseFlag: Record = { reactToMessage: "reactions:false", removeReaction: "reactions:false", @@ -170,6 +167,21 @@ describe("createGoogleChatPlugin — capability parity (every false flag omits i expect(typeof adapter[method]).toBe("undefined"); } }); + + it("BACKS every advertised-true flag with a present adapter method", async () => { + const { deps } = await makeDeps(); + const plugin = createGoogleChatPlugin(deps); + const adapter = plugin.adapter as Record; + + // The inverse of the omit check: editMessages/deleteMessages are advertised + // true, so their methods MUST be present — otherwise the daemon capability + // gate (requireMethod) throws when the advertised RPC is dispatched. The flag + // and the method move together. + expect(plugin.capabilities.features.editMessages).toBe(true); + expect(plugin.capabilities.features.deleteMessages).toBe(true); + expect(typeof adapter.editMessage).toBe("function"); + expect(typeof adapter.deleteMessage).toBe("function"); + }); }); describe("createGoogleChatPlugin — lifecycle delegation", () => { From 4c1793db910b07826fb33f490ba1aff80aa3d080 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:11:32 +0300 Subject: [PATCH 055/200] feat(234-05): advertise googlechat editMessages/deleteMessages/threads - flip the plugin CAPABILITIES: editMessages/deleteMessages/threads -> true - their adapter methods already ship, so the capability gate parity is honest - buttons stays "none"; reactions/fetchHistory/attachments/typing stay false - refresh the module doc + per-flag comments (drop the stale text-only claim) --- .../src/googlechat/googlechat-plugin.ts | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-plugin.ts b/packages/channels/src/googlechat/googlechat-plugin.ts index 526ded71e..6f787199f 100644 --- a/packages/channels/src/googlechat/googlechat-plugin.ts +++ b/packages/channels/src/googlechat/googlechat-plugin.ts @@ -1,19 +1,22 @@ // SPDX-License-Identifier: Apache-2.0 /** * Google Chat Channel Plugin: wraps the Google Chat adapter as a - * ChannelPluginPort with an honest, text-only capability matrix. + * ChannelPluginPort with an honest, app-auth capability matrix. * - * The adapter implements a text send/receive round-trip over Pub/Sub pull and - * nothing more, so every optional feature flag is `false` and `buttons` is + * The adapter sends and receives text over Pub/Sub pull, edits and deletes its + * own messages, and posts threaded replies, so `editMessages`, `deleteMessages`, + * and `threads` are advertised `true` and their methods are present. Reactions, + * history fetch, outbound attachments, and typing indicators are not reachable + * for a service-account app, so those flags stay `false` and `buttons` stays * `"none"`. That honesty is load-bearing: the daemon capability gate * (requireMethod) throws if a capability is advertised whose adapter method is - * omitted. Each `false`/`"none"` flag here has its method deliberately OMITTED - * from the adapter, so a forbidden call is blocked at the gate rather than - * silently reaching an unimplemented path — the honest-capability contract. + * omitted, and blocks a false-flag call before it reaches an unimplemented path. + * Every advertised-true flag has its method present; every false/none flag has + * its method deliberately OMITTED — the honest-capability contract. * * activate() delegates to adapter.start() (which opens the pull loop) and * deactivate() to adapter.stop(). The plugin returns a plain ChannelPluginPort: - * an inbound-media resolver handle is not part of the text-only surface. + * an inbound-media resolver handle is not part of this surface. * * @module */ @@ -29,17 +32,17 @@ import { type GoogleChatAdapterDeps, } from "./googlechat-adapter.js"; -/** Google Chat platform capabilities — the honest text-only interim matrix. */ +/** Google Chat platform capabilities — the honest app-auth capability matrix. */ const CAPABILITIES: ChannelCapability = { features: { reactions: false, // user-auth-only — permanently omitted - editMessages: false, // edit not implemented (method omitted so the capability gate blocks it) - deleteMessages: false, // delete not implemented + editMessages: true, // edit lands in place via a text-masked patch + deleteMessages: true, // the app can delete its own message fetchHistory: false, // admin-approval-gated attachments: false, // outbound upload is user-auth-only typing: false, // no typing API - threads: false, // threaded replies not implemented (mapper still captures threadId) - buttons: "none", // no card buttons yet — text-only + threads: true, // threaded replies route through the send path + buttons: "none", // no card-button surface }, limits: { maxMessageChars: 4000 }, replyToMetaKey: "googlechatMessageName", From 346529503ff05b724ed2b6fe9bfbd687de0e5f55 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:43:20 +0300 Subject: [PATCH 056/200] test(234): assert googlechat edit/delete reject query-string resource names The isSafeMessageName guard only blocked empty/../control-char names, so a resource name carrying its own query (spaces/AAAA/messages/CCC?updateMask=*&x=1) was interpolated ahead of the pinned ?updateMask=text and swallowed the pin, leaving updateMask=* as the sole effective mask. These cases assert edit and delete reject query/path metacharacters BEFORE any token mint or fetch. --- .../src/googlechat/googlechat-adapter.test.ts | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 1acd6e81b..086b5e6e2 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -802,6 +802,46 @@ describe("createGoogleChatAdapter — editMessage (messages.patch)", () => { } }); + it("rejects a messageId carrying query/path metacharacters (?, &, #, space) BEFORE any token mint or fetch", async () => { + for (const bad of [ + "spaces/AAAA/messages/CCC?updateMask=*", + "spaces/AAAA/messages/CCC&x=1", + "spaces/AAAA/messages/CCC#frag", + "spaces/AAAA/messages/ CCC", + ]) { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.("spaces/AAAA", bad, "x"); + + expect(result?.ok).toBe(false); + // Rejected before the token mint and the fetch — zero network calls fired. + expect(spy).not.toHaveBeenCalled(); + } + }); + + it("rejects a messageId carrying its own query string BEFORE any token mint or fetch — the pinned updateMask=text cannot be swallowed", async () => { + // The name is interpolated ahead of the pinned `?updateMask=text`, so a name + // carrying its own query (`…/CCC?updateMask=*&x=1`) would push the pin into + // `x`'s value and leave `updateMask=*` as the sole effective mask — wiping + // every unspecified field. The allowlist guard must reject it up front. + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC?updateMask=*&x=1", + "pwned", + ); + + expect(result?.ok).toBe(false); + // Rejected before the token mint and the fetch — the injected updateMask + // never reached the PATCH URL. + expect(spy).not.toHaveBeenCalled(); + }); + it("does NOT reject a legitimate resource name that contains '/'", async () => { const { fetchImpl } = makeChatFetch(); const { deps } = await makeDeps({ fetchImpl }); @@ -881,6 +921,25 @@ describe("createGoogleChatAdapter — deleteMessage (messages.delete)", () => { } }); + it("rejects a messageId carrying query/path metacharacters (?, &, #, space) BEFORE any token mint or fetch", async () => { + for (const bad of [ + "spaces/AAAA/messages/CCC?foo=bar", + "spaces/AAAA/messages/CCC&x=1", + "spaces/AAAA/messages/CCC#frag", + "spaces/AAAA/messages/ CCC", + ]) { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.deleteMessage?.("spaces/AAAA", bad); + + expect(result?.ok).toBe(false); + // The shared allowlist guard short-circuits before token+fetch. + expect(spy).not.toHaveBeenCalled(); + } + }); + it("exposes deleteMessage as a function on the adapter handle", async () => { const { deps } = await makeDeps(); const adapter = createGoogleChatAdapter(deps); From e64ee9f337127ff8ed737d0ae6abda8a82ce33b2 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:44:14 +0300 Subject: [PATCH 057/200] fix(234): allowlist the googlechat resource-name charset so a pinned query param cannot be swallowed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit editMessage interpolates the caller-supplied messageId ahead of the pinned ?updateMask=text, so a name carrying its own query string (…/CCC?updateMask=*&x=1) pushed the pin into a later key's value and left updateMask=* as the sole effective mask — a prompt-injected agent could widen the edit to a full-field patch. Replace the empty/../control-char blocklist with a strict allowlist: a valid Chat resource name is [A-Za-z0-9._/-]+ only, which rejects ?, &, #, space and control chars in one check. Retire the now-subsumed hasControlChar and sharpen the reject hint. --- .../src/googlechat/googlechat-adapter.ts | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index da0cfec97..4be92966a 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -80,23 +80,20 @@ const RETRY_AFTER_CAP_MS = 60_000; // Path safety for edit/delete resource names // --------------------------------------------------------------------------- -/** True if the id carries an ASCII control character (never valid, always dropped). */ -function hasControlChar(id: string): boolean { - for (let i = 0; i < id.length; i++) { - const code = id.charCodeAt(i); - if (code <= 0x1f || code === 0x7f) return true; - } - return false; -} - /** - * Reject a message resource name that is empty, `..`-escaping, or carries a - * control character before it is interpolated into the edit/delete REST path. - * Path separators are allowed: a Chat message resource name is - * `spaces/{space}/messages/{id}` and legitimately contains `/`. + * Reject a Chat resource name before it is interpolated into a REST path. + * + * A valid Chat resource name (`spaces/{space}/messages/{id}` or `spaces/{space}`) + * is drawn from a strict charset — letters, digits, and `._/-` only. Allowlisting + * that charset rejects every query/fragment metacharacter (`?`, `&`, `#`, space, + * control chars) in one check, so a caller-supplied name can never carry its own + * query string to defeat a pinned query parameter — the edit path pins + * `updateMask=text`, and a name like `…/CCC?updateMask=*` would otherwise widen + * it to a full-field patch. The explicit `..` check still stands because the + * charset permits `.`; `/` is legitimately part of the resource name. */ function isSafeMessageName(id: string): boolean { - return id.length > 0 && !id.includes("..") && !hasControlChar(id); + return id.length > 0 && !id.includes("..") && /^[A-Za-z0-9._/-]+$/.test(id); } /** Dependencies for the Google Chat adapter. */ @@ -503,7 +500,7 @@ export function createGoogleChatAdapter( deps.logger.warn( { channelType: "googlechat" as const, - hint: "messageId must be a spaces/{space}/messages/{id} resource name without .. or control characters", + hint: "messageId must be a spaces/{space}/messages/{id} resource name — letters, digits, and ._/- only, with no query, fragment, or traversal characters", errorKind: "validation" as const, }, "Rejected an unsafe message resource name", @@ -567,7 +564,7 @@ export function createGoogleChatAdapter( deps.logger.warn( { channelType: "googlechat" as const, - hint: "messageId must be a spaces/{space}/messages/{id} resource name without .. or control characters", + hint: "messageId must be a spaces/{space}/messages/{id} resource name — letters, digits, and ._/- only, with no query, fragment, or traversal characters", errorKind: "validation" as const, }, "Rejected an unsafe message resource name", From 765d6d788cdd2f70bbcb714714e1168b11c8125e Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:44:55 +0300 Subject: [PATCH 058/200] test(234): assert googlechat sendMessage rejects an unsafe channelId sendMessage interpolates the agent-controlled channelId into the REST path (${chatBase}/${channelId}/messages) with no guard, unlike edit/delete. A traversal such as spaces/A/../../v1beta1/spaces/B would normalise to a different Chat API path under the bot's bearer. Assert the same allowlist guard rejects it before the bearer is minted. --- .../src/googlechat/googlechat-adapter.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 086b5e6e2..1f382e636 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -735,6 +735,40 @@ describe("createGoogleChatAdapter — sendMessage (messages.create)", () => { expect(adapter.getStatus?.().lastInboundAt).toBeUndefined(); expect(adapter.getStatus?.().lastMessageAt).toBe(NOW); }); + + it("rejects an agent-supplied channelId with a .. traversal or query/path metacharacter BEFORE any token mint or fetch", async () => { + // channelId is interpolated into `${chatBase}/${channelId}/messages` and is + // agent-controlled (message.send's channel_id), so it gets the same allowlist + // guard the edit/delete resource names get — a traversal like + // spaces/A/../../v1beta1/spaces/B would otherwise normalise to a different + // Chat API path under the bot's bearer. Rejected before the bearer is minted. + for (const bad of [ + "spaces/A/../../v1beta1/spaces/B", + "spaces/AAAA?foo=bar", + "spaces/AAAA&x=1", + "spaces/AAAA messages", + ]) { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.sendMessage(bad, "hi"); + + expect(result.ok).toBe(false); + // No bearer minted, no POST fired for the unsafe space name. + expect(spy).not.toHaveBeenCalled(); + } + }); + + it("does NOT reject a legitimate space channelId", async () => { + const { fetchImpl } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.sendMessage("spaces/AAAA", "hi"); + + expect(result.ok).toBe(true); + }); }); describe("createGoogleChatAdapter — editMessage (messages.patch)", () => { From 483f6d843ca092acecc26df36c0025c8ff120e0c Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:45:16 +0300 Subject: [PATCH 059/200] fix(234): guard the agent-supplied channelId in googlechat sendMessage editMessage/deleteMessage guard their interpolated resource name but sendMessage interpolated channelId into ${chatBase}/${channelId}/messages with no check. Apply the same isSafeMessageName allowlist before the token mint, pacer, and fetch so a traversal or query metacharacter cannot redirect the write to another Chat API path under the bot's bearer. --- .../channels/src/googlechat/googlechat-adapter.ts | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 4be92966a..56da4f3e8 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -373,6 +373,21 @@ export function createGoogleChatAdapter( text: string, options?: SendMessageOptions, ): Promise> { + // Guard the agent-supplied space name before it reaches the token mint, + // the pacer, or the REST path — it is interpolated into + // `${chatBase}/${channelId}/messages`, so a traversal or query + // metacharacter would otherwise redirect the write under the bot's bearer. + if (!isSafeMessageName(channelId)) { + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "channelId must be a spaces/{space} resource name — letters, digits, and ._/- only, with no query, fragment, or traversal characters", + errorKind: "validation" as const, + }, + "Rejected an unsafe space resource name", + ); + return err(new Error("unsafe space resource name")); + } const tok = await tokens.getToken(CHAT_SCOPE); if (!tok.ok) return err(tok.error); // auth already logged a secret-free WARN const chatBase = deps.chatBaseUrl ?? "https://chat.googleapis.com/v1"; From 7f52d99e4ae9b079b2faf1098b72fb04a19d13ba Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:46:18 +0300 Subject: [PATCH 060/200] test(234): assert stop() cancels a pending googlechat 429 retry backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pace-wait is abort-aware but the 429 retry-backoff wait ignores sendAbort, so a send parked in a backoff (up to the 60s cap) is not cancelled by stop() and issues another POST when the timer fires — a message can land after the adapter was stopped. Assert stop() during the backoff clears the parked timer and returns err with no further POST. --- .../src/googlechat/googlechat-adapter.test.ts | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 1f382e636..e5d5b33a3 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -1180,4 +1180,50 @@ describe("createGoogleChatAdapter — 429 auto-resend (send-safety)", () => { expect(timers.delays).toHaveLength(0); expect(findByErrorKind(loggerSpy.error, "network")).toBeDefined(); }); + + it("stop() during a 429 retry backoff cancels the pending resend — no POST lands after shutdown", async () => { + const timers = makeFakeTimers(); + const { fetchImpl, spy } = makeChatFetch({ + sendStatuses: [200, 429, 200], + retryAfter: "1", + }); + const { deps } = await makeDeps({ + fetchImpl, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + const adapter = createGoogleChatAdapter(deps); + + // Warm the token cache with a send to a DIFFERENT space so the target send's + // token mint resolves without a real-time tick and the only parked timer is + // the retry backoff (not a pace-wait). + await settleWithTimers(adapter.sendMessage("spaces/WARM", "warm"), timers); + const postsAfterWarm = spy.mock.calls.filter(([u]) => + String(u).includes("/messages"), + ).length; + expect(postsAfterWarm).toBe(1); + + // This send hits a 429 and parks a retry backoff (Retry-After 1s, under cap). + const pending = adapter.sendMessage("spaces/AAAA", "hi"); + await flushMicrotasks(); + const postsAfterFirst = spy.mock.calls.filter(([u]) => + String(u).includes("/messages"), + ).length; + expect(postsAfterFirst).toBe(2); // the first attempt fired and got a 429 + expect(timers.pendingCount()).toBe(1); // a retry backoff is parked + expect(timers.cleared).toHaveLength(0); + + // Shutdown must cancel the parked retry backoff — mirror the abort-awareness + // the pace-wait already has, so a resend never lands after the adapter stops. + await adapter.stop(); + await flushMicrotasks(); + expect(timers.cleared.length).toBeGreaterThan(0); // the retry timer was cancelled + + const result = await pending; + expect(result.ok).toBe(false); // aborted during backoff → err, not a late send + const finalPosts = spy.mock.calls.filter(([u]) => + String(u).includes("/messages"), + ).length; + expect(finalPosts).toBe(2); // no third POST — the resend was cancelled by stop() + }); }); From 7ab19dcd14ed6ed99a056bc339d53fa9c4b3a4f8 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:47:59 +0300 Subject: [PATCH 061/200] fix(234): make the googlechat 429 retry backoff abort-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pace-wait cancels on stop() but the 429 retry-backoff wait did not observe sendAbort, so a send parked in a backoff (up to the 60s cap) issued another POST when the timer fired — a message could land after the adapter stopped. Add a shared abortableSleep (mirrors the pull source's) and check sendAbort before and after the wait, returning err on abort instead of resending. --- .../src/googlechat/googlechat-adapter.ts | 52 ++++++++++++++++--- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 56da4f3e8..36b1232c2 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -30,7 +30,12 @@ */ import { randomUUID } from "node:crypto"; -import { runWithContext, systemNowMs, systemSetTimeout } from "@comis/core"; +import { + runWithContext, + systemNowMs, + systemSetTimeout, + systemClearTimeout, +} from "@comis/core"; import type { ChannelPort, ChannelStatus, @@ -170,10 +175,36 @@ export function createGoogleChatAdapter( setTimeout: deps.setTimeoutImpl ?? systemSetTimeout, ...(deps.clearTimeoutImpl && { clearTimeout: deps.clearTimeoutImpl }), }); - // Aborted on stop() so a send parked in a pending pace-wait cancels its wait - // promptly rather than holding shutdown; abort is terminal for this instance. + // Aborted on stop() so a send parked in a pending pace-wait OR a 429 retry + // backoff cancels its wait promptly rather than holding shutdown; abort is + // terminal for a given controller instance. const sendAbort = new AbortController(); + // Resolve after `ms`, or promptly if `signal` aborts — the same abort-aware + // shape the pacer's pace-wait uses. The timer handle is unref'd so a pending + // wait never holds the event loop open at shutdown, and the abort listener is + // dropped on normal completion so it cannot accumulate across a retry loop + // that shares one signal. + function abortableSleep(ms: number, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.resolve(); + const setT = deps.setTimeoutImpl ?? systemSetTimeout; + const clearT = deps.clearTimeoutImpl ?? systemClearTimeout; + return new Promise((resolve) => { + // onAbort closes over `handle`; it only runs on the abort event, by which + // point `handle` is assigned — so the forward reference is safe. + const onAbort = (): void => { + clearT(handle); + resolve(); + }; + const handle = setT(() => { + signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + handle.unref?.(); + signal.addEventListener("abort", onAbort, { once: true }); + }); + } + let _connected = false; let _startedAt: number | undefined; let _lastMessageAt: number | undefined; @@ -392,7 +423,6 @@ export function createGoogleChatAdapter( if (!tok.ok) return err(tok.error); // auth already logged a secret-free WARN const chatBase = deps.chatBaseUrl ?? "https://chat.googleapis.com/v1"; const doFetch = deps.fetchImpl ?? fetch; - const timer = deps.setTimeoutImpl ?? systemSetTimeout; // A reply to a threaded inbound rides its thread. The thread resource name // is a BODY value (never interpolated into the URL path); the reply option // is a query param. REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD makes the platform @@ -464,10 +494,16 @@ export function createGoogleChatAdapter( }, "Google Chat send retry scheduled after rate limiting", ); - await new Promise((resolve) => { - const handle = timer(() => resolve(), delayMs); - handle.unref?.(); - }); + // The retry wait observes sendAbort so stop() cancels a parked + // resend — a non-idempotent create must not fire a POST after the + // adapter has been stopped. Bail on abort rather than continue. + if (sendAbort.signal.aborted) { + return err(new Error("send aborted during retry backoff")); + } + await abortableSleep(delayMs, sendAbort.signal); + if (sendAbort.signal.aborted) { + return err(new Error("send aborted during retry backoff")); + } continue; } const c = classifyGoogleChatError(res.status); From 723a92af67650d14b5a38a7562996840a38b8e50 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:48:47 +0300 Subject: [PATCH 062/200] test(234): assert googlechat re-paces sends after a stop()->start() cycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendAbort is built once and permanently aborted on stop(); start() does not refresh it, so after a deactivate/reactivate every pacer.acquire rides an already-aborted signal and the pace-wait short-circuits — the per-space 1/s ceiling is silently disabled. Assert a second same-space send is still paced ~700ms after a restart. --- .../src/googlechat/googlechat-adapter.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index e5d5b33a3..4b849f492 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -1041,6 +1041,36 @@ describe("createGoogleChatAdapter — per-space send pacing", () => { await pending; // the send resolves after the abandoned wait }); + + it("re-paces sends after a stop()->start() cycle — the send-abort signal is refreshed, not left aborted", async () => { + const timers = makeFakeTimers(); + let clock = NOW; + const { fetchImpl } = makeChatFetch(); + const { deps } = await makeDeps({ + fetchImpl, + now: () => clock, + setTimeoutImpl: timers.setTimeoutImpl, + clearTimeoutImpl: timers.clearTimeoutImpl, + }); + const adapter = createGoogleChatAdapter(deps); + + // deactivate -> reactivate lifecycle (config reload). stop() aborts the send + // signal; start() must install a fresh one, or every later pace-wait rides a + // stale aborted signal and the pacer short-circuits — silently unpaced. + await adapter.start(); + await adapter.stop(); + await adapter.start(); + + // First send after restart primes the space (no prior write → no wait). + await adapter.sendMessage("spaces/AAAA", "one"); + // 300ms later, a second same-space send must STILL wait the remaining ~700ms. + clock = NOW + 300; + const second = adapter.sendMessage("spaces/AAAA", "two"); + await flushMicrotasks(); + expect(timers.delays).toContain(700); // paced again — the signal was refreshed + await timers.fireNext(); // release the pace-wait so the POST proceeds + await second; + }); }); describe("createGoogleChatAdapter — 429 auto-resend (send-safety)", () => { From b98a6981ccc4057c489355ca7c8ca9328076c745 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:50:56 +0300 Subject: [PATCH 063/200] fix(234): refresh the googlechat send-abort controller on start() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sendAbort was built once and permanently aborted on stop(); start() never recreated it, so after a deactivate/reactivate every pacer.acquire rode an already-aborted signal and the pace-wait short-circuited — the per-space 1/s ceiling was silently disabled. Recreate the controller in start() (mirroring the pull source) and bind each send to the controller current at its start so a later start() cannot un-abort an in-flight resend. --- .../src/googlechat/googlechat-adapter.ts | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 36b1232c2..e6c901d24 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -176,9 +176,11 @@ export function createGoogleChatAdapter( ...(deps.clearTimeoutImpl && { clearTimeout: deps.clearTimeoutImpl }), }); // Aborted on stop() so a send parked in a pending pace-wait OR a 429 retry - // backoff cancels its wait promptly rather than holding shutdown; abort is - // terminal for a given controller instance. - const sendAbort = new AbortController(); + // backoff cancels its wait promptly rather than holding shutdown. Abort is + // terminal for a given controller instance, so start() installs a fresh one + // (see below) — otherwise a reactivated adapter would pace every send against + // an already-aborted signal. + let sendAbort = new AbortController(); // Resolve after `ms`, or promptly if `signal` aborts — the same abort-aware // shape the pacer's pace-wait uses. The timer handle is unref'd so a pending @@ -327,6 +329,11 @@ export function createGoogleChatAdapter( // call), which would double-pull the subscription and leak the old loop. if (_connected) return ok(undefined); + // A prior stop() left sendAbort aborted; install a fresh controller for + // this run so the pacer and the retry backoff are not short-circuited by a + // stale aborted signal. Mirrors the pull source recreating its controller. + sendAbort = new AbortController(); + // The token provider already parsed the service-account key once at // construction; reuse that result rather than re-parsing here. The // subscription is the only additional precondition (it is not a parse). @@ -419,6 +426,11 @@ export function createGoogleChatAdapter( ); return err(new Error("unsafe space resource name")); } + // Bind this send to the abort controller current at its start. A stop() + // aborts it (cancelling the pace-wait / retry backoff); a later start() + // installs a NEW controller for future sends and must never silently + // un-abort this in-flight one — the post-backoff resend stays cancelled. + const abortSignal = sendAbort.signal; const tok = await tokens.getToken(CHAT_SCOPE); if (!tok.ok) return err(tok.error); // auth already logged a secret-free WARN const chatBase = deps.chatBaseUrl ?? "https://chat.googleapis.com/v1"; @@ -447,7 +459,7 @@ export function createGoogleChatAdapter( // Pace the write against the per-space 1/s ceiling ONCE before the send. A // pending wait cancels promptly on stop() through the shared abort signal. - await pacer.acquire(channelId, sendAbort.signal); + await pacer.acquire(channelId, abortSignal); // Bounded resend loop. ONLY a 429 is safe to auto-resend: rate limiting // rejects the request BEFORE the message lands, so it definitively created @@ -497,11 +509,11 @@ export function createGoogleChatAdapter( // The retry wait observes sendAbort so stop() cancels a parked // resend — a non-idempotent create must not fire a POST after the // adapter has been stopped. Bail on abort rather than continue. - if (sendAbort.signal.aborted) { + if (abortSignal.aborted) { return err(new Error("send aborted during retry backoff")); } - await abortableSleep(delayMs, sendAbort.signal); - if (sendAbort.signal.aborted) { + await abortableSleep(delayMs, abortSignal); + if (abortSignal.aborted) { return err(new Error("send aborted during retry backoff")); } continue; From fea2a7ec195002cb31235b04b198b45b9dfabad5 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:51:41 +0300 Subject: [PATCH 064/200] fix(234): default the googlechat pacer's clearTimeout in production clearTimeout was forwarded to the pacer only when a test injected one, so in production an aborted pace-wait left its unref'd timer to fire as a late no-op rather than cancelling it. Default it to systemClearTimeout, matching the setTimeout default on the adjacent line and the pull source's canceller. --- packages/channels/src/googlechat/googlechat-adapter.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index e6c901d24..7608c36a1 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -173,7 +173,10 @@ export function createGoogleChatAdapter( const pacer = createSendPacer({ now, setTimeout: deps.setTimeoutImpl ?? systemSetTimeout, - ...(deps.clearTimeoutImpl && { clearTimeout: deps.clearTimeoutImpl }), + // Default the canceller too (not only when a test injects one), so an + // aborted pace-wait actively cancels its unref'd timer in production rather + // than leaving it to fire as a late no-op — mirrors the pull source. + clearTimeout: deps.clearTimeoutImpl ?? systemClearTimeout, }); // Aborted on stop() so a send parked in a pending pace-wait OR a 429 retry // backoff cancels its wait promptly rather than holding shutdown. Abort is From fe116d43897cf312a60aff8ad1250e1b50bd0b6b Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:52:41 +0300 Subject: [PATCH 065/200] test(234): assert googlechat transport-fault ERRORs carry the underlying err The no-response branches for send/edit/delete logged hint+errorKind but dropped the err, so an operator could not tell ECONNREFUSED from a DNS or TLS failure. Assert each transport-fault ERROR attaches the underlying Error while still never logging the token. --- .../src/googlechat/googlechat-adapter.test.ts | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 4b849f492..28eb23c1c 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -714,7 +714,7 @@ describe("createGoogleChatAdapter — sendMessage (messages.create)", () => { expect(loggerSpy.serialized()).not.toContain(MINTED_TOKEN); }); - it("returns err classified network when the send transport rejects", async () => { + it("returns err classified network when the send transport rejects, attaching the underlying err (never the token)", async () => { const { fetchImpl } = makeChatFetch({ sendThrows: true }); const { deps, loggerSpy } = await makeDeps({ fetchImpl }); const adapter = createGoogleChatAdapter(deps); @@ -722,7 +722,12 @@ describe("createGoogleChatAdapter — sendMessage (messages.create)", () => { const result = await adapter.sendMessage("spaces/AAAA", "hi"); expect(result.ok).toBe(false); - expect(findByErrorKind(loggerSpy.error, "network")).toBeDefined(); + const errRec = findByErrorKind(loggerSpy.error, "network"); + expect(errRec).toBeDefined(); + // The transport cause is attached so an operator can tell ECONNREFUSED from + // a DNS/TLS failure; the token lives only in init.headers, never in err. + expect(errRec?.err).toBeInstanceOf(Error); + expect(loggerSpy.serialized()).not.toContain(MINTED_TOKEN); }); it("does NOT bump lastInboundAt on an outbound send (bumps lastMessageAt only)", async () => { @@ -890,6 +895,24 @@ describe("createGoogleChatAdapter — editMessage (messages.patch)", () => { expect(result?.ok).toBe(true); }); + it("attaches the underlying err on an edit transport fault (diagnosable) while never logging the token", async () => { + const { fetchImpl } = makeChatFetch({ sendThrows: true }); + const { deps, loggerSpy } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC", + "hi", + ); + + expect(result?.ok).toBe(false); + const errRec = findByErrorKind(loggerSpy.error, "network"); + expect(errRec).toBeDefined(); + expect(errRec?.err).toBeInstanceOf(Error); + expect(loggerSpy.serialized()).not.toContain(MINTED_TOKEN); + }); + it("exposes editMessage as a function on the adapter handle", async () => { const { deps } = await makeDeps(); const adapter = createGoogleChatAdapter(deps); @@ -974,6 +997,23 @@ describe("createGoogleChatAdapter — deleteMessage (messages.delete)", () => { } }); + it("attaches the underlying err on a delete transport fault (diagnosable) while never logging the token", async () => { + const { fetchImpl } = makeChatFetch({ sendThrows: true }); + const { deps, loggerSpy } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.deleteMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC", + ); + + expect(result?.ok).toBe(false); + const errRec = findByErrorKind(loggerSpy.error, "network"); + expect(errRec).toBeDefined(); + expect(errRec?.err).toBeInstanceOf(Error); + expect(loggerSpy.serialized()).not.toContain(MINTED_TOKEN); + }); + it("exposes deleteMessage as a function on the adapter handle", async () => { const { deps } = await makeDeps(); const adapter = createGoogleChatAdapter(deps); From 22b9ad1d6ef49d41d13b26bd9548e42c2f6f52d2 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:53:37 +0300 Subject: [PATCH 066/200] fix(234): attach the underlying err to googlechat transport-fault ERRORs The no-response branches for send/edit/delete logged hint+errorKind but dropped the fetch rejection, so an operator could not distinguish ECONNREFUSED from a DNS or TLS failure. Add err (object-first, matching the handler-error branch); the token lives only in init.headers, never in the thrown error, so this stays secret-free and is redacted by Pino regardless. --- packages/channels/src/googlechat/googlechat-adapter.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 7608c36a1..3fb524589 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -478,6 +478,7 @@ export function createGoogleChatAdapter( deps.logger.error( { channelType: "googlechat" as const, + err: responded.error, hint: c.hint, errorKind: c.errorKind, }, @@ -594,6 +595,7 @@ export function createGoogleChatAdapter( deps.logger.error( { channelType: "googlechat" as const, + err: responded.error, hint: c.hint, errorKind: c.errorKind, }, @@ -653,6 +655,7 @@ export function createGoogleChatAdapter( deps.logger.error( { channelType: "googlechat" as const, + err: responded.error, hint: c.hint, errorKind: c.errorKind, }, From 322aef5bfe286350b9226966b60459dbad9053f5 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 20:54:27 +0300 Subject: [PATCH 067/200] refactor(234): rename the googlechat channel-id const to CHANNEL_ID MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit const _channelId is actually used (adapter.channelId, getStatus) yet its underscore prefix reads as 'intentionally unused' and shadowed the genuinely unused _channelId params in editMessage/deleteMessage. Rename it to CHANNEL_ID (a true constant) — same value, no behaviour change. --- packages/channels/src/googlechat/googlechat-adapter.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 3fb524589..8df8e7db8 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -164,7 +164,7 @@ export function createGoogleChatAdapter( }); const handlers: MessageHandler[] = []; - const _channelId = "googlechat"; + const CHANNEL_ID = "googlechat"; // Per-space write pacer: Google Chat caps message creation at one write per // second per space, so a chunked reply that fans several sends into one space @@ -312,7 +312,7 @@ export function createGoogleChatAdapter( } const adapter: GoogleChatAdapterHandle = { - channelId: _channelId, + channelId: CHANNEL_ID, channelType: "googlechat", onMessage(handler: MessageHandler): void { @@ -685,7 +685,7 @@ export function createGoogleChatAdapter( getStatus(): ChannelStatus { return { connected: _connected, - channelId: _channelId, + channelId: CHANNEL_ID, channelType: "googlechat", uptime: _connected && _startedAt !== undefined From 52e5031098ef3f1c076a38c0c4c396506c6f4a4a Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 21:04:48 +0300 Subject: [PATCH 068/200] docs(234): correct stale capability-matrix comment on the plugin --- packages/channels/src/googlechat/googlechat-plugin.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/googlechat-plugin.ts b/packages/channels/src/googlechat/googlechat-plugin.ts index 6f787199f..f773327a9 100644 --- a/packages/channels/src/googlechat/googlechat-plugin.ts +++ b/packages/channels/src/googlechat/googlechat-plugin.ts @@ -52,7 +52,8 @@ const CAPABILITIES: ChannelCapability = { * Create a Google Chat channel plugin wrapping the Google Chat adapter. * * activate() delegates to adapter.start() and deactivate() to adapter.stop(), - * while the plugin declares its honest text-only capability matrix. + * while the plugin declares its honest app-auth capability matrix (threaded + * replies, edit/delete; reactions, uploads, and history are omitted). */ export function createGoogleChatPlugin( deps: GoogleChatAdapterDeps, From a4c51590ac0557cd555937f761f37725cdb41fab Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 22:28:55 +0300 Subject: [PATCH 069/200] test(235-01): add failing rendered-function contract for googlechat card actions - assert GOOGLECHAT_APPROVAL_FUNCTION and the closed RENDERED_FUNCTIONS set - fails on pre-patch source (googlechat-actions module absent) --- .../src/googlechat/googlechat-actions.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-actions.test.ts diff --git a/packages/channels/src/googlechat/googlechat-actions.test.ts b/packages/channels/src/googlechat/googlechat-actions.test.ts new file mode 100644 index 000000000..65d335d86 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-actions.test.ts @@ -0,0 +1,35 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat card-action normalizer tests (the click-authorization security core). + * + * `normalizeGoogleChatCardAction` turns a verified CARD_CLICKED interaction event + * into a button-callback NormalizedMessage — or drops it, returning the reason so + * the adapter can make the security-relevant rejects observable. These tests pin + * the two trust decisions the normalizer owns (and the drop reasons) and nothing + * more: + * + * - closed method set: an invoked function outside the CLOSED rendered set never + * becomes a message — a click cannot invoke a method the bot did not render. + * - verified clicker: the sender id is the verified `event.user.name` of the + * envelope, NEVER the client-controllable `action.parameters` / + * `common.parameters` body. A forged id parameter is ignored. + * + * The default-deny decision on that clicker id and the HMAC/session/replay + * verification are downstream layers, deliberately NOT exercised here. + */ +import { describe, it, expect } from "vitest"; +import { + GOOGLECHAT_APPROVAL_FUNCTION, + RENDERED_FUNCTIONS, +} from "./googlechat-actions.js"; + +describe("googlechat card-action — rendered-function contract", () => { + it("exposes the approval function inside the closed rendered-function set", () => { + expect(RENDERED_FUNCTIONS).toContain(GOOGLECHAT_APPROVAL_FUNCTION); + expect(GOOGLECHAT_APPROVAL_FUNCTION).toBe("comis.approval.resolve"); + }); + + it("keeps the rendered-function set closed to exactly the functions the bot renders", () => { + expect(RENDERED_FUNCTIONS).toEqual([GOOGLECHAT_APPROVAL_FUNCTION]); + }); +}); From 682e8a45327af14655926c85a5bbe9d8095fcdf9 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 22:29:37 +0300 Subject: [PATCH 070/200] feat(235-01): add drift-proof googlechat rendered-function contract + types - GOOGLECHAT_APPROVAL_FUNCTION + closed RENDERED_FUNCTIONS (one shared source of truth) - CardActionDropReason / CardActionResult unions + local GoogleChatCardClickEvent shape - pure contract module: no I/O, no logger --- .../src/googlechat/googlechat-actions.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-actions.ts diff --git a/packages/channels/src/googlechat/googlechat-actions.ts b/packages/channels/src/googlechat/googlechat-actions.ts new file mode 100644 index 000000000..d04216f64 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-actions.ts @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat card-action normalizer. + * + * Turns a verified CARD_CLICKED interaction event into a button-callback + * NormalizedMessage the inbound approval path already understands — or drops it + * (returning the reason) when the event is not a well-formed, rendered card + * click, so the adapter can make the security-relevant rejects observable. + * + * Two trust decisions live here, and only these two: + * + * - the clicker identity is sourced ONLY from the verified event envelope + * (`user.name`, established when the transport verified the Pub/Sub + * subscription or the webhook JWT) — never from the client-controllable + * `action.parameters` / `common.parameters` body; and + * - the invoked function must be one this bot actually renders + * ({@link RENDERED_FUNCTIONS}) — a click naming any other method never + * becomes a message. + * + * What this module deliberately does NOT do: it never authorizes the clicker + * (that is the adapter's allowlist gate on the verified senderId) and never + * verifies the callback signature (the downstream router's constant-time HMAC / + * session / expiry / replay checks own that). The opaque callback is passed + * through unparsed as `metadata.callbackData`. + * + * @module + */ + +import type { NormalizedMessage } from "@comis/core"; + +/** + * The action-method name the approval card renders on its buttons. Shared by the + * renderer that stamps it onto the card and the validator here, so the rendered + * set and the validated set are one source of truth and cannot drift. + */ +export const GOOGLECHAT_APPROVAL_FUNCTION = "comis.approval.resolve"; + +/** + * The CLOSED set of action-method names this bot actually renders onto cards. A + * click carrying any other method is dropped before it becomes a message — a + * click cannot invoke a method that was never rendered. + */ +export const RENDERED_FUNCTIONS: readonly string[] = [GOOGLECHAT_APPROVAL_FUNCTION]; + +/** + * Why a card click did not become a message. + * + * - `ignored`: not a CARD_CLICKED event (a message or a lifecycle event) — a + * benign, expected drop the caller logs nowhere. + * - `unrendered-method`: the invoked function is outside {@link RENDERED_FUNCTIONS} + * — an arbitrary method a click could never have rendered (a crafted probe). + * - `missing-callback`: no opaque callback parameter — a malformed card click. + * - `missing-clicker`: no verified `user.name` — the clicker cannot be + * authorized downstream. + * + * The last three are security-relevant rejects the caller must make observable; + * `ignored` is not. The normalizer stays pure — it names the reason, and the + * adapter owns the logging. + */ +export type CardActionDropReason = + | "ignored" + | "unrendered-method" + | "missing-callback" + | "missing-clicker"; + +/** + * The outcome of normalizing a card click: either the button-callback message, + * or a drop carrying the {@link CardActionDropReason} so the caller can log the + * specific reject class. Discriminate on `message === null`. + */ +export type CardActionResult = + | { readonly message: NormalizedMessage; readonly reason?: undefined } + | { readonly message: null; readonly reason: CardActionDropReason }; + +/** + * Minimal CARD_CLICKED interaction-event shape — only the fields the normalizer + * reads. Deliberately loose: the platform sends many more fields we ignore. The + * invoked function and the opaque callback are each carried in two places (the + * classic `action` object and the newer `common` object); both are read so the + * normalizer is robust across delivery variants. + */ +export interface GoogleChatCardClickEvent { + /** Event kind: "CARD_CLICKED" for a button click; anything else is ignored. */ + type?: string; + /** The acting user; the ONLY source of the clicker id. */ + user?: { name?: string }; + /** The space the click happened in ("spaces/AAAA"). */ + space?: { name?: string }; + /** The clicked card message ("spaces/AAAA/messages/CCCC") — the edit target. */ + message?: { name?: string }; + /** Classic click payload: the invoked method plus a `{key,value}` parameter list. */ + action?: { + actionMethodName?: string; + parameters?: Array<{ key?: string; value?: string }>; + }; + /** Newer click payload: the same invoked method plus a keyed parameter map. */ + common?: { + invokedFunction?: string; + parameters?: Record; + }; +} From 4e06b658d114790d748094ace653571fe1aa1dc6 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 22:31:17 +0300 Subject: [PATCH 071/200] test(235-01): add failing normalizeGoogleChatCardAction behavior contract - verified-clicker (forged action.parameters id ignored), closed rendered-method drop - dual-location method/callback reads, missing-callback / missing-clicker / ignored drops - ground-truth: success message round-trips through parseMessage - fails on pre-patch source (normalizeGoogleChatCardAction absent) --- .../src/googlechat/googlechat-actions.test.ts | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-actions.test.ts b/packages/channels/src/googlechat/googlechat-actions.test.ts index 65d335d86..25d97d15b 100644 --- a/packages/channels/src/googlechat/googlechat-actions.test.ts +++ b/packages/channels/src/googlechat/googlechat-actions.test.ts @@ -18,11 +18,54 @@ * verification are downstream layers, deliberately NOT exercised here. */ import { describe, it, expect } from "vitest"; +import { parseMessage } from "@comis/core"; import { + normalizeGoogleChatCardAction, GOOGLECHAT_APPROVAL_FUNCTION, RENDERED_FUNCTIONS, + type GoogleChatCardClickEvent, } from "./googlechat-actions.js"; +/** The verified clicker id off the event envelope (a "users/{id}" resource name). */ +const CLICKER = "users/123456789"; +/** A neutral, well-formed opaque callback wire string (not a real secret). */ +const CB = "v1.approve.Abc123Def456.QWERTYuiop123456"; +const SPACE = "spaces/AAAA"; +const MSG = "spaces/AAAA/messages/CCCC"; + +interface ClickOverrides { + type?: string; + user?: { name?: string }; + space?: { name?: string }; + message?: { name?: string }; + action?: { actionMethodName?: string; parameters?: Array<{ key?: string; value?: string }> }; + common?: { invokedFunction?: string; parameters?: Record }; +} + +/** + * Build a verified CARD_CLICKED event. Every knob defaults to a valid value; + * passing a key (even set to `undefined`) overrides that slot so a test can + * express a missing type / method / callback / clicker or a forged parameter. + * The method and callback are seeded in BOTH the classic `action` object and the + * newer `common` object so a test can drop either location independently. + */ +function clickEvent(overrides: ClickOverrides = {}): GoogleChatCardClickEvent { + return { + type: "type" in overrides ? overrides.type : "CARD_CLICKED", + user: "user" in overrides ? overrides.user : { name: CLICKER }, + space: "space" in overrides ? overrides.space : { name: SPACE }, + message: "message" in overrides ? overrides.message : { name: MSG }, + action: + "action" in overrides + ? overrides.action + : { actionMethodName: GOOGLECHAT_APPROVAL_FUNCTION, parameters: [{ key: "cb", value: CB }] }, + common: + "common" in overrides + ? overrides.common + : { invokedFunction: GOOGLECHAT_APPROVAL_FUNCTION, parameters: { cb: CB } }, + }; +} + describe("googlechat card-action — rendered-function contract", () => { it("exposes the approval function inside the closed rendered-function set", () => { expect(RENDERED_FUNCTIONS).toContain(GOOGLECHAT_APPROVAL_FUNCTION); @@ -33,3 +76,140 @@ describe("googlechat card-action — rendered-function contract", () => { expect(RENDERED_FUNCTIONS).toEqual([GOOGLECHAT_APPROVAL_FUNCTION]); }); }); + +describe("normalizeGoogleChatCardAction — only CARD_CLICKED events normalize", () => { + it("drops a non-CARD_CLICKED event such as a plain message as a benign ignored drop", () => { + const result = normalizeGoogleChatCardAction(clickEvent({ type: "MESSAGE" })); + expect(result.message).toBeNull(); + expect(result.reason).toBe("ignored"); + }); + + it("drops an event carrying no type as a benign ignored drop", () => { + const result = normalizeGoogleChatCardAction(clickEvent({ type: undefined })); + expect(result.message).toBeNull(); + expect(result.reason).toBe("ignored"); + }); +}); + +describe("normalizeGoogleChatCardAction — closed rendered-method set", () => { + it("drops a click whose invoked method is outside the rendered set", () => { + const result = normalizeGoogleChatCardAction( + clickEvent({ + action: { + actionMethodName: "attacker.arbitrary.method", + parameters: [{ key: "cb", value: CB }], + }, + common: { invokedFunction: "attacker.arbitrary.method", parameters: { cb: CB } }, + }), + ); + expect(result.message).toBeNull(); + expect(result.reason).toBe("unrendered-method"); + }); + + it("drops a click that names no method at all in either location", () => { + const result = normalizeGoogleChatCardAction( + clickEvent({ + action: { parameters: [{ key: "cb", value: CB }] }, + common: { parameters: { cb: CB } }, + }), + ); + expect(result.message).toBeNull(); + expect(result.reason).toBe("unrendered-method"); + }); +}); + +describe("normalizeGoogleChatCardAction — verified clicker identity", () => { + it("keys the sender on the verified event.user.name", () => { + const { message } = normalizeGoogleChatCardAction(clickEvent()); + expect(message).not.toBeNull(); + expect(message?.senderId).toBe(CLICKER); + expect(message?.metadata.callbackData).toBe(CB); + }); + + it("ignores a forged clicker id in action.parameters and keeps the verified sender", () => { + const { message } = normalizeGoogleChatCardAction( + clickEvent({ + action: { + actionMethodName: GOOGLECHAT_APPROVAL_FUNCTION, + parameters: [ + { key: "cb", value: CB }, + { key: "userId", value: "users/attacker" }, + ], + }, + }), + ); + expect(message).not.toBeNull(); + expect(message?.senderId).toBe(CLICKER); + }); + + it("drops the click with a missing-clicker reason when user.name is absent", () => { + const result = normalizeGoogleChatCardAction(clickEvent({ user: undefined })); + expect(result.message).toBeNull(); + expect(result.reason).toBe("missing-clicker"); + }); + + it("drops the click with a missing-clicker reason when user.name is empty", () => { + const result = normalizeGoogleChatCardAction(clickEvent({ user: { name: "" } })); + expect(result.message).toBeNull(); + expect(result.reason).toBe("missing-clicker"); + }); +}); + +describe("normalizeGoogleChatCardAction — dual-location field reads", () => { + it("reads the method from common.invokedFunction when action.actionMethodName is absent", () => { + const { message } = normalizeGoogleChatCardAction( + clickEvent({ action: { parameters: [{ key: "cb", value: CB }] } }), + ); + expect(message).not.toBeNull(); + expect(message?.senderId).toBe(CLICKER); + }); + + it("reads the callback from common.parameters.cb when action.parameters is absent", () => { + const { message } = normalizeGoogleChatCardAction( + clickEvent({ action: { actionMethodName: GOOGLECHAT_APPROVAL_FUNCTION } }), + ); + expect(message).not.toBeNull(); + expect(message?.text).toBe(CB); + expect(message?.metadata.callbackData).toBe(CB); + }); +}); + +describe("normalizeGoogleChatCardAction — malformed callback", () => { + it("drops the click with a missing-callback reason when no callback parameter is present", () => { + const result = normalizeGoogleChatCardAction( + clickEvent({ + action: { actionMethodName: GOOGLECHAT_APPROVAL_FUNCTION }, + common: { invokedFunction: GOOGLECHAT_APPROVAL_FUNCTION }, + }), + ); + expect(result.message).toBeNull(); + expect(result.reason).toBe("missing-callback"); + }); +}); + +describe("normalizeGoogleChatCardAction — button-callback message shape", () => { + it("produces a schema-valid button-callback message for a rendered click", () => { + const { message } = normalizeGoogleChatCardAction(clickEvent()); + expect(message).not.toBeNull(); + expect(message?.channelType).toBe("googlechat"); + expect(message?.channelId).toBe(SPACE); + expect(message?.senderId).toBe(CLICKER); + expect(message?.text).toBe(CB); + expect(message?.metadata.isButtonCallback).toBe(true); + expect(message?.metadata.callbackData).toBe(CB); + expect(message?.metadata.googlechatMessageName).toBe(MSG); + // Ground truth: the produced message satisfies the shared NormalizedMessage schema. + expect(parseMessage(message).ok).toBe(true); + }); + + it("falls back to the sentinel space when the event carries no space name", () => { + const { message } = normalizeGoogleChatCardAction(clickEvent({ space: undefined })); + expect(message?.channelId).toBe("spaces/unknown"); + }); + + it("omits googlechatMessageName when the event carries no clicked-message name", () => { + const { message } = normalizeGoogleChatCardAction(clickEvent({ message: undefined })); + expect(message).not.toBeNull(); + expect(message?.metadata.googlechatMessageName).toBeUndefined(); + }); +}); From b76949e4b3c6d321ba20ce2f1e7f9552242a793c Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 22:32:59 +0300 Subject: [PATCH 072/200] feat(235-01): implement pure normalizeGoogleChatCardAction - clicker id sourced only from the verified event.user.name (forged params ignored) - invoked method must be in the closed RENDERED_FUNCTIONS set, else unrendered-method - opaque callback passed through as metadata.callbackData; never parsed for identity - distinct typed drop reason per reject class; pure, no I/O, no logger --- .../src/googlechat/googlechat-actions.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-actions.ts b/packages/channels/src/googlechat/googlechat-actions.ts index d04216f64..80afdf3af 100644 --- a/packages/channels/src/googlechat/googlechat-actions.ts +++ b/packages/channels/src/googlechat/googlechat-actions.ts @@ -27,6 +27,8 @@ */ import type { NormalizedMessage } from "@comis/core"; +import { systemNowMs } from "@comis/core"; +import { randomUUID } from "node:crypto"; /** * The action-method name the approval card renders on its buttons. Shared by the @@ -99,3 +101,77 @@ export interface GoogleChatCardClickEvent { parameters?: Record; }; } + +/** + * Read a named parameter value from the classic `action.parameters` list. + * + * The list is `{key,value}` entries; returns the `value` of the first entry + * whose `key` matches, or `undefined` when absent. The value is treated as an + * opaque wire string — never parsed for identity. + */ +function readParam( + parameters: ReadonlyArray<{ key?: string; value?: string }> | undefined, + key: string, +): string | undefined { + return parameters?.find((p) => p.key === key)?.value; +} + +/** + * Normalize a verified CARD_CLICKED event into a button-callback message. + * + * Returns `{ message }` on success, or `{ message: null, reason }` when the + * event is not a card click (`ignored`), the invoked function is not in + * {@link RENDERED_FUNCTIONS} (`unrendered-method`), the opaque callback is + * missing (`missing-callback`), or the verified `user.name` is absent + * (`missing-clicker`). The reason lets the caller log the security-relevant + * rejects while this function stays a pure mapper. + * + * @param event - A verified Google Chat interaction event + * @returns A button-callback message, or a drop carrying its reason + */ +export function normalizeGoogleChatCardAction( + event: GoogleChatCardClickEvent, +): CardActionResult { + if (event?.type !== "CARD_CLICKED") { + return { message: null, reason: "ignored" }; + } + + // The invoked function must be one the bot rendered; both the classic and the + // newer field carry it. An unknown or absent method never becomes a message. + const fn = event.action?.actionMethodName ?? event.common?.invokedFunction; + if (fn === undefined || !RENDERED_FUNCTIONS.includes(fn)) { + return { message: null, reason: "unrendered-method" }; + } + + // The opaque callback wire string, passed through unparsed — verified + // downstream. Read from either payload location. + const cb = + readParam(event.action?.parameters, "cb") ?? event.common?.parameters?.cb; + if (typeof cb !== "string" || cb.length === 0) { + return { message: null, reason: "missing-callback" }; + } + + // The clicker id is the verified envelope id — never anything under + // `action.parameters` / `common.parameters` (client-controllable). + const senderId = event.user?.name; + if (senderId === undefined || senderId.length === 0) { + return { message: null, reason: "missing-clicker" }; + } + + return { + message: { + id: randomUUID(), + channelType: "googlechat", + channelId: event.space?.name ?? "spaces/unknown", + senderId, + text: cb, + timestamp: systemNowMs(), + attachments: [], + metadata: { + isButtonCallback: true, + callbackData: cb, + ...(event.message?.name ? { googlechatMessageName: event.message.name } : {}), + }, + }, + }; +} From 76f79ff00ebb88d76a88413e956d25fd790b1a71 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 22:44:24 +0300 Subject: [PATCH 073/200] test(235-02): add failing test for cardsv2 buttons enum member - Extend the accepts-every-buttons-enum-member loop with "cardsv2" - Add a targeted assertion that the buttons flavour parses to "cardsv2" - Fails on the current enum (rejects "cardsv2"); reject-unknown case stays green --- packages/core/src/domain/channel-capability.test.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/packages/core/src/domain/channel-capability.test.ts b/packages/core/src/domain/channel-capability.test.ts index 490483517..9a267f1e6 100644 --- a/packages/core/src/domain/channel-capability.test.ts +++ b/packages/core/src/domain/channel-capability.test.ts @@ -41,6 +41,7 @@ describe("ChannelCapability feature flags", () => { "quickreply", "none", "adaptivecard", + "cardsv2", ] as const) { const cap = ChannelCapabilitySchema.parse({ features: { buttons }, @@ -50,6 +51,14 @@ describe("ChannelCapability feature flags", () => { } }); + it("accepts the cardsv2 buttons flavour", () => { + const cap = ChannelCapabilitySchema.parse({ + features: { buttons: "cardsv2" }, + limits: { maxMessageChars: 4096 }, + }); + expect(cap.features.buttons).toBe("cardsv2"); + }); + it("rejects an unknown buttons value", () => { const result = ChannelCapabilitySchema.safeParse({ features: { buttons: "bogus" }, From 1e010e5a222b5be7a9b8480cdbc510330261b679 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 22:46:11 +0300 Subject: [PATCH 074/200] feat(235-02): add cardsv2 to the ChannelCapability buttons enum - Append "cardsv2" to the buttons z.enum (additive; no reorder, default unchanged) - Extend the doc comment to list the Cards v2 widget button surface - Enum stays closed: unknown values still rejected at parse time --- packages/core/src/domain/channel-capability.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/core/src/domain/channel-capability.ts b/packages/core/src/domain/channel-capability.ts index 0cbc39302..6ea102628 100644 --- a/packages/core/src/domain/channel-capability.ts +++ b/packages/core/src/domain/channel-capability.ts @@ -22,11 +22,12 @@ const ChannelFeaturesSchema = z.strictObject({ /** Whether the channel supports threads/topics (activity strategy hint). */ threads: z.boolean().default(false), /** Interactive-button capability flavour for this channel: one of - * "inline", "components", "blockkit", "quickreply", "adaptivecard", or - * "none" when the platform has no button surface. The default exists only - * as a safety net for *new* plugins; in-tree plugins declare it explicitly. */ + * "inline", "components", "blockkit", "quickreply", "adaptivecard", + * "cardsv2" (the Cards v2 widget button surface), or "none" when the + * platform has no button surface. The default exists only as a safety net + * for *new* plugins; in-tree plugins declare it explicitly. */ buttons: z - .enum(["inline", "components", "blockkit", "quickreply", "none", "adaptivecard"]) + .enum(["inline", "components", "blockkit", "quickreply", "none", "adaptivecard", "cardsv2"]) .default("none"), }); From b65e03d6b2b0e19db46f45f8f68fb9feef685dbd Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:01:07 +0300 Subject: [PATCH 075/200] test(235-03): add failing test for Google Chat cards v2 renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Per-widget-kind assertions: title/description/image → textParagraph + image widgets; fields degrade to a single textParagraph - Interactive callback_data button stamps the shared approval function on onClick.action + rides the opaque callback as a cb parameter - url → onClick.openLink; static → plain button; interactive wins over url - Nested card buttons fold into the card section (never silently dropped) - HTML-subset escaping guard; RichEffect never an input; color accent dropped --- .../googlechat-rich-renderer.test.ts | 261 ++++++++++++++++++ 1 file changed, 261 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-rich-renderer.test.ts diff --git a/packages/channels/src/googlechat/googlechat-rich-renderer.test.ts b/packages/channels/src/googlechat/googlechat-rich-renderer.test.ts new file mode 100644 index 000000000..ec9dd04e7 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-rich-renderer.test.ts @@ -0,0 +1,261 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { renderGoogleChatButtons, renderGoogleChatCards } from "./googlechat-rich-renderer.js"; +import { GOOGLECHAT_APPROVAL_FUNCTION } from "./googlechat-actions.js"; +import type { RichButton, RichCard } from "@comis/core"; + +// A representative signed callback wire (v1...) — neutral literal. +const SIGNED_CB = "v1.approve.Abc123Def456.QWERTYuiop123456"; + +/** The widgets of the first section of a single cardsV2 entry. */ +function widgetsOf(entry: Record): Record[] { + const card = entry.card as Record; + const sections = card.sections as Record[]; + return sections[0]!.widgets as Record[]; +} + +/** The buttons of a buttonList widget. */ +function buttonsOf(widget: Record): Record[] { + const list = widget.buttonList as Record; + return list.buttons as Record[]; +} + +// Type-level guard: the renderer accepts ONLY cards / button rows. RichEffect is +// not an input to either export — a spoiler/silent effect can never be emitted +// because it can never be passed in. +const _cardsSig: (cards: RichCard[]) => Record[] = renderGoogleChatCards; +const _buttonsSig: (buttons: RichButton[][]) => Record = renderGoogleChatButtons; +void _cardsSig; +void _buttonsSig; + +describe("renderGoogleChatCards cardsV2 envelope", () => { + it("wraps each card in a cardsV2 entry with a non-empty cardId and one section of widgets", () => { + const cardsV2 = renderGoogleChatCards([{ title: "T" }]); + + expect(cardsV2).toHaveLength(1); + expect(typeof cardsV2[0]!.cardId).toBe("string"); + expect((cardsV2[0]!.cardId as string).length).toBeGreaterThan(0); + const card = cardsV2[0]!.card as Record; + const sections = card.sections as Record[]; + expect(Array.isArray(sections)).toBe(true); + expect(Array.isArray(sections[0]!.widgets)).toBe(true); + }); + + it("gives each card its own cardsV2 entry with a distinct cardId", () => { + const cardsV2 = renderGoogleChatCards([{ title: "A" }, { title: "B" }]); + + expect(cardsV2).toHaveLength(2); + expect(cardsV2[0]!.cardId).not.toBe(cardsV2[1]!.cardId); + }); +}); + +describe("renderGoogleChatCards widgets per field", () => { + it("renders the title as a bolded textParagraph (HTML subset)", () => { + const widgets = widgetsOf(renderGoogleChatCards([{ title: "Approval required" }])[0]!); + + expect(widgets).toContainEqual({ textParagraph: { text: "Approval required" } }); + }); + + it("renders the description as a plain textParagraph", () => { + const widgets = widgetsOf(renderGoogleChatCards([{ description: "Do the thing" }])[0]!); + + expect(widgets).toContainEqual({ textParagraph: { text: "Do the thing" } }); + }); + + it("renders image_url as an image widget with imageUrl and altText", () => { + const cards: RichCard[] = [{ title: "Pic", image_url: "https://example.com/y.png" }]; + const widgets = widgetsOf(renderGoogleChatCards(cards)[0]!); + + expect(widgets).toContainEqual({ + image: { imageUrl: "https://example.com/y.png", altText: "Pic" }, + }); + }); + + it("falls back the image altText to a neutral label when the card has no title", () => { + const widgets = widgetsOf( + renderGoogleChatCards([{ image_url: "https://example.com/y.png" }])[0]!, + ); + const image = widgets.find((w) => "image" in w)!.image as Record; + + expect(image.altText).toBe("image"); + }); + + it("degrades card fields to a single textParagraph (no FactSet in the minimal set)", () => { + const cards: RichCard[] = [ + { + fields: [ + { name: "Tool", value: "bash" }, + { name: "Risk", value: "high" }, + ], + }, + ]; + const widgets = widgetsOf(renderGoogleChatCards(cards)[0]!); + const paragraph = widgets.find((w) => "textParagraph" in w)!.textParagraph as { + text: string; + }; + + expect(paragraph.text).toBe("Tool: bash
Risk: high"); + }); +}); + +describe("renderGoogleChatCards card-text escaping (HTML-subset injection guard)", () => { + it("escapes &, <, > in the title before wrapping it in the bold tag", () => { + const widgets = widgetsOf(renderGoogleChatCards([{ title: "a&c" }])[0]!); + + expect(widgets).toContainEqual({ textParagraph: { text: "a<b>&c" } }); + }); + + it("escapes &, <, > in the description so agent text cannot inject card markup", () => { + const widgets = widgetsOf( + renderGoogleChatCards([{ description: "x & " }])[0]!, + ); + + expect(widgets).toContainEqual({ + textParagraph: { text: "<i>x</i> & <script>y</script>" }, + }); + }); + + it("escapes field names and values against the HTML subset", () => { + const cards: RichCard[] = [{ fields: [{ name: "K", value: "v&w" }] }]; + const paragraph = ( + widgetsOf(renderGoogleChatCards(cards)[0]!).find((w) => "textParagraph" in w)! + .textParagraph as { text: string } + ).text; + + expect(paragraph).toBe("K<x>: v&w"); + }); +}); + +describe("renderGoogleChatButtons buttonList widget", () => { + it("returns a single buttonList widget wrapping the button rows", () => { + const widget = renderGoogleChatButtons([[{ text: "A" }]]); + + expect(Object.keys(widget)).toEqual(["buttonList"]); + expect(buttonsOf(widget)).toHaveLength(1); + }); + + it("maps an interactive callback_data button to onClick.action stamping the shared function + cb param", () => { + const buttons: RichButton[][] = [[{ text: "Approve", callback_data: SIGNED_CB }]]; + const btn = buttonsOf(renderGoogleChatButtons(buttons))[0]!; + + expect(btn).toEqual({ + text: "Approve", + onClick: { + action: { + function: GOOGLECHAT_APPROVAL_FUNCTION, + parameters: [{ key: "cb", value: SIGNED_CB }], + }, + }, + }); + }); + + it("stamps the function that equals the normalizer's shared approval function (no drift)", () => { + const buttons: RichButton[][] = [ + [{ text: "Deny", callback_data: "v1.deny.Abc123Def456.ZXCVbnmasdfghjkl0" }], + ]; + const btn = buttonsOf(renderGoogleChatButtons(buttons))[0]!; + const action = (btn.onClick as Record).action as Record; + + expect(action.function).toBe(GOOGLECHAT_APPROVAL_FUNCTION); + }); + + it("maps a url button to onClick.openLink carrying the destination url", () => { + const buttons: RichButton[][] = [[{ text: "Docs", url: "https://example.com" }]]; + const btn = buttonsOf(renderGoogleChatButtons(buttons))[0]!; + + expect(btn).toEqual({ + text: "Docs", + onClick: { openLink: { url: "https://example.com" } }, + }); + }); + + it("maps a static button (no callback_data, no url) to a plain button with no onClick", () => { + const btn = buttonsOf(renderGoogleChatButtons([[{ text: "Refresh" }]]))[0]!; + + expect(btn).toEqual({ text: "Refresh" }); + expect(btn.onClick).toBeUndefined(); + }); + + it("prefers the interactive action form when a button carries BOTH callback_data and url", () => { + const buttons: RichButton[][] = [ + [{ text: "Both", callback_data: SIGNED_CB, url: "https://example.com" }], + ]; + const btn = buttonsOf(renderGoogleChatButtons(buttons))[0]!; + const onClick = btn.onClick as Record; + + expect("action" in onClick).toBe(true); + expect("openLink" in onClick).toBe(false); + }); + + it("flattens multiple button rows into one buttonList in order", () => { + const buttons: RichButton[][] = [ + [{ text: "A", callback_data: "cb-a" }], + [ + { text: "B", url: "https://example.com/b" }, + { text: "C" }, + ], + ]; + const btns = buttonsOf(renderGoogleChatButtons(buttons)); + + expect(btns).toHaveLength(3); + expect(btns.map((b) => b.text)).toEqual(["A", "B", "C"]); + }); +}); + +describe("renderGoogleChatCards nested card buttons", () => { + it("folds a card's own button rows into its section as a buttonList (never silently dropped)", () => { + const cards: RichCard[] = [ + { + title: "Choose", + buttons: [[{ text: "Yes", callback_data: SIGNED_CB }, { text: "No" }]], + }, + ]; + const widgets = widgetsOf(renderGoogleChatCards(cards)[0]!); + const buttonList = widgets.find((w) => "buttonList" in w); + + expect(buttonList).toBeDefined(); + const btns = buttonsOf(buttonList!); + expect(btns.map((b) => b.text)).toEqual(["Yes", "No"]); + const yesAction = (btns[0]!.onClick as Record).action as Record< + string, + unknown + >; + expect(yesAction.function).toBe(GOOGLECHAT_APPROVAL_FUNCTION); + expect(btns[1]!.onClick).toBeUndefined(); + }); + + it("omits the buttonList widget entirely for a card with no buttons", () => { + const widgets = widgetsOf(renderGoogleChatCards([{ title: "T" }])[0]!); + + expect(widgets.some((w) => "buttonList" in w)).toBe(false); + }); +}); + +describe("renderGoogleChat effect + unsupported degradation", () => { + it("never emits a spoiler or silent effect field in the rendered output", () => { + const cardsSerialized = JSON.stringify(renderGoogleChatCards([{ title: "T", description: "D" }])); + const buttonsSerialized = JSON.stringify( + renderGoogleChatButtons([[{ text: "Go", callback_data: SIGNED_CB }]]), + ); + + expect(cardsSerialized).not.toContain("spoiler"); + expect(cardsSerialized).not.toContain("silent"); + expect(buttonsSerialized).not.toContain("spoiler"); + expect(buttonsSerialized).not.toContain("silent"); + }); + + it("sends the outbound action field (function), never the inbound receive field name", () => { + const serialized = JSON.stringify( + renderGoogleChatButtons([[{ text: "Approve", callback_data: SIGNED_CB }]]), + ); + + expect(serialized).toContain("function"); + expect(serialized).not.toContain("actionMethodName"); + }); + + it("drops the card color accent (no cardsV2 widget equivalent) rather than emitting it", () => { + const serialized = JSON.stringify(renderGoogleChatCards([{ title: "T", color: 0x0099ff }])); + + expect(serialized).not.toContain('"color"'); + }); +}); From de5be6633e716c8e2c2080961911e597a96f05e0 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:03:13 +0300 Subject: [PATCH 076/200] feat(235-03): render Google Chat cards v2 from rich cards and buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - renderGoogleChatCards: one cardsV2 entry per card (unique cardId, one section) with textParagraph/image widgets; a card's own button rows fold into its section as a buttonList so they are never silently dropped - renderGoogleChatButtons: one buttonList widget from flattened button rows - Interactive buttons stamp the shared approval function on onClick.action (rendered set == validated set) and ride the opaque callback as a cb param; url → onClick.openLink; static → plain button; interactive wins over url - Card text escaped against the basic-HTML subset before tag wrapping; fields degrade to a textParagraph; color accent and RichEffect are never emitted --- .../googlechat/googlechat-rich-renderer.ts | 139 ++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-rich-renderer.ts diff --git a/packages/channels/src/googlechat/googlechat-rich-renderer.ts b/packages/channels/src/googlechat/googlechat-rich-renderer.ts new file mode 100644 index 000000000..ad7703364 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-rich-renderer.ts @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat rich renderer: pure functions mapping domain rich types to + * Cards v2 widget JSON. + * + * Converts `RichCard[]` into `cardsV2` entries (one card per entry, a single + * section of widgets) and `RichButton[][]` into a `buttonList` widget. + * Interactive buttons (`callback_data`) stamp the shared approval function on + * `onClick.action.function` — the SAME constant the inbound normalizer + * validates, so the rendered function set and the validated set cannot drift — + * and ride the opaque signed callback as an `onClick.action.parameters` entry. + * `url` buttons become `onClick.openLink`; a button with neither is a plain + * button. + * + * `RichEffect` ("spoiler"/"silent") has no Cards v2 equivalent and is not an + * input here — it is dropped upstream. Fields with no widget equivalent (the + * key/value list, the color accent) degrade to a text paragraph or are omitted. + * + * Card text is emitted in the Cards v2 basic-HTML subset (``, `
`), so + * agent-supplied text is escaped against that subset before it is placed inside + * a tag — user text can never inject card markup. Message-body text is a + * separate surface handled by the message-text formatter, not this module. + * + * Pure functions, no side effects, no I/O — fully testable without network. The + * outbound action field is `onClick.action.function`; the distinct inbound + * receive field is the normalizer's concern — the two directions are never + * conflated here. + * + * @module + */ + +import type { RichButton, RichCard } from "@comis/core"; +import { randomUUID } from "node:crypto"; +import { GOOGLECHAT_APPROVAL_FUNCTION } from "./googlechat-actions.js"; + +/** + * Escape agent text against the Cards v2 basic-HTML subset so it cannot inject + * tags when placed inside a `textParagraph`. `&` is escaped first so an already + * escaped entity is never doubled. + */ +function escapeCardText(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">"); +} + +/** + * Build the widgets for one card: a bolded title paragraph, a plain description + * paragraph, an image, and a single paragraph folding the key/value fields — + * each emitted only when its field is present. The domain `color` integer accent + * has no widget equivalent and is dropped. + */ +function cardToWidgets(card: RichCard): Record[] { + const widgets: Record[] = []; + + if (card.title !== undefined) { + widgets.push({ textParagraph: { text: `${escapeCardText(card.title)}` } }); + } + if (card.description !== undefined) { + widgets.push({ textParagraph: { text: escapeCardText(card.description) } }); + } + if (card.image_url !== undefined) { + widgets.push({ image: { imageUrl: card.image_url, altText: card.title ?? "image" } }); + } + if (card.fields !== undefined && card.fields.length > 0) { + const text = card.fields + .map((f) => `${escapeCardText(f.name)}: ${escapeCardText(f.value)}`) + .join("
"); + widgets.push({ textParagraph: { text } }); + } + + return widgets; +} + +/** + * Map one domain button to its Cards v2 button by discriminant: + * - `callback_data` present → an interactive `onClick.action` stamping the + * shared approval function (so the rendered and validated function sets + * cannot drift) plus the opaque signed callback as a `cb` parameter; + * - `url` present → an `onClick.openLink`; + * - neither → a plain button with no `onClick`. + * + * An interactive button wins over a link when a button carries both, mirroring + * the interactive-first precedence of the other card-based channels. + */ +function buttonToWidget(btn: RichButton): Record { + if (btn.callback_data !== undefined) { + return { + text: btn.text, + onClick: { + action: { + function: GOOGLECHAT_APPROVAL_FUNCTION, + parameters: [{ key: "cb", value: btn.callback_data }], + }, + }, + }; + } + if (btn.url !== undefined) { + return { text: btn.text, onClick: { openLink: { url: btn.url } } }; + } + return { text: btn.text }; +} + +/** Wrap flattened button rows in one `buttonList` widget. */ +function buttonListWidget(rows: RichButton[][]): Record { + return { buttonList: { buttons: rows.flat().map(buttonToWidget) } }; +} + +/** + * Render `RichCard[]` into the `cardsV2` array for a Chat message body. + * + * Each card becomes one `cardsV2` entry with a unique `cardId` and a single + * section of widgets. A card's own `buttons` rows fold into that section as a + * trailing `buttonList` widget so they are never silently dropped. + * + * @param cards - Card bodies (title/description/image/fields/buttons) + * @returns The `cardsV2` array (each entry `{ cardId, card: { sections } }`) + */ +export function renderGoogleChatCards(cards: RichCard[]): Record[] { + return cards.map((card) => { + const widgets = cardToWidgets(card); + if (card.buttons !== undefined && card.buttons.length > 0) { + widgets.push(buttonListWidget(card.buttons)); + } + return { cardId: randomUUID(), card: { sections: [{ widgets }] } }; + }); +} + +/** + * Render top-level `RichButton[][]` into one `buttonList` widget. + * + * The rows are flattened into a single list; the adapter places the widget in + * the outbound card body. Interactive buttons stamp the shared approval function + * and ride their opaque callback as a parameter (see {@link renderGoogleChatCards}). + * + * @param buttons - Button rows + * @returns One `buttonList` widget object + */ +export function renderGoogleChatButtons(buttons: RichButton[][]): Record { + return buttonListWidget(buttons); +} From 7412c08791f80f2d4f73465678a7e8e690e6614c Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:04:32 +0300 Subject: [PATCH 077/200] test(235-03): add failing test for Google Chat message-text formatter - Plain markup-free text (incl. newlines/whitespace) returned byte-identical - Slack-mrkdwn-shaped markers (*bold*, _italic_, `code`, ~strike~) pass through - Link tokens (, ) and mentions () preserved - Stray &, <, > escaped so agent text cannot open an HTML tag; an injected tag is escaped, a valid link token in the same string is preserved - escapeGoogleChatText raw helper escapes unconditionally (no token preservation) --- .../src/googlechat/format-googlechat.test.ts | 101 ++++++++++++++++++ 1 file changed, 101 insertions(+) create mode 100644 packages/channels/src/googlechat/format-googlechat.test.ts diff --git a/packages/channels/src/googlechat/format-googlechat.test.ts b/packages/channels/src/googlechat/format-googlechat.test.ts new file mode 100644 index 000000000..b93e88d90 --- /dev/null +++ b/packages/channels/src/googlechat/format-googlechat.test.ts @@ -0,0 +1,101 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, expect, it } from "vitest"; +import { escapeGoogleChatText, formatGoogleChatText } from "./format-googlechat.js"; + +describe("format-googlechat", () => { + describe("formatGoogleChatText — plain-text passthrough (load-bearing)", () => { + it("returns markup-free plain text byte-identical", () => { + expect(formatGoogleChatText("hello world")).toBe("hello world"); + }); + + it("returns an empty string unchanged", () => { + expect(formatGoogleChatText("")).toBe(""); + }); + + it("leaves newlines and surrounding whitespace byte-identical", () => { + expect(formatGoogleChatText("line one\nline two\n")).toBe("line one\nline two\n"); + }); + }); + + describe("formatGoogleChatText — Slack-mrkdwn-shaped markers pass through", () => { + it("preserves bold markers (*text*)", () => { + expect(formatGoogleChatText("*bold*")).toBe("*bold*"); + }); + + it("preserves italic markers (_text_)", () => { + expect(formatGoogleChatText("_italic_")).toBe("_italic_"); + }); + + it("preserves inline code markers (`text`)", () => { + expect(formatGoogleChatText("`code`")).toBe("`code`"); + }); + + it("preserves strikethrough markers (~text~)", () => { + expect(formatGoogleChatText("~strike~")).toBe("~strike~"); + }); + + it("preserves a multi-marker line byte-identical when it has no stray brackets", () => { + expect(formatGoogleChatText("*b* and _i_ and `c` and ~s~")).toBe( + "*b* and _i_ and `c` and ~s~", + ); + }); + }); + + describe("formatGoogleChatText — link and mention tokens are preserved", () => { + it("preserves a hyperlink token ", () => { + expect(formatGoogleChatText("See ")).toBe( + "See ", + ); + }); + + it("preserves a bare https link token", () => { + expect(formatGoogleChatText("Visit ")).toBe( + "Visit ", + ); + }); + + it("preserves a user mention token ", () => { + expect(formatGoogleChatText("Hi ")).toBe("Hi "); + }); + }); + + describe("formatGoogleChatText — stray HTML-significant characters are escaped", () => { + it("escapes a stray less-than so agent text cannot open a tag", () => { + expect(formatGoogleChatText("a < b")).toBe("a < b"); + }); + + it("escapes a stray greater-than", () => { + expect(formatGoogleChatText("a > b")).toBe("a > b"); + }); + + it("escapes a stray ampersand", () => { + expect(formatGoogleChatText("Tom & Jerry")).toBe("Tom & Jerry"); + }); + + it("escapes an unrecognized angle-bracket token (an injected tag)", () => { + expect(formatGoogleChatText("")).toBe( + "<script>alert(1)</script>", + ); + }); + + it("escapes stray brackets while preserving a valid link token in the same string", () => { + expect(formatGoogleChatText("5 < 6 see ")).toBe( + "5 < 6 see ", + ); + }); + }); + + describe("escapeGoogleChatText — raw escaping helper", () => { + it("escapes &, <, > to HTML entities", () => { + expect(escapeGoogleChatText("a & b < c > d")).toBe("a & b < c > d"); + }); + + it("returns text with no special characters byte-identical", () => { + expect(escapeGoogleChatText("plain text")).toBe("plain text"); + }); + + it("escapes an angle-bracket token unconditionally (no token preservation)", () => { + expect(escapeGoogleChatText("")).toBe("<https://x.com>"); + }); + }); +}); From a9874f25758a9cfe742ef2dbaa85a28e50490775 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:06:33 +0300 Subject: [PATCH 078/200] feat(235-03): format Google Chat message text with a conservative escape boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - formatGoogleChatText: escape stray &, <, > so agent text cannot open an HTML tag, while preserving // tokens and every Slack-mrkdwn-shaped marker; markup-free text is byte-identical - escapeGoogleChatText: raw &<>-entity helper (escapes unconditionally) - Correct one over-specified assertion: an unmatched < that swallows a later token is escaped conservatively (matches the shared escaping boundary — a half-open tag never reaches the wire; a stray bracket after a token keeps it) --- .../src/googlechat/format-googlechat.test.ts | 12 ++- .../src/googlechat/format-googlechat.ts | 85 +++++++++++++++++++ 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 packages/channels/src/googlechat/format-googlechat.ts diff --git a/packages/channels/src/googlechat/format-googlechat.test.ts b/packages/channels/src/googlechat/format-googlechat.test.ts index b93e88d90..ded23bbb9 100644 --- a/packages/channels/src/googlechat/format-googlechat.test.ts +++ b/packages/channels/src/googlechat/format-googlechat.test.ts @@ -78,9 +78,17 @@ describe("format-googlechat", () => { ); }); - it("escapes stray brackets while preserving a valid link token in the same string", () => { + it("escapes a stray bracket after a valid link token while preserving the token", () => { + expect(formatGoogleChatText("see then 5 > 6")).toBe( + "see then 5 > 6", + ); + }); + + it("conservatively escapes a token an unmatched < swallows (never emits unbalanced markup)", () => { + // An unmatched "<" pairs with the next token's ">"; escaping the whole span + // is the safe outcome — no half-open tag ever reaches the wire. expect(formatGoogleChatText("5 < 6 see ")).toBe( - "5 < 6 see ", + "5 < 6 see <https://x.com|x>", ); }); }); diff --git a/packages/channels/src/googlechat/format-googlechat.ts b/packages/channels/src/googlechat/format-googlechat.ts new file mode 100644 index 000000000..7efff0f5c --- /dev/null +++ b/packages/channels/src/googlechat/format-googlechat.ts @@ -0,0 +1,85 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat message-text formatting: a conservative escaping boundary for the + * outbound message `text` field. + * + * Google Chat message text is Slack-mrkdwn-shaped — bold `*text*`, italic + * `_text_`, monospace `` `text` ``, strikethrough `~text~`, hyperlinks + * ``, and user mentions `` — and it ALSO + * interprets a basic HTML subset (``, `
`, …) in the same field. So the + * agent's markup is already in the shape Chat renders and is passed through + * unchanged; the boundary's only job is to escape stray `&`, `<`, `>` so a + * literal angle bracket in agent text cannot be read as an HTML tag, while the + * genuine `<…>` link/mention tokens are preserved. + * + * Markup-free plain text is returned byte-identical — an existing plain-text + * send is never altered by routing through this module. + * + * This module serves the MESSAGE body text only. Card `textParagraph` content is + * a separate surface escaped inside the rich renderer, not here. + * + * @module + */ + +const GCHAT_ANGLE_TOKEN_RE = /<[^>\n]+>/g; + +/** + * Whether an angle-bracket token is a genuine Google Chat token that must be + * preserved rather than escaped. Allowed: `` mentions and `` + * links, including the `` form (the inner still begins with the + * scheme). Anything else is treated as literal text and escaped. + */ +function isAllowedGoogleChatAngleToken(token: string): boolean { + if (!token.startsWith("<") || !token.endsWith(">")) return false; + const inner = token.slice(1, -1); + return ( + inner.startsWith("users/") || + inner.startsWith("http://") || + inner.startsWith("https://") + ); +} + +/** + * Escape the HTML-significant characters `&`, `<`, `>` to their entities. `&` is + * escaped first so an already escaped entity is never doubled. This is the raw + * helper — it escapes unconditionally and preserves no tokens. + */ +export function escapeGoogleChatText(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">"); +} + +/** + * Format outbound Google Chat message text. + * + * Escapes stray `&`, `<`, `>` (so agent text cannot open an HTML tag) while + * preserving genuine `` / `` / `` tokens and leaving + * every Slack-mrkdwn-shaped marker (`*bold*`, `_italic_`, `` `code` ``, + * `~strike~`) byte-identical. Markup-free text is returned unchanged. + * + * @param text - The raw outbound message text + * @returns The text with stray HTML-significant characters escaped + */ +export function formatGoogleChatText(text: string): string { + if (!text.includes("&") && !text.includes("<") && !text.includes(">")) { + return text; + } + + GCHAT_ANGLE_TOKEN_RE.lastIndex = 0; + const out: string[] = []; + let lastIndex = 0; + + for ( + let match = GCHAT_ANGLE_TOKEN_RE.exec(text); + match; + match = GCHAT_ANGLE_TOKEN_RE.exec(text) + ) { + const matchIndex = match.index ?? 0; + out.push(escapeGoogleChatText(text.slice(lastIndex, matchIndex))); + const token = match[0] ?? ""; + out.push(isAllowedGoogleChatAngleToken(token) ? token : escapeGoogleChatText(token)); + lastIndex = matchIndex + token.length; + } + + out.push(escapeGoogleChatText(text.slice(lastIndex))); + return out.join(""); +} From 26b95a66e8d6db844c86cb656199913d8baf5971 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:17:38 +0300 Subject: [PATCH 079/200] test(235-04): add failing test for sendMessage cardsV2 attach + text format - cardsV2 buttonList carries the interactive button (function + cb param) - cardsV2 card carries title/description text-paragraph widgets - plain send stays the bare { text } body (no cardsV2 key) - message text routed through formatGoogleChatText - threaded card send keeps thread{name} + reply query alongside cardsV2 --- .../src/googlechat/googlechat-adapter.test.ts | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 28eb23c1c..8b9e14da2 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -6,6 +6,7 @@ import { createGoogleChatAdapter, type GoogleChatAdapterDeps, } from "./googlechat-adapter.js"; +import { GOOGLECHAT_APPROVAL_FUNCTION } from "./googlechat-actions.js"; import type { PubSubSource, PubSubSourceDeps, @@ -776,6 +777,139 @@ describe("createGoogleChatAdapter — sendMessage (messages.create)", () => { }); }); +/** Find the single /messages REST call captured by the fetch spy (asserts it fired). */ +function sendCallOf(spy: ReturnType): [string, RequestInit] { + const call = spy.mock.calls.find(([u]) => String(u).includes("/messages")) as + | [string, RequestInit] + | undefined; + expect(call).toBeDefined(); + return call as [string, RequestInit]; +} + +/** Flatten every widget across all cardsV2 entries' sections of a request body. */ +function cardsV2Widgets(body: unknown): Array> { + const cardsV2 = (body as { cardsV2?: unknown }).cardsV2; + if (!Array.isArray(cardsV2)) return []; + const widgets: Array> = []; + for (const entry of cardsV2) { + const sections = (entry as { card?: { sections?: unknown } }).card?.sections; + if (!Array.isArray(sections)) continue; + for (const section of sections) { + const ws = (section as { widgets?: unknown }).widgets; + if (Array.isArray(ws)) widgets.push(...(ws as Array>)); + } + } + return widgets; +} + +describe("createGoogleChatAdapter — sendMessage cardsV2 (cards/buttons)", () => { + const SIGNED_CB = "v1.approve.abc123.deadbeefcafe"; + + it("attaches a cardsV2 buttonList carrying the interactive button when options.buttons is present", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.sendMessage("spaces/AAAA", "hi", { + buttons: [[{ text: "Approve", callback_data: SIGNED_CB }]], + }); + + expect(result.ok).toBe(true); + const [, init] = sendCallOf(spy); + const body = JSON.parse(String(init.body)) as Record; + const cardsV2 = body.cardsV2 as Array<{ cardId?: unknown }> | undefined; + expect(Array.isArray(cardsV2)).toBe(true); + // Each cardsV2 entry carries a non-empty cardId. + for (const entry of cardsV2 ?? []) { + expect(typeof entry.cardId).toBe("string"); + expect(String(entry.cardId).length).toBeGreaterThan(0); + } + // The widget tree contains a buttonList carrying the interactive button, and + // the button stamps the shared rendered function + rides the opaque callback. + const buttonList = cardsV2Widgets(body).find( + (w) => (w as { buttonList?: unknown }).buttonList, + ) as { buttonList: { buttons: Array> } } | undefined; + expect(buttonList).toBeDefined(); + const btn = buttonList?.buttonList.buttons[0] as { + text?: string; + onClick?: { + action?: { function?: string; parameters?: Array<{ key?: string; value?: string }> }; + }; + }; + expect(btn?.text).toBe("Approve"); + expect(btn?.onClick?.action?.function).toBe(GOOGLECHAT_APPROVAL_FUNCTION); + expect( + btn?.onClick?.action?.parameters?.find((p) => p.key === "cb")?.value, + ).toBe(SIGNED_CB); + }); + + it("attaches a cardsV2 card with title/description text-paragraph widgets when options.cards is present", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.sendMessage("spaces/AAAA", "hi", { + cards: [{ title: "T", description: "D" }], + }); + + expect(result.ok).toBe(true); + const [, init] = sendCallOf(spy); + const body = JSON.parse(String(init.body)) as Record; + expect(Array.isArray(body.cardsV2)).toBe(true); + const paragraphs = cardsV2Widgets(body) + .map((w) => (w as { textParagraph?: { text?: string } }).textParagraph?.text) + .filter((t): t is string => typeof t === "string"); + expect(paragraphs).toContain("T"); + expect(paragraphs).toContain("D"); + }); + + it("a plain send (no cards/buttons) stays the bare { text } body — no cardsV2 key", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + await adapter.sendMessage("spaces/AAAA", "hello"); + + const [, init] = sendCallOf(spy); + const body = JSON.parse(String(init.body)) as Record; + expect(body).toEqual({ text: "hello" }); + expect("cardsV2" in body).toBe(false); + }); + + it("routes the message text through formatGoogleChatText (a stray angle bracket is escaped)", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + await adapter.sendMessage("spaces/AAAA", "1 < 2 & 3"); + + const [, init] = sendCallOf(spy); + const body = JSON.parse(String(init.body)) as { text?: string }; + expect(body.text).toBe("1 < 2 & 3"); + }); + + it("threads a card send: thread{name} + reply query alongside cardsV2 when threadId and buttons are both set", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + await adapter.sendMessage("spaces/AAAA", "hi", { + threadId: "spaces/AAAA/threads/TTTT", + buttons: [[{ text: "Approve", callback_data: SIGNED_CB }]], + }); + + const [url, init] = sendCallOf(spy); + // The thread name stays a BODY value and the reply option a QUERY param — + // the cardsV2 payload rides the same POST alongside them. + expect(url).toBe( + "https://chat.googleapis.com/v1/spaces/AAAA/messages?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD", + ); + const body = JSON.parse(String(init.body)) as Record; + expect(body.thread).toEqual({ name: "spaces/AAAA/threads/TTTT" }); + expect(Array.isArray(body.cardsV2)).toBe(true); + }); +}); + describe("createGoogleChatAdapter — editMessage (messages.patch)", () => { it("mints a chat.bot bearer and PATCHes {text} with updateMask=text to the message resource, returning ok(undefined)", async () => { const { fetchImpl, spy } = makeChatFetch(); From 2692acf77991331916cb0fec1cd1ff02bb2e18ed Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:18:34 +0300 Subject: [PATCH 080/200] feat(235-04): attach cardsV2 to Google Chat sends and format message text - sendMessage renders options.cards/options.buttons into a cardsV2 body - top-level button rows ride a trailing single-section card - message text passes through formatGoogleChatText (plain text byte-identical) - plain and threaded sends keep their bare { text }(+thread) shape --- .../src/googlechat/googlechat-adapter.ts | 33 ++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 8df8e7db8..cc7c64852 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -63,6 +63,11 @@ import { mapGoogleChatEventToNormalized, type GoogleChatEvent, } from "./message-mapper.js"; +import { + renderGoogleChatCards, + renderGoogleChatButtons, +} from "./googlechat-rich-renderer.js"; +import { formatGoogleChatText } from "./format-googlechat.js"; // --------------------------------------------------------------------------- // Send-safety knobs for the 429-only bounded resend @@ -450,7 +455,33 @@ export function createGoogleChatAdapter( const url = threadName ? `${chatBase}/${channelId}/messages?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD` : `${chatBase}/${channelId}/messages`; - const body = threadName ? { text, thread: { name: threadName } } : { text }; + // Format the message text through the conservative escape boundary — a + // markup-free string is returned byte-identical, so a plain send is + // unchanged. Attach a cardsV2 body ONLY when the caller supplies cards or + // button rows; otherwise the body stays the bare { text } (optionally with + // a thread) shape rather than carrying an empty cardsV2 key. + const body: Record = threadName + ? { text: formatGoogleChatText(text), thread: { name: threadName } } + : { text: formatGoogleChatText(text) }; + const hasButtons = (options?.buttons?.length ?? 0) > 0; + const hasCards = (options?.cards?.length ?? 0) > 0; + if (hasButtons || hasCards) { + // Each supplied card renders to its own cardsV2 entry; top-level button + // rows ride a trailing single-section card so the interactive widgets are + // never dropped when no card body accompanies them. + const cardsV2 = renderGoogleChatCards(options?.cards ?? []); + if (hasButtons) { + cardsV2.push({ + cardId: randomUUID(), + card: { + sections: [ + { widgets: [renderGoogleChatButtons(options?.buttons ?? [])] }, + ], + }, + }); + } + body.cardsV2 = cardsV2; + } const init: RequestInit = { method: "POST", headers: { From e4204ab615e8abbd52218604d462bfaf43695ace Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:19:30 +0300 Subject: [PATCH 081/200] test(235-04): add failing test for editMessage cardsV2 field-mask patch - cardsV2 patch pins updateMask=text,cardsV2 (never *), body { text, cardsV2 } - the resolved card carries no buttonList widget (original buttons retired) - text-only edit path stays byte-identical (updateMask=text, body { text }) - unsafe messageId rejected before token mint or fetch on the patch path --- .../src/googlechat/googlechat-adapter.test.ts | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 8b9e14da2..f6848b17b 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -1054,6 +1054,79 @@ describe("createGoogleChatAdapter — editMessage (messages.patch)", () => { }); }); +describe("createGoogleChatAdapter — editMessage cardsV2 patch", () => { + it("patches text,cardsV2 with a button-less resolved card when options.cards is present (buttons retired)", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC", + "Approved", + { cards: [{ description: "Approved" }] }, + ); + + expect(result?.ok).toBe(true); + const [url, init] = sendCallOf(spy); + // The field-mask is pinned to text,cardsV2 — never `*`, which would clear + // every unspecified field on the message. + expect(url).toBe( + "https://chat.googleapis.com/v1/spaces/AAAA/messages/CCC?updateMask=text,cardsV2", + ); + expect(url).not.toContain("updateMask=*"); + expect(init.method).toBe("PATCH"); + const body = JSON.parse(String(init.body)) as Record; + expect(body.text).toBe("Approved"); + expect(Array.isArray(body.cardsV2)).toBe(true); + // The resolved card the caller supplied carries no buttons, so the patched + // card has no buttonList widget — the original buttons are retired. + expect( + cardsV2Widgets(body).some((w) => (w as { buttonList?: unknown }).buttonList), + ).toBe(false); + }); + + it("keeps the text-only edit path byte-identical (updateMask=text, body { text }) when no cards are supplied", async () => { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC", + "typing…", + ); + + expect(result?.ok).toBe(true); + const [url, init] = sendCallOf(spy); + expect(url).toBe( + "https://chat.googleapis.com/v1/spaces/AAAA/messages/CCC?updateMask=text", + ); + expect(JSON.parse(String(init.body))).toEqual({ text: "typing…" }); + }); + + it("rejects an unsafe messageId on the cardsV2 patch path BEFORE any token mint or fetch (spy never called)", async () => { + for (const bad of [ + "spaces/../secret", + "spaces/AAAA/messages/CCC?updateMask=*", + "spaces/AAAA/messages/ CCC", + ]) { + const { fetchImpl, spy } = makeChatFetch(); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.("spaces/AAAA", bad, "x", { + cards: [{ description: "d" }], + }); + + expect(result?.ok).toBe(false); + // The reused isSafeMessageName guard short-circuits before the token mint + // and the fetch on the cardsV2 patch path too — no bearer, no PATCH. + expect(spy).not.toHaveBeenCalled(); + } + }); +}); + describe("createGoogleChatAdapter — deleteMessage (messages.delete)", () => { it("mints a chat.bot bearer and DELETEs the message resource with no body, returning ok(undefined)", async () => { const { fetchImpl, spy } = makeChatFetch(); From 0afd8d7c68207b42af24921ed058be1b4b9d058a Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:20:32 +0300 Subject: [PATCH 082/200] feat(235-04): patch Google Chat cards in place via a pinned field-mask - editMessage patches text,cardsV2 when options.cards is present - the resolved card carries no buttons, retiring the interactive widgets - field-mask pinned to a literal list (never *); reuses isSafeMessageName - the shipped text-only edit path (mask text, body { text }) is unchanged --- .../src/googlechat/googlechat-adapter.ts | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index cc7c64852..65b511d02 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -590,7 +590,7 @@ export function createGoogleChatAdapter( _channelId: string, messageId: string, text: string, - _options?: SendMessageOptions, + options?: SendMessageOptions, ): Promise> { // Guard the caller-supplied resource name before it ever reaches the token // mint or the REST path — an unsafe name mints no bearer and fires no fetch. @@ -609,16 +609,25 @@ export function createGoogleChatAdapter( if (!tok.ok) return err(tok.error); // auth already logged a secret-free WARN const chatBase = deps.chatBaseUrl ?? "https://chat.googleapis.com/v1"; const doFetch = deps.fetchImpl ?? fetch; - // updateMask is pinned to `text`: a `*` mask would clear every unspecified - // field on the message rather than editing only its text. - const url = `${chatBase}/${messageId}?updateMask=text`; + // updateMask is pinned to a literal field list — never `*`, which would + // clear every unspecified field. A supplied card re-renders the message in + // place through the `text,cardsV2` mask; the resolved card the caller hands + // in carries no buttons, so patching it retires the original interactive + // widgets. With no card the text-only path is unchanged (mask `text`). + const hasCards = (options?.cards?.length ?? 0) > 0; + const url = hasCards + ? `${chatBase}/${messageId}?updateMask=text,cardsV2` + : `${chatBase}/${messageId}?updateMask=text`; + const body = hasCards + ? { text, cardsV2: renderGoogleChatCards(options?.cards ?? []) } + : { text }; const init: RequestInit = { method: "PATCH", headers: { authorization: `Bearer ${tok.value}`, "content-type": "application/json", }, - body: JSON.stringify({ text }), + body: JSON.stringify(body), }; const responded = await fromPromise(doFetch(url, init)); if (!responded.ok) { From 3ccbe21ebd05441122e0980469ed876b01d38846 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:33:29 +0300 Subject: [PATCH 083/200] test(235-05): add failing CARD_CLICKED routing + default-deny tests - Drive handleChatEvent with CARD_CLICKED fixtures: routed into onMessage as a button callback before the message mapper; verified user.name is the clicker id (a forged action.parameters id is ignored) - A non-allowFrom clicker is denied by the reused isAllowedSender gate; every security drop (unrendered method / missing callback / missing clicker) acks with a distinct WARN and never throws (no redelivery amplification) - A click before a handler is wired skip-acks (redelivers); a benign non-card event falls through to the message path unchanged --- .../src/googlechat/googlechat-adapter.test.ts | 241 +++++++++++++++++- 1 file changed, 240 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index f6848b17b..050fde484 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from "vitest"; -import type { ComisLogger } from "@comis/core"; +import type { ComisLogger, NormalizedMessage } from "@comis/core"; import { generateKeyPair, exportPKCS8 } from "jose"; import { createGoogleChatAdapter, @@ -261,6 +261,45 @@ function makeEvent( }; } +/** + * Build a CARD_CLICKED interaction event: a verified clicker (`user.name`), a + * rendered action method, and an opaque `cb` parameter. `cb: null` omits the + * callback param (a malformed click); `omitClicker` drops the verified user + * envelope; `forgedUserId` plants a client-controllable id under + * `action.parameters` (which the normalizer must ignore). + */ +function makeCardClickEvent( + over: { + clickerName?: string; + method?: string; + cb?: string | null; + spaceName?: string; + messageName?: string; + forgedUserId?: string; + omitClicker?: boolean; + } = {}, +): unknown { + const parameters: Array<{ key: string; value: string }> = []; + if (over.cb !== null) { + parameters.push({ key: "cb", value: over.cb ?? "v1.allow.shortid.hmac" }); + } + if (over.forgedUserId !== undefined) { + parameters.push({ key: "userId", value: over.forgedUserId }); + } + return { + type: "CARD_CLICKED", + ...(over.omitClicker + ? {} + : { user: { name: over.clickerName ?? "users/123" } }), + space: { name: over.spaceName ?? "spaces/AAAA" }, + message: { name: over.messageName ?? "spaces/AAAA/messages/CCC" }, + action: { + actionMethodName: over.method ?? GOOGLECHAT_APPROVAL_FUNCTION, + parameters, + }, + }; +} + /** Find a logged record at a level whose object arg has the given errorKind. */ function findByErrorKind( spy: ReturnType, @@ -1504,3 +1543,203 @@ describe("createGoogleChatAdapter — 429 auto-resend (send-safety)", () => { expect(finalPosts).toBe(2); // no third POST — the resend was cancelled by stop() }); }); + +describe("createGoogleChatAdapter — CARD_CLICKED routing + default-deny", () => { + const CB = "v1.allow.shortid.hmachmac"; + + it("routes an allowlisted clicker's rendered card click into onMessage as a button callback and bumps liveness", async () => { + const { deps } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await expect( + adapter.handleChatEvent( + makeCardClickEvent({ clickerName: "users/123", cb: CB }), + ), + ).resolves.toBeUndefined(); + + expect(handler).toHaveBeenCalledTimes(1); + const msg = handler.mock.calls[0][0] as NormalizedMessage; + // Routed through the normalizer into the button-callback message shape the + // inbound approval path consumes — BEFORE the message mapper (which is null). + expect(msg.channelType).toBe("googlechat"); + expect(msg.channelId).toBe("spaces/AAAA"); + expect(msg.senderId).toBe("users/123"); + expect(msg.metadata.isButtonCallback).toBe(true); + expect(msg.metadata.callbackData).toBe(CB); + expect(typeof msg.metadata.traceId).toBe("string"); + // An admitted card click bumps inbound liveness, exactly like a message. + expect(adapter.getStatus?.().lastInboundAt).toBe(NOW); + }); + + it("DENIES a well-formed card click from a non-allowFrom clicker via the one reused gate, never calling the handler, and RESOLVES (ack — no redelivery)", async () => { + const { deps, loggerSpy } = await makeDeps({ allowFrom: ["users/good"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await expect( + adapter.handleChatEvent( + makeCardClickEvent({ clickerName: "users/evil", cb: CB }), + ), + ).resolves.toBeUndefined(); + + expect(handler).not.toHaveBeenCalled(); + const warn = findByErrorKind(loggerSpy.warn, "precondition"); + expect(warn).toBeDefined(); + expect(String(warn?.hint)).toContain("channels.googlechat.allowFrom"); + // A default-deny drop must never bump inbound liveness. + expect(adapter.getStatus?.().lastInboundAt).toBeUndefined(); + }); + + it("keys the default-deny gate on the VERIFIED user.name, ignoring a forged users/... id planted in action.parameters (drop side)", async () => { + // The verified clicker is users/evil; the payload forges the allowlisted + // users/good under action.parameters. The gate reads the envelope id only, + // so the click is still denied. + const { deps } = await makeDeps({ allowFrom: ["users/good"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleChatEvent( + makeCardClickEvent({ + clickerName: "users/evil", + forgedUserId: "users/good", + cb: CB, + }), + ); + + expect(handler).not.toHaveBeenCalled(); + }); + + it("admits on the VERIFIED user.name even when a forged id rides action.parameters, and the fanned senderId is the envelope id (admit side)", async () => { + const { deps } = await makeDeps({ allowFrom: ["users/good"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleChatEvent( + makeCardClickEvent({ + clickerName: "users/good", + forgedUserId: "users/attacker", + cb: CB, + }), + ); + + expect(handler).toHaveBeenCalledTimes(1); + const msg = handler.mock.calls[0][0] as NormalizedMessage; + // The verified envelope id, never the forged action.parameters value. + expect(msg.senderId).toBe("users/good"); + expect(msg.metadata.isButtonCallback).toBe(true); + expect(msg.metadata.callbackData).toBe(CB); + }); + + it("drops a card click naming an unrendered method with a validation WARN and RESOLVES (ack — no redelivery); the callback never rides the log", async () => { + const { deps, loggerSpy } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await expect( + adapter.handleChatEvent( + makeCardClickEvent({ + clickerName: "users/123", + method: "attacker.arbitrary.method", + cb: CB, + }), + ), + ).resolves.toBeUndefined(); + + expect(handler).not.toHaveBeenCalled(); + expect(findByErrorKind(loggerSpy.warn, "validation")).toBeDefined(); + // The opaque signed callback must never be logged on a security drop. + expect(loggerSpy.serialized()).not.toContain(CB); + }); + + it("drops a card click carrying no opaque callback with a validation WARN and RESOLVES", async () => { + const { deps, loggerSpy } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await expect( + adapter.handleChatEvent( + makeCardClickEvent({ clickerName: "users/123", cb: null }), + ), + ).resolves.toBeUndefined(); + + expect(handler).not.toHaveBeenCalled(); + expect(findByErrorKind(loggerSpy.warn, "validation")).toBeDefined(); + }); + + it("drops a card click with no verified clicker id with a precondition WARN and RESOLVES", async () => { + const { deps, loggerSpy } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await expect( + adapter.handleChatEvent(makeCardClickEvent({ omitClicker: true, cb: CB })), + ).resolves.toBeUndefined(); + + expect(handler).not.toHaveBeenCalled(); + expect(findByErrorKind(loggerSpy.warn, "precondition")).toBeDefined(); + }); + + it("never throws (ack — no redelivery amplification) on ANY security drop", async () => { + // Only a genuine handler rejection skip-acks; every rejected click resolves. + const { deps } = await makeDeps({ allowFrom: ["users/good"] }); + const adapter = createGoogleChatAdapter(deps); + adapter.onMessage(vi.fn()); + + await expect( + adapter.handleChatEvent( + makeCardClickEvent({ clickerName: "users/evil", cb: CB }), + ), + ).resolves.toBeUndefined(); + await expect( + adapter.handleChatEvent( + makeCardClickEvent({ clickerName: "users/good", method: "x.y", cb: CB }), + ), + ).resolves.toBeUndefined(); + await expect( + adapter.handleChatEvent( + makeCardClickEvent({ clickerName: "users/good", cb: null }), + ), + ).resolves.toBeUndefined(); + await expect( + adapter.handleChatEvent(makeCardClickEvent({ omitClicker: true, cb: CB })), + ).resolves.toBeUndefined(); + }); + + it("skip-acks (rejects → redelivers) a rendered card click that arrives before a handler is wired, and does not bump liveness", async () => { + const { deps } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + // No onMessage handler registered yet — a click must redeliver, not drop. + + await expect( + adapter.handleChatEvent( + makeCardClickEvent({ clickerName: "users/123", cb: CB }), + ), + ).rejects.toThrow(); + expect(adapter.getStatus?.().lastInboundAt).toBeUndefined(); + }); + + it("lets a benign non-card event fall through to the message path unchanged", async () => { + const { deps } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + // A normal MESSAGE is not a CARD_CLICKED; it flows through the mapper + gate. + await adapter.handleChatEvent( + makeEvent({ senderName: "users/123", text: "hi there" }), + ); + + expect(handler).toHaveBeenCalledTimes(1); + const msg = handler.mock.calls[0][0] as NormalizedMessage; + expect(msg.text).toBe("hi there"); + expect(msg.metadata.isButtonCallback).toBeUndefined(); + }); +}); From be4c34403ae98e534ca63dc33f656de1453504fe Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:35:22 +0300 Subject: [PATCH 084/200] feat(235-05): route CARD_CLICKED through the reused default-deny gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - handleChatEvent gains a CARD_CLICKED branch before the message mapper: it normalizes the click, logs each security-relevant drop with a distinct WARN (unrendered-method/missing-callback → validation; missing-clicker → precondition), then authorizes the VERIFIED clicker id through the SAME isAllowedSender gate the message path uses (no parallel allowlist) - Extract the shared fanout tail (handler-not-registered skip-ack, liveness bump, traceId, runWithContext allSettled + one @allow-throw rethrow) into fanOutMessage, reused by both the message and card-click paths - Every card-action drop acks (resolves); only a genuine handler failure or a click before a handler is wired skip-acks (redelivers) --- .../src/googlechat/googlechat-adapter.ts | 135 +++++++++++++++--- 1 file changed, 112 insertions(+), 23 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 65b511d02..acf944d1b 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -41,6 +41,7 @@ import type { ChannelStatus, ComisLogger, MessageHandler, + NormalizedMessage, ReconcileSendOutcome, ReconcileSendQuery, SendMessageOptions, @@ -63,6 +64,10 @@ import { mapGoogleChatEventToNormalized, type GoogleChatEvent, } from "./message-mapper.js"; +import { + normalizeGoogleChatCardAction, + type GoogleChatCardClickEvent, +} from "./googlechat-actions.js"; import { renderGoogleChatCards, renderGoogleChatButtons, @@ -238,25 +243,17 @@ export function createGoogleChatAdapter( ); } - async function handleChatEvent(event: unknown): Promise { - const normalized = mapGoogleChatEventToNormalized(event as GoogleChatEvent); - if (!normalized) return; // non-MESSAGE → nothing to dispatch → ack (resolve) - - if (!isAllowedSender(normalized.senderId, normalized.channelId)) { - deps.logger.warn( - { - channelType: "googlechat" as const, - senderId: normalized.senderId, - hint: "Add the sender users/{id} or the space spaces/{id} to channels.googlechat.allowFrom", - errorKind: "precondition" as const, - }, - "Inbound from non-allowlisted sender dropped", - ); - return; // drop BEFORE any processing → ack (resolve) - } - + /** + * Admit a gated inbound and fan it out to the registered handlers under a fresh + * request context. An inbound that arrives before onMessage() has wired a + * handler skip-acks (throws) so the pull loop redelivers it rather than + * acking-and-dropping; a handler failure likewise skip-acks. Shared by the + * message path and the card-click path so both traverse one fanout — one + * liveness bump, one skip-ack boundary, no duplicated dispatch. + */ + async function fanOutMessage(normalized: NormalizedMessage): Promise { if (handlers.length === 0) { - // A pull channel drains the backlog immediately on start(); a message that + // A pull channel drains the backlog immediately on start(); an inbound that // arrives before onMessage() has wired a handler must redeliver, not be // acked-and-dropped. No liveness bump — a never-wired ingress must look // stale to the health monitor rather than falsely healthy. @@ -269,7 +266,7 @@ export function createGoogleChatAdapter( "Inbound arrived before a handler was registered; skipping ack", ); // Skip-ack via the same pull-loop boundary as a handler failure (the file - // carries the @allow-throw annotation) so the message redelivers. + // carries the @allow-throw annotation) so the inbound redelivers. throw new Error("no inbound handler registered"); } @@ -304,10 +301,10 @@ export function createGoogleChatAdapter( }, "Inbound message handler error", ); - // @allow-throw: handleChatEvent is the pull loop's onEvent boundary — a - // rejected promise IS the skip-ack (redeliver) signal, which the loop - // catches and translates. Rethrow so the failed enqueue redelivers - // rather than being acked-and-dropped. + // @allow-throw: fanOutMessage runs inside handleChatEvent, the pull + // loop's onEvent boundary — a rejected promise IS the skip-ack + // (redeliver) signal, which the loop catches and translates. Rethrow so + // the failed enqueue redelivers rather than being acked-and-dropped. throw failed.reason instanceof Error ? failed.reason : new Error(String(failed.reason)); @@ -316,6 +313,98 @@ export function createGoogleChatAdapter( ); } + async function handleChatEvent(event: unknown): Promise { + // A card click arrives as a CARD_CLICKED event on the same ingress; route it + // through the card-action normalizer BEFORE the message mapper (which yields + // null for it), so it traverses the same default-deny gate the message path + // uses. A null/undefined or primitive payload safely misses this branch and + // flows to the mapper, which already handles it. + const clickEvent = event as GoogleChatCardClickEvent | null | undefined; + if (clickEvent?.type === "CARD_CLICKED") { + const result = normalizeGoogleChatCardAction(clickEvent); + if (result.message === null) { + // Distinguish the benign non-click drop (silent) from the security- + // relevant rejects, each carrying a distinct errorKind + hint and NO + // secret/callback, so a probe naming a method the bot never rendered, a + // malformed click, or a click that cannot be authorized is diagnosable — + // mirrors the non-allowlisted-sender WARN below. + switch (result.reason) { + case "ignored": + break; + case "unrendered-method": + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "Card-action method is not in the rendered set; a click cannot invoke a method the bot never rendered", + errorKind: "validation" as const, + }, + "Inbound card action dropped: unrendered method", + ); + break; + case "missing-callback": + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "Card-action click carried no opaque callback; the action is malformed", + errorKind: "validation" as const, + }, + "Inbound card action dropped: missing callback", + ); + break; + case "missing-clicker": + deps.logger.warn( + { + channelType: "googlechat" as const, + hint: "Card-action click carried no verified user.name; the clicker cannot be authorized", + errorKind: "precondition" as const, + }, + "Inbound card action dropped: no verified clicker id", + ); + break; + } + return; // every card-action drop acks (resolves) — never redelivers + } + + const clicked = result.message; + // Authorize the click through the SAME default-deny gate the message path + // uses — on the VERIFIED clicker id the normalizer read from user.name, + // never a payload field. One authoritative gate, no parallel allowlist. + if (!isAllowedSender(clicked.senderId, clicked.channelId)) { + deps.logger.warn( + { + channelType: "googlechat" as const, + senderId: clicked.senderId, + hint: "Add the sender users/{id} or the space spaces/{id} to channels.googlechat.allowFrom", + errorKind: "precondition" as const, + }, + "Inbound from non-allowlisted sender dropped", + ); + return; // drop BEFORE any processing → ack (resolve) + } + + await fanOutMessage(clicked); + return; + } + + const normalized = mapGoogleChatEventToNormalized(event as GoogleChatEvent); + if (!normalized) return; // non-MESSAGE → nothing to dispatch → ack (resolve) + + if (!isAllowedSender(normalized.senderId, normalized.channelId)) { + deps.logger.warn( + { + channelType: "googlechat" as const, + senderId: normalized.senderId, + hint: "Add the sender users/{id} or the space spaces/{id} to channels.googlechat.allowFrom", + errorKind: "precondition" as const, + }, + "Inbound from non-allowlisted sender dropped", + ); + return; // drop BEFORE any processing → ack (resolve) + } + + await fanOutMessage(normalized); + } + const adapter: GoogleChatAdapterHandle = { channelId: CHANNEL_ID, channelType: "googlechat", From 0cf98be7c534de197b9959fb6fe9150699671f8d Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:36:22 +0300 Subject: [PATCH 085/200] test(235-05): pin googlechat buttons to "cardsv2" - googlechat-plugin deep-equal now expects buttons:"cardsv2" - plugin-capabilities-explicit: add "cardsv2" to the ExpectedCaps.buttons union and flip the pinned googlechat row (toHaveLength(12) unchanged) --- .../src/__tests__/plugin-capabilities-explicit.test.ts | 5 +++-- packages/channels/src/googlechat/googlechat-plugin.test.ts | 6 +++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts b/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts index 8e8f6f3d4..b8a11d796 100644 --- a/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts +++ b/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts @@ -50,7 +50,8 @@ interface ExpectedCaps { | "blockkit" | "quickreply" | "none" - | "adaptivecard"; + | "adaptivecard" + | "cardsv2"; } const EXPECTED: readonly ExpectedCaps[] = [ @@ -65,7 +66,7 @@ const EXPECTED: readonly ExpectedCaps[] = [ { dir: "irc", typing: false, threads: false, buttons: "none" }, { dir: "email", typing: false, threads: false, buttons: "none" }, { dir: "msteams", typing: true, threads: true, buttons: "adaptivecard" }, - { dir: "googlechat", typing: false, threads: true, buttons: "none" }, + { dir: "googlechat", typing: false, threads: true, buttons: "cardsv2" }, ]; /** The three fields that MUST be declared (not defaulted) per plugin. */ diff --git a/packages/channels/src/googlechat/googlechat-plugin.test.ts b/packages/channels/src/googlechat/googlechat-plugin.test.ts index ace35ed79..e7f2b727e 100644 --- a/packages/channels/src/googlechat/googlechat-plugin.test.ts +++ b/packages/channels/src/googlechat/googlechat-plugin.test.ts @@ -124,8 +124,8 @@ describe("createGoogleChatPlugin — honest app-auth CAPABILITIES", () => { const plugin = createGoogleChatPlugin(deps); // Deep-equal on the DECLARED literal (not a schema-parsed shape): edit, - // delete, and threaded replies are on; reactions, history, attachments, and - // typing are off and there is no button surface. + // delete, threaded replies, and Cards v2 interactive buttons are on; + // reactions, history, attachments, and typing are off. expect(plugin.capabilities.features).toEqual({ reactions: false, editMessages: true, @@ -134,7 +134,7 @@ describe("createGoogleChatPlugin — honest app-auth CAPABILITIES", () => { attachments: false, typing: false, threads: true, - buttons: "none", + buttons: "cardsv2", }); }); From 7393b0ca9625618f8d12f145aceba103d9591e94 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:37:43 +0300 Subject: [PATCH 086/200] feat(235-05): advertise googlechat buttons:"cardsv2" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CAPABILITIES.features.buttons flips "none" → "cardsv2" now that the renderer and the CARD_CLICKED routing both exist — an honest capability declaration - Rewrite the module header to describe the real Cards v2 interactive-button surface instead of the interim no-button state --- .../src/googlechat/googlechat-plugin.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-plugin.ts b/packages/channels/src/googlechat/googlechat-plugin.ts index f773327a9..242e26aee 100644 --- a/packages/channels/src/googlechat/googlechat-plugin.ts +++ b/packages/channels/src/googlechat/googlechat-plugin.ts @@ -4,15 +4,16 @@ * ChannelPluginPort with an honest, app-auth capability matrix. * * The adapter sends and receives text over Pub/Sub pull, edits and deletes its - * own messages, and posts threaded replies, so `editMessages`, `deleteMessages`, - * and `threads` are advertised `true` and their methods are present. Reactions, - * history fetch, outbound attachments, and typing indicators are not reachable - * for a service-account app, so those flags stay `false` and `buttons` stays - * `"none"`. That honesty is load-bearing: the daemon capability gate + * own messages, posts threaded replies, and renders and routes Cards v2 + * interactive buttons, so `editMessages`, `deleteMessages`, `threads`, and the + * `"cardsv2"` button surface are advertised with their supporting paths in place. + * Reactions, history fetch, outbound attachments, and typing indicators are not + * reachable for a service-account app, so those flags stay `false`. That honesty + * is load-bearing: the daemon capability gate * (requireMethod) throws if a capability is advertised whose adapter method is * omitted, and blocks a false-flag call before it reaches an unimplemented path. - * Every advertised-true flag has its method present; every false/none flag has - * its method deliberately OMITTED — the honest-capability contract. + * Every advertised-true flag has its method present; every false flag has its + * method deliberately OMITTED — the honest-capability contract. * * activate() delegates to adapter.start() (which opens the pull loop) and * deactivate() to adapter.stop(). The plugin returns a plain ChannelPluginPort: @@ -42,7 +43,7 @@ const CAPABILITIES: ChannelCapability = { attachments: false, // outbound upload is user-auth-only typing: false, // no typing API threads: true, // threaded replies route through the send path - buttons: "none", // no card-button surface + buttons: "cardsv2", // Cards v2 interactive widget buttons, rendered and routed }, limits: { maxMessageChars: 4000 }, replyToMetaKey: "googlechatMessageName", From 804412c1a30113d85e092a372f538d293d32f5d1 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:53:48 +0300 Subject: [PATCH 087/200] test(235-06): add failing test for Google Chat EditPlace render-actions - classifyGoogleChatRenderError structural-status mapping onto the closed ActivityRenderError union (429/401/403/404/internal, status off cause) - card-frame tracking: send with buttons records a card frame; a plain send does not - retire timing: a mid-wait streaming edit patches text only (buttons kept); the terminal successLabel edit patches a button-less card (buttons retired); a non-card completion stays text - bounded 429 retry buffer + 404 edits-dropped short-circuit + delete guard - end-to-end drive of the real renderer proving buttons survive mid-wait and retire only on the finalize-success resolve --- .../googlechat/googlechat-activity.test.ts | 568 ++++++++++++++++++ 1 file changed, 568 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-activity.test.ts diff --git a/packages/channels/src/googlechat/googlechat-activity.test.ts b/packages/channels/src/googlechat/googlechat-activity.test.ts new file mode 100644 index 000000000..017aa9c3e --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-activity.test.ts @@ -0,0 +1,568 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat EditPlace renderer tests. + * + * Three parts under test: + * 1. classifyGoogleChatRenderError — maps a Chat REST failure onto the CLOSED + * ActivityRenderError union by its STRUCTURAL numeric status (read off the + * error AND off error.cause), never the send-path taxonomy. + * 2. makeGoogleChatRenderActions — the ActivityRenderActions adapter. send + * paints signed approval buttons and records the message as a card frame; + * the bounded 429 retry buffer mirrors the shipped card-channel renderer. + * 3. createGoogleChatActivityRenderer — wires the shared createEditPlaceRenderer. + * + * The correctness edge (retire TIMING): the shared EditPlace machine calls the + * SAME 2-arg edit(id, text) for both the debounced streaming refresh + * (renderFrameText) and the terminal finalize success closing render + * (successLabel(markers)). The render-actions must recognize ONLY the terminal + * render — by exact-matching successLabel(markers) — and retire the buttons there + * via a button-less cardsV2 patch; every mid-wait refresh must patch text only so + * Approve/Deny stay clickable, and a plain completion must stay text. The + * end-to-end suite drives the REAL renderer (apply → fire-debounce → finalize) and + * proves the buttons survive mid-wait and are retired only on resolve. + * + * Time discipline: every test drives the injected FakeTimers / FakeClock — no raw + * setTimeout / Date.now. The fake adapter records every op (incl. the 4th-arg + * cards patch on edit and the buttons on send) and exposes a structural-status + * error seam so the classifier is driven off e.status. + */ +import { describe, it, expect } from "vitest"; +import { ok, err, type Result } from "@comis/shared"; +import { signCallbackData } from "@comis/core"; +import type { + ChannelPort, + ChannelStatus, + MessageHandler, + SendMessageOptions, + ActivityRenderFrame, + ActivityEvent, + ApprovalCorrelation, + TurnOutcome, + FinalDeliveryReceipt, + RichButton, + RichCard, +} from "@comis/core"; +import { createFakeTimers } from "../../../../test/support/fake-timers.js"; +import { createFakeClock } from "../../../../test/support/fake-clock.js"; +import { successLabel } from "../shared/strategies/render.js"; +import { + classifyGoogleChatRenderError, + makeGoogleChatRenderActions, + createGoogleChatActivityRenderer, +} from "./googlechat-activity.js"; + +const DEBOUNCE_MS = 800; + +// --- Fake ChannelPort (records ops incl. send buttons + edit cards patch) ----- + +/** One recorded adapter call — discriminated by `op`, deterministic ids. */ +type FakeCall = + | { op: "send"; id: string; text: string; buttons?: RichButton[][] } + | { op: "edit"; id: string; text: string; cards?: RichCard[] } + | { op: "delete"; id: string }; + +/** + * A Chat-REST-shaped failure carrying the STRUCTURAL numeric `status` (and the + * optional `retryAfter` seconds) the renderer classifier reads — never the + * message string. + */ +interface RenderError extends Error { + status?: number; + retryAfter?: number; +} + +interface FakeGoogleChatAdapter extends ChannelPort { + readonly recorded: { calls: FakeCall[] }; + /** One-shot: the NEXT recording op returns `err(nextError)`, then clears. */ + nextError: RenderError | undefined; + /** Persistent: EVERY `editMessage` returns `err(alwaysEditError)` until cleared. */ + alwaysEditError: RenderError | undefined; + /** Count of `editMessage` invocations (incl. failed) — proves the retry bound. */ + editAttempts(): number; +} + +function mkError(fields: { status?: number; retryAfter?: number; message?: string }): RenderError { + const e = new Error(fields.message ?? "chat rest failed") as RenderError; + if (fields.status !== undefined) e.status = fields.status; + if (fields.retryAfter !== undefined) e.retryAfter = fields.retryAfter; + return e; +} + +function createFakeGoogleChatAdapter(channelId = "spaces/AAAA"): FakeGoogleChatAdapter { + const recorded: { calls: FakeCall[] } = { calls: [] }; + let counter = 0; + let edits = 0; + + const adapter: FakeGoogleChatAdapter = { + channelId, + channelType: "googlechat", + recorded, + nextError: undefined, + alwaysEditError: undefined, + editAttempts: () => edits, + + async start(): Promise> { + return ok(undefined); + }, + async stop(): Promise> { + return ok(undefined); + }, + + async sendMessage( + _channelId: string, + text: string, + options?: SendMessageOptions, + ): Promise> { + if (adapter.nextError) { + const e = adapter.nextError; + adapter.nextError = undefined; + return err(e); + } + const id = `googlechat-msg-${counter++}`; + recorded.calls.push({ op: "send", id, text, buttons: options?.buttons }); + return ok(id); + }, + + async editMessage( + _channelId: string, + messageId: string, + text: string, + options?: SendMessageOptions, + ): Promise> { + edits += 1; + if (adapter.alwaysEditError) return err(adapter.alwaysEditError); + if (adapter.nextError) { + const e = adapter.nextError; + adapter.nextError = undefined; + return err(e); + } + recorded.calls.push({ op: "edit", id: messageId, text, cards: options?.cards }); + return ok(undefined); + }, + + async deleteMessage(_channelId: string, messageId: string): Promise> { + if (adapter.nextError) { + const e = adapter.nextError; + adapter.nextError = undefined; + return err(e); + } + recorded.calls.push({ op: "delete", id: messageId }); + return ok(undefined); + }, + + onMessage(_handler: MessageHandler): void { + // no-op: this fake never receives inbound messages. + }, + + async platformAction( + action: string, + params: Record, + ): Promise> { + return ok({ action, params }); + }, + + getStatus(): ChannelStatus { + return { connected: true, channelId, channelType: "googlechat" }; + }, + }; + + return adapter; +} + +// --- Deterministic frame builders -------------------------------------------- + +function makeEvent(overrides: Partial = {}): ActivityEvent { + return { + schemaVersion: 1, + activityId: "00000000-0000-0000-0000-000000000000", + sessionKey: "sess-a", + agentId: "main", + traceId: "trace-a", + ts: "2026-07-05T00:00:00.000Z", + phase: "progress", + status: "running", + kind: "tool", + semanticPhase: "tool", + defaultLabel: "working", + ...overrides, + } as ActivityEvent; +} + +function makeFrame(frameSeq: number, label: string): ActivityRenderFrame { + return { + frameSeq, + visibleEvents: [makeEvent({ defaultLabel: label })], + groupedActivityIds: {}, + planSnapshot: undefined, + changeSet: { added: [], edited: [], removed: [] }, + }; +} + +function approval(overrides: Partial = {}): ApprovalCorrelation { + return { + shortId: "GcH789Abc012", + expiresAt: 300000, + choices: [ + { id: "approve", defaultLabel: "Approve", style: "primary" }, + { id: "deny", defaultLabel: "Deny", style: "danger" }, + ], + ...overrides, + }; +} + +function approvalFrame(corr: ApprovalCorrelation = approval()): ActivityRenderFrame { + const event: ActivityEvent = { + schemaVersion: 1, + activityId: "00000000-0000-0000-0000-000000000000", + sessionKey: "sess-a", + agentId: "main", + traceId: "trace-a", + ts: "2026-07-05T00:00:00.000Z", + phase: "progress", + status: "running", + kind: "approval", + semanticPhase: "tool", + defaultLabel: "approval required: bash", + approval: corr, + } as ActivityEvent; + return { + frameSeq: 0, + visibleEvents: [event], + groupedActivityIds: {}, + planSnapshot: undefined, + changeSet: { added: [], edited: [], removed: [] }, + }; +} + +function receiptAt(deliveredAtMs: number): FinalDeliveryReceipt { + return { ok: true, deliveredChunks: 1, lastChunkMessageId: "msg-final", deliveredAtMs }; +} + +const SECRET = "test-callback-signing-secret-0123456789"; +const sign = (choice: "approve" | "deny" | "details", shortId: string): string => + signCallbackData(SECRET, choice, shortId); + +const btnRow: RichButton[][] = [[{ text: "Approve", callback_data: "v1.approve.x.y" }]]; + +// --- classifyGoogleChatRenderError (structural status → renderer union) ------- + +describe("classifyGoogleChatRenderError (structural status onto the closed ActivityRenderError union)", () => { + it("maps a 429 with retryAfter to rate_limited with retryAfter*1000", () => { + expect(classifyGoogleChatRenderError(mkError({ status: 429, retryAfter: 2 }))).toEqual({ + kind: "rate_limited", + retryAfterMs: 2000, + }); + }); + + it("defaults a 429 with no retryAfter to a 1s floor", () => { + expect(classifyGoogleChatRenderError(mkError({ status: 429 }))).toEqual({ + kind: "rate_limited", + retryAfterMs: 1000, + }); + }); + + it("maps a 401 to permission (status only — no token/body copied into detail)", () => { + const r = classifyGoogleChatRenderError(mkError({ status: 401 })); + expect(r.kind).toBe("permission"); + if (r.kind === "permission") { + expect(r.detail).toContain("401"); + expect(r.detail).not.toContain("Bearer"); + } + }); + + it("maps a 403 to permission", () => { + expect(classifyGoogleChatRenderError(mkError({ status: 403 })).kind).toBe("permission"); + }); + + it("maps a 404 to not_supported:edit (drop further edits)", () => { + expect(classifyGoogleChatRenderError(mkError({ status: 404 }))).toEqual({ + kind: "not_supported", + capability: "edit", + }); + }); + + it("maps a 500 to internal (not a rate-limit; carries the cause)", () => { + const e = mkError({ status: 500 }); + expect(classifyGoogleChatRenderError(e)).toEqual({ kind: "internal", cause: e }); + }); + + it("maps a status-less bare Error (transport fault) to internal carrying the cause", () => { + const e = new Error("boom"); + expect(classifyGoogleChatRenderError(e)).toEqual({ kind: "internal", cause: e }); + }); + + it("reads the status off error.cause when the adapter wrapped it there", () => { + const cause = mkError({ status: 429, retryAfter: 3 }); + const wrapped = new Error("chat edit failed", { cause }); + expect(classifyGoogleChatRenderError(wrapped)).toEqual({ kind: "rate_limited", retryAfterMs: 3000 }); + }); +}); + +// --- makeGoogleChatRenderActions (send/edit/delete, card-frame tracking) ------ + +describe("makeGoogleChatRenderActions (Result discipline, card-frame tracking, guards)", () => { + it("send WITH buttons posts a card, records the id, and forwards { buttons }", async () => { + const fake = createFakeGoogleChatAdapter(); + const actions = makeGoogleChatRenderActions(fake, "spaces/AAAA"); + const r = await actions.send("placeholder", { buttons: btnRow }); + expect(r.ok && r.value).toBe("googlechat-msg-0"); + const send = fake.recorded.calls.find((c): c is Extract => c.op === "send"); + expect(send?.buttons).toEqual(btnRow); + }); + + it("send WITHOUT buttons is a plain send (undefined options) and records NO card frame", async () => { + const fake = createFakeGoogleChatAdapter(); + const actions = makeGoogleChatRenderActions(fake, "spaces/AAAA"); + const sent = await actions.send("plain"); + expect(sent.ok).toBe(true); + const send = fake.recorded.calls.find((c): c is Extract => c.op === "send"); + expect(send?.buttons).toBeUndefined(); + + // A subsequent finalize-shaped edit (successLabel) on this NON-card frame stays + // text-only — a plain completion is never turned into a card. + if (sent.ok) { + await actions.edit(sent.value, successLabel()); + const edit = fake.recorded.calls.find((c): c is Extract => c.op === "edit"); + expect(edit?.cards).toBeUndefined(); + } + }); + + it("maps a failing sendMessage through the classifier without throwing", async () => { + const fake = createFakeGoogleChatAdapter(); + const actions = makeGoogleChatRenderActions(fake, "spaces/AAAA"); + fake.nextError = mkError({ status: 403 }); + const r = await actions.send("placeholder"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.kind).toBe("permission"); + }); + + it("RETIRE TIMING: a mid-wait streaming edit of a card frame patches TEXT ONLY (buttons kept)", async () => { + const fake = createFakeGoogleChatAdapter(); + const actions = makeGoogleChatRenderActions(fake, "spaces/AAAA"); + const sent = await actions.send("card", { buttons: btnRow }); + expect(sent.ok).toBe(true); + if (!sent.ok) return; + + // An intermediate render (anything other than the terminal successLabel). + const r = await actions.edit(sent.value, "🔧 still working"); + expect(r.ok).toBe(true); + const edit = fake.recorded.calls.find((c): c is Extract => c.op === "edit"); + expect(edit?.text).toBe("🔧 still working"); + // No cardsV2 patch → the card + buttons are left intact by the text-only mask. + expect(edit?.cards).toBeUndefined(); + }); + + it("RETIRE TIMING: the terminal successLabel edit of a card frame patches a button-less card", async () => { + const fake = createFakeGoogleChatAdapter(); + const actions = makeGoogleChatRenderActions(fake, "spaces/AAAA"); + const sent = await actions.send("card", { buttons: btnRow }); + expect(sent.ok).toBe(true); + if (!sent.ok) return; + + const r = await actions.edit(sent.value, successLabel()); + expect(r.ok).toBe(true); + const edit = fake.recorded.calls.find((c): c is Extract => c.op === "edit"); + // A cardsV2 patch is fired, and the resolved card carries NO buttons → the + // interactive widgets are retired in place. + expect(edit?.cards).toBeDefined(); + expect(edit?.cards?.length).toBeGreaterThan(0); + expect(edit?.cards?.[0]?.buttons).toBeUndefined(); + }); + + it("RETIRE TIMING: a successLabel edit of a NON-card frame stays text-only (no cards)", async () => { + const fake = createFakeGoogleChatAdapter(); + const actions = makeGoogleChatRenderActions(fake, "spaces/AAAA"); + // Never sent with buttons → not a card frame, even for the terminal label. + const r = await actions.edit("spaces/AAAA/messages/plain", successLabel()); + expect(r.ok).toBe(true); + const edit = fake.recorded.calls.find((c): c is Extract => c.op === "edit"); + expect(edit?.cards).toBeUndefined(); + }); + + it("guards an absent editMessage method — returns err(not_supported:edit) WITHOUT throwing", async () => { + const fake = createFakeGoogleChatAdapter(); + const noEdit = { ...fake, editMessage: undefined } as unknown as FakeGoogleChatAdapter; + const actions = makeGoogleChatRenderActions(noEdit, "spaces/AAAA"); + const r = await actions.edit("googlechat-msg-0", "x"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toEqual({ kind: "not_supported", capability: "edit" }); + }); + + it("retries a 429 edit once via the timer then resolves by editing the latest text", async () => { + const timer = createFakeTimers(); + const fake = createFakeGoogleChatAdapter(); + const actions = makeGoogleChatRenderActions(fake, "spaces/AAAA", { timer }); + + fake.nextError = mkError({ status: 429, retryAfter: 1 }); + const r = await actions.edit("googlechat-msg-0", "updated"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toEqual({ kind: "rate_limited", retryAfterMs: 1000 }); + expect(fake.recorded.calls.filter((c) => c.op === "edit")).toHaveLength(0); + + timer.advance(1000); + await Promise.resolve(); + await Promise.resolve(); + + const edits = fake.recorded.calls.filter((c): c is Extract => c.op === "edit"); + expect(edits).toHaveLength(1); + expect(edits[0].text).toBe("updated"); + }); + + it("bounds a sustained 429 storm (never unbounded) and stays terminal rate_limited", async () => { + const timer = createFakeTimers(); + const fake = createFakeGoogleChatAdapter(); + const actions = makeGoogleChatRenderActions(fake, "spaces/AAAA", { timer }); + + fake.alwaysEditError = mkError({ status: 429, retryAfter: 1 }); + const r = await actions.edit("googlechat-msg-0", "x"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.kind).toBe("rate_limited"); + + for (let i = 0; i < 20; i++) { + timer.advance(1000); + await Promise.resolve(); + await Promise.resolve(); + } + + expect(fake.editAttempts()).toBeGreaterThan(1); + expect(fake.editAttempts()).toBeLessThanOrEqual(5); + expect(fake.recorded.calls.some((c) => c.op === "edit")).toBe(false); + }); + + it("stops all further edits after a 404 (message gone) not_supported classification", async () => { + const timer = createFakeTimers(); + const fake = createFakeGoogleChatAdapter(); + const actions = makeGoogleChatRenderActions(fake, "spaces/AAAA", { timer }); + + fake.nextError = mkError({ status: 404 }); + const first = await actions.edit("googlechat-msg-0", "gone"); + expect(first.ok).toBe(false); + if (!first.ok) expect(first.error).toEqual({ kind: "not_supported", capability: "edit" }); + + // A subsequent edit is short-circuited without touching the adapter again. + const attemptsAfterFirst = fake.editAttempts(); + const second = await actions.edit("googlechat-msg-0", "again"); + expect(second.ok).toBe(false); + if (!second.ok) expect(second.error.kind).toBe("not_supported"); + expect(fake.editAttempts()).toBe(attemptsAfterFirst); + }); + + it("delete → deleteMessage; a missing method returns err(not_supported:delete)", async () => { + const fake = createFakeGoogleChatAdapter(); + const actions = makeGoogleChatRenderActions(fake, "spaces/AAAA"); + const okDel = await actions.delete("googlechat-msg-0"); + expect(okDel.ok).toBe(true); + expect(fake.recorded.calls.some((c) => c.op === "delete")).toBe(true); + + const noDelete = { ...fake, deleteMessage: undefined } as unknown as FakeGoogleChatAdapter; + const actions2 = makeGoogleChatRenderActions(noDelete, "spaces/AAAA"); + const r = await actions2.delete("googlechat-msg-0"); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error).toEqual({ kind: "not_supported", capability: "delete" }); + }); +}); + +// --- createGoogleChatActivityRenderer (EditPlace wiring; retire timing E2E) ---- + +describe("createGoogleChatActivityRenderer (EditPlace wiring + signer consumption)", () => { + it("returns the EditPlace ChannelActivityRenderer surface", () => { + const timer = createFakeTimers(); + const clock = createFakeClock(0); + const fake = createFakeGoogleChatAdapter(); + const r = createGoogleChatActivityRenderer(fake, "spaces/AAAA", { timer, clock }); + expect(r.strategy).toBe("EditPlace"); + expect(r.canEdit).toBe(true); + expect(r.canDelete).toBe(true); + expect(typeof r.apply).toBe("function"); + expect(typeof r.finalize).toBe("function"); + }); + + it("paints a kind:'approval' frame as signed RichButton rows via the injected signer", async () => { + const timer = createFakeTimers(); + const clock = createFakeClock(0); + const fake = createFakeGoogleChatAdapter(); + const r = createGoogleChatActivityRenderer(fake, "spaces/AAAA", { timer, clock, signCallbackData: sign }); + + await r.apply(approvalFrame()); + + const send = fake.recorded.calls.find((c): c is Extract => c.op === "send"); + expect(send?.buttons).toBeDefined(); + const flat = (send?.buttons ?? []).flat(); + expect(flat).toHaveLength(2); + expect(flat[0]).toEqual({ + text: "Approve", + callback_data: `v1.approve.GcH789Abc012.${sign("approve", "GcH789Abc012")}`, + style: "primary", + }); + }); + + it("a non-approval frame carries NO buttons (button-less send stays byte-stable)", async () => { + const timer = createFakeTimers(); + const clock = createFakeClock(0); + const fake = createFakeGoogleChatAdapter(); + const r = createGoogleChatActivityRenderer(fake, "spaces/AAAA", { timer, clock, signCallbackData: sign }); + + await r.apply(makeFrame(0, "running tool")); + + const send = fake.recorded.calls.find((c): c is Extract => c.op === "send"); + expect(send?.buttons).toBeUndefined(); + }); + + it("END-TO-END retire timing: buttons SURVIVE a mid-wait streaming edit, RETIRE on the resolve", async () => { + const timer = createFakeTimers(); + const clock = createFakeClock(0); + const fake = createFakeGoogleChatAdapter(); + const r = createGoogleChatActivityRenderer(fake, "spaces/AAAA", { timer, clock, signCallbackData: sign }); + + // 1) Approval frame → the placeholder posts a card WITH buttons. + await r.apply(approvalFrame()); + const send = fake.recorded.calls.find((c): c is Extract => c.op === "send"); + expect(send?.buttons).toBeDefined(); + + // 2) A refresh mid-wait schedules the debounce; firing it flushes ONE + // streaming edit. It must be TEXT ONLY — the buttons stay clickable. + clock.advance(500); + await r.apply(makeFrame(1, "still working")); + timer.advance(DEBOUNCE_MS); + await Promise.resolve(); + await Promise.resolve(); + + const midEdits = fake.recorded.calls.filter((c): c is Extract => c.op === "edit"); + expect(midEdits).toHaveLength(1); + expect(midEdits[0].cards).toBeUndefined(); + + // 3) Resolve (finalize success). deliveredAtMs in the future defers the delete + // so it does not race this assertion. The closing edit is a button-less + // cardsV2 patch — the buttons are retired in place. + const deliveredAtMs = clock.now() + 10_000; + await r.finalize({ kind: "success", trivial: false, delivery: receiptAt(deliveredAtMs) }); + await Promise.resolve(); + await Promise.resolve(); + + const edits = fake.recorded.calls.filter((c): c is Extract => c.op === "edit"); + const resolveEdit = edits[edits.length - 1]; + expect(resolveEdit.text).toBe(successLabel()); + expect(resolveEdit.cards).toBeDefined(); + expect(resolveEdit.cards?.[0]?.buttons).toBeUndefined(); + // No delete raced the resolve (deliveredAt is in the future). + expect(fake.recorded.calls.some((c) => c.op === "delete")).toBe(false); + }); + + it("END-TO-END: a non-approval completion stays TEXT-ONLY through its own finalize", async () => { + const timer = createFakeTimers(); + const clock = createFakeClock(0); + const fake = createFakeGoogleChatAdapter(); + const r = createGoogleChatActivityRenderer(fake, "spaces/AAAA", { timer, clock, signCallbackData: sign }); + + await r.apply(makeFrame(0, "tool")); + const deliveredAtMs = clock.now() + 10_000; + await r.finalize({ kind: "success", trivial: false, delivery: receiptAt(deliveredAtMs) }); + await Promise.resolve(); + await Promise.resolve(); + + const edits = fake.recorded.calls.filter((c): c is Extract => c.op === "edit"); + // The finalize success edit exists and is text-only — no card is ever attached. + expect(edits.length).toBeGreaterThan(0); + for (const e of edits) expect(e.cards).toBeUndefined(); + }); +}); From 83b2df12ff32cbeef2c34fcdc1ec071c8f3f9945 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:55:31 +0300 Subject: [PATCH 088/200] feat(235-06): Google Chat EditPlace render-actions with terminal-gated card re-render - classifyGoogleChatRenderError maps a Chat REST failure onto the closed ActivityRenderError union by structural status (direct + off error.cause), content-free - makeGoogleChatRenderActions tracks card frames (send with buttons) and retires the buttons ONLY on the terminal resolving render, recognized by exact-match on the shared successLabel(markers): a button-less cardsV2 patch retires them in place; every other edit patches text only, so mid-wait streaming refreshes keep Approve/Deny clickable and completions stay text - local bounded 429 latest-text retry buffer + 404 edits-dropped short-circuit - createGoogleChatActivityRenderer wraps the shared createEditPlaceRenderer and threads markers into both the machine and the render-actions --- .../src/googlechat/googlechat-activity.ts | 283 ++++++++++++++++++ 1 file changed, 283 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-activity.ts diff --git a/packages/channels/src/googlechat/googlechat-activity.ts b/packages/channels/src/googlechat/googlechat-activity.ts new file mode 100644 index 000000000..508d4f697 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-activity.ts @@ -0,0 +1,283 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat EditPlace activity renderer. + * + * Structurally mirrors the other card-capable renderers — three parts: + * + * 1. `classifyGoogleChatRenderError` — maps a Chat REST failure onto the CLOSED + * `ActivityRenderError` union (never the send-path observability taxonomy, + * which reports `errorKind` / `retryable` for logging rather than a render + * variant). It reads the STRUCTURAL numeric `status` (and an optional + * `retryAfter` in seconds) off the error itself and — when the adapter + * wrapped the failure in `new Error(msg, { cause })` — off `error.cause`. The + * status is consulted ONLY to choose the variant, never rendered or logged as + * activity text, and no token/header/body is copied into the payload. + * Mapping: `429` → rate_limited; `401`/`403` → permission; `404` (message + * gone) → not_supported:edit (drop edits); anything else → internal. + * + * 2. `makeGoogleChatRenderActions` — the `ActivityRenderActions` adapter. `send` + * posts the placeholder and forwards the signed approval buttons when an + * approval frame produced them, RECORDING that message as a card frame (a + * plain send records nothing). `edit`/`delete` guard the optional + * `ChannelPort` methods and classify every failure structurally. + * + * Terminal-gated card re-render (the one deliberate divergence from a + * text-only renderer): the shared EditPlace machine calls the SAME 2-arg + * `edit(id, text)` for BOTH the debounced streaming refresh (the frame text) + * AND the finalize success closing render (the shared `successLabel`). The + * port carries no discriminator and the shared machine is not this module's + * to change, so the render-actions recognizes the terminal render by + * EXACT-matching `successLabel(markers)` — reusing the shared helper so the + * recognized string can never drift from what the machine emits. On the + * terminal render of a CARD frame it patches a button-less card (the cardsV2 + * patch) so the resolved buttons are retired in place; every other edit + * patches text only. Because a text-only patch leaves the card's widgets + * untouched, a mid-wait streaming refresh keeps Approve/Deny clickable, and a + * plain (non-card) completion stays text. A non-success terminal (failure / + * aborted) does not match the success label and is left text-only — the + * buttons stay inert (the downstream router rejects a stale click). + * + * 3. `createGoogleChatActivityRenderer` — wires the shared + * `createEditPlaceRenderer` (the debounce / edit / delete-after-delivery + * state machine). It does NOT re-implement any rendering logic and threads + * the resolved markers into BOTH the machine AND the render-actions so both + * compute the identical `successLabel(markers)`. + * + * 429 backoff: the channels package depends on `core` + `shared` only — no + * observability substrate — so the buffer is a LOCAL fixed-cap latest-text slot + * with a `retryAfterMs`-gated retry through the injected `TimerPort` (the handle + * is `unref`'d and `cancel()`-able). It coalesces to the latest text and never + * grows unbounded; a `not_supported` (message-gone) edit drops all further edits. + */ +import { ok, err, type Result } from "@comis/shared"; +import type { + ChannelActivityRenderer, + ActivityRenderError, + ChannelPort, + TimerPort, + TimerHandle, + ClockPort, + ActivityStatusMarkers, +} from "@comis/core"; +import type { ActivityRenderActions } from "../shared/strategies/actions.js"; +import { createEditPlaceRenderer } from "../shared/strategies/edit-place.js"; +import { successLabel } from "../shared/strategies/render.js"; +import { + buildApprovalButtons, + type SignCallbackData, +} from "../shared/strategies/approval-render.js"; + +/** Structural subset of a Chat REST error the classifier reads (also off `error.cause`). */ +interface GoogleChatRenderErrorFields { + /** The HTTP status of the Chat response (e.g. 429, 401, 404). */ + status?: number; + /** Rate-limit backoff in SECONDS (the `Retry-After` header value). */ + retryAfter?: number; + cause?: unknown; +} + +/** + * Classify a raw Chat REST failure into the closed {@link ActivityRenderError} + * union by its STRUCTURAL numeric `status`. Reads `status`/`retryAfter` off the + * error itself and, when the adapter wrapped the failure in + * `new Error(msg, { cause })`, off `error.cause`. The status is consulted ONLY to + * pick the variant — never rendered or logged, and no token/body is copied in. + */ +export function classifyGoogleChatRenderError(e: unknown): ActivityRenderError { + const direct = (e ?? {}) as GoogleChatRenderErrorFields; + // Prefer the status attached directly; when absent, unwrap the cause the + // adapter attached (`new Error(msg, { cause })`). + const ce: GoogleChatRenderErrorFields = + direct.status === undefined && direct.cause != null + ? ((direct.cause as GoogleChatRenderErrorFields) ?? direct) + : direct; + + const status = ce.status; + if (status === 429) { + return { kind: "rate_limited", retryAfterMs: (ce.retryAfter ?? 1) * 1000 }; + } + if (status === 401 || status === 403) { + // Content-free: only the numeric status, never a token/header/body. + return { kind: "permission", detail: `chat status ${status}` }; + } + if (status === 404) { + // The message is gone — stop editing entirely. + return { kind: "not_supported", capability: "edit" }; + } + return { kind: "internal", cause: e }; +} + +/** Optional deps for the render-actions. */ +export interface GoogleChatRenderActionsDeps { + /** Timer for the local 429 retry buffer. Omit it and a 429 simply propagates. */ + timer?: TimerPort; + /** + * Resolved theme markers. MUST be the same markers the shared machine is given + * so `successLabel(markers)` here matches the finalize success closing text + * verbatim — that exact match is how the terminal resolving render is + * recognized (and thus when the buttons are retired). + */ + markers?: ActivityStatusMarkers; +} + +/** Latest-text retry cap — a 429 storm coalesces to the latest text; we never replay a backlog. */ +const MAX_RETRY_ATTEMPTS = 4; + +/** + * Build the {@link ActivityRenderActions} for a Google Chat space. `send` records + * a card frame when it painted buttons; `edit` retires the buttons ONLY on the + * terminal resolving render (exact-match on `successLabel(markers)`) via a + * button-less cardsV2 patch, and patches text only otherwise; `delete` is the + * delete-on-success op. When a `timer` is supplied, a `rate_limited` edit + * schedules a single bounded retry of the LATEST text. + */ +export function makeGoogleChatRenderActions( + adapter: ChannelPort, + channelId: string, + deps: GoogleChatRenderActionsDeps = {}, +): ActivityRenderActions { + const { timer, markers } = deps; + // The exact text the finalize success closing edit emits — recognizing it is + // how the terminal resolving render is told apart from a mid-wait refresh. + const resolveText = successLabel(markers); + + // Message ids sent as interactive cards. Only these retire buttons on resolve; + // a plain completion is never turned into a card. + const cardFrameIds = new Set(); + + // --- Local bounded 429 buffer (single latest-text slot + single retry) --- + let pendingText: string | undefined; + let pendingId: string | undefined; + let retryHandle: TimerHandle | undefined; + let retryAttempts = 0; + let editsDropped = false; + + function cancelRetry(): void { + if (retryHandle && !retryHandle.cancelled) retryHandle.cancel(); + retryHandle = undefined; + } + + async function attemptEdit(id: string, text: string): Promise> { + if (editsDropped) return err({ kind: "not_supported", capability: "edit" }); + if (!adapter.editMessage) return err({ kind: "not_supported", capability: "edit" }); + + // Retire the buttons ONLY on the terminal resolving render of a card frame: + // the button-less resolved card, patched through the adapter's pinned + // `text,cardsV2` mask, replaces the interactive widgets in place. Every other + // edit (mid-wait streaming refresh, non-card completion, non-success terminal) + // patches text only — the `text` mask leaves any existing card untouched. + const isResolve = cardFrameIds.has(id) && text === resolveText; + const r = isResolve + ? await adapter.editMessage(channelId, id, text, { cards: [{ description: text }] }) + : await adapter.editMessage(channelId, id, text); + + if (r.ok) { + cancelRetry(); + pendingText = undefined; + retryAttempts = 0; + return ok(undefined); + } + + const classified = classifyGoogleChatRenderError(r.error); + if (classified.kind === "not_supported") { + // Message gone → stop editing entirely. + editsDropped = true; + cancelRetry(); + pendingText = undefined; + return err(classified); + } + if (classified.kind === "rate_limited" && timer !== undefined) { + pendingText = text; + pendingId = id; + scheduleRetry(timer, classified.retryAfterMs); + } + return err(classified); + } + + function scheduleRetry(t: TimerPort, retryAfterMs: number): void { + if (retryAttempts >= MAX_RETRY_ATTEMPTS) { + cancelRetry(); + pendingText = undefined; + return; + } + cancelRetry(); + retryAttempts += 1; + retryHandle = t.setTimeout(() => { + const text = pendingText; + const id = pendingId; + retryHandle = undefined; + if (text === undefined || id === undefined || editsDropped) return; + void attemptEdit(id, text); + }, retryAfterMs); + retryHandle.unref(); + } + + return { + async send(text, opts): Promise> { + // The placeholder carries the signed approval buttons when an approval frame + // produced them (each button's callback_data is the wire string + // v1...); a non-approval / absent-signer frame + // forwards none, leaving a bare text message. Buttons are a DISPLAY + // affordance — the InteractiveCallbackRouter owns resolution. + const r = await adapter.sendMessage( + channelId, + text, + opts?.buttons !== undefined ? { buttons: opts.buttons } : undefined, + ); + if (!r.ok) return err(classifyGoogleChatRenderError(r.error)); + // Record the message as a card frame only when it actually carried + // interactive buttons — the resolve later retires them in place. + if ((opts?.buttons?.length ?? 0) > 0) cardFrameIds.add(r.value); + return ok(r.value); + }, + + async edit(id, text): Promise> { + return attemptEdit(id, text); + }, + + async delete(id): Promise> { + cancelRetry(); + pendingText = undefined; + // The required delete-on-success op (gated on deliveredAtMs by the + // EditPlace finalize). + if (!adapter.deleteMessage) return err({ kind: "not_supported", capability: "delete" }); + const r = await adapter.deleteMessage(channelId, id); + return r.ok ? ok(undefined) : err(classifyGoogleChatRenderError(r.error)); + }, + }; +} + +/** + * Create the Google Chat EditPlace activity renderer — wires the + * {@link createEditPlaceRenderer} with the per-space render-actions adapter. The + * daemon composition root constructs this with its runtime `TimerPort` / + * `ClockPort` and the space id. + * + * `signCallbackData` is the secret-bound signer injected at the composition root: + * the renderer CONSUMES it to build the signed approval button rows and never + * imports the orchestrator package. When omitted, an approval frame degrades to a + * button-less text prompt. + * + * `markers` is threaded into BOTH the shared machine AND the render-actions so the + * render-actions computes the SAME `successLabel(markers)` the finalize emits — + * the exact-match that gates the button retire. + */ +export function createGoogleChatActivityRenderer( + adapter: ChannelPort, + channelId: string, + deps: { timer: TimerPort; clock: ClockPort; signCallbackData?: SignCallbackData; markers?: ActivityStatusMarkers }, +): ChannelActivityRenderer { + const { signCallbackData } = deps; + return createEditPlaceRenderer({ + actions: makeGoogleChatRenderActions(adapter, channelId, { timer: deps.timer, markers: deps.markers }), + timer: deps.timer, + clock: deps.clock, + markers: deps.markers, + // Approval frame → signed native button rows. The signer is the only path to + // the callback wire; without it, no buttons are painted. + buildButtons: + signCallbackData === undefined + ? undefined + : (events) => events.flatMap((event) => buildApprovalButtons(event, signCallbackData)), + }); +} From d1835f4c7cff75f661cd6c326a2ebb1e41a13599 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:56:46 +0300 Subject: [PATCH 089/200] test(235-06): add failing composition case for Google Chat EditPlace registration MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - assert googlechat produces a live EditPlace factory (present in the returned map) — fails until googlechat is added to the EditPlaceChannel union + factory map + barrel export - add googlechat to the edit-capable dispatch array (size 5 -> 6) --- .../setup-channels-activity-renderers.test.ts | 30 ++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/packages/daemon/src/wiring/setup-channels/setup-channels-activity-renderers.test.ts b/packages/daemon/src/wiring/setup-channels/setup-channels-activity-renderers.test.ts index e51c094a6..c9a485f76 100644 --- a/packages/daemon/src/wiring/setup-channels/setup-channels-activity-renderers.test.ts +++ b/packages/daemon/src/wiring/setup-channels/setup-channels-activity-renderers.test.ts @@ -149,10 +149,32 @@ describe("buildActivityRenderers", () => { expect(typeof renderer.finalize).toBe("function"); }); + it("constructs an EditPlace renderer factory for Google Chat and marks it live in the map", () => { + // Google Chat declares editMessages:true, so selectStrategy routes it to + // EditPlace. Before the registration it routed to EditPlace but was ABSENT + // from the factory map — silently live:false. The factory-map slot is what + // makes that routing LIVE: presence in the returned map == live:true. + const adapters = new Map([["googlechat", makeStubAdapter("googlechat")]]); + const plugins = new Map([ + ["googlechat", makeStubPlugin("googlechat", makeCaps({ editMessages: true }))], + ]); + + const { timer, clock } = makeTime(); + const renderers = buildActivityRenderers(adapters, plugins, makeLogger(), { timer, clock }); + + const factory = renderers.get("googlechat"); + expect(factory).toBeDefined(); + expect(renderers.size).toBe(1); + const renderer = factory!("spaces/AAAA"); + expect(renderer.strategy).toBe("EditPlace"); + expect(typeof renderer.apply).toBe("function"); + expect(typeof renderer.finalize).toBe("function"); + }); + it("dispatches each edit-capable channelType to its own EditPlace factory", () => { - // Closed dispatch on channelType: telegram/discord/slack/whatsapp/msteams each - // map to their own createActivityRenderer. - const editChannels = ["telegram", "discord", "slack", "whatsapp", "msteams"] as const; + // Closed dispatch on channelType: telegram/discord/slack/whatsapp/msteams/ + // googlechat each map to their own createActivityRenderer. + const editChannels = ["telegram", "discord", "slack", "whatsapp", "msteams", "googlechat"] as const; const adapters = new Map(editChannels.map((c) => [c, makeStubAdapter(c)])); const plugins = new Map( editChannels.map((c) => [c, makeStubPlugin(c, makeCaps({ editMessages: true }))]), @@ -161,7 +183,7 @@ describe("buildActivityRenderers", () => { const { timer, clock } = makeTime(); const renderers = buildActivityRenderers(adapters, plugins, makeLogger(), { timer, clock }); - expect(renderers.size).toBe(5); + expect(renderers.size).toBe(6); for (const c of editChannels) { const factory = renderers.get(c); expect(factory, `factory for ${c}`).toBeDefined(); From c2bd131107c54b3f0795122700b2e82b5668b943 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Sun, 5 Jul 2026 23:57:51 +0300 Subject: [PATCH 090/200] feat(235-06): register Google Chat in the EditPlace factory map (close the live gap) - barrel-export createGoogleChatActivityRenderer (+ classifier + render-actions) from @comis/channels - add googlechat to the EditPlaceChannel union and the closed EDIT_PLACE_RENDERER_FACTORIES map, so selectStrategy=EditPlace now produces a live per-channelId factory instead of the silent live:false gap - the closed RendererFactoryMap makes the map entry a tsc requirement once the union member lands --- packages/channels/src/index.ts | 1 + .../setup-channels/setup-channels-activity-renderers.ts | 4 +++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/index.ts b/packages/channels/src/index.ts index 98fc30888..1c80c02b2 100644 --- a/packages/channels/src/index.ts +++ b/packages/channels/src/index.ts @@ -403,6 +403,7 @@ export { createTelegramActivityRenderer, classifyTelegramError } from "./telegra export { createDiscordActivityRenderer } from "./discord/discord-activity.js"; export { createSlackActivityRenderer } from "./slack/slack-activity.js"; export { createMSTeamsActivityRenderer } from "./msteams/msteams-activity.js"; +export { createGoogleChatActivityRenderer } from "./googlechat/googlechat-activity.js"; export { createWhatsAppActivityRenderer } from "./whatsapp/whatsapp-activity.js"; export { createEchoActivityRenderer } from "./echo/echo-activity.js"; // Non-EditPlace strategy factories — wired by diff --git a/packages/daemon/src/wiring/setup-channels/setup-channels-activity-renderers.ts b/packages/daemon/src/wiring/setup-channels/setup-channels-activity-renderers.ts index fe2bd301f..9bbd5b3b0 100644 --- a/packages/daemon/src/wiring/setup-channels/setup-channels-activity-renderers.ts +++ b/packages/daemon/src/wiring/setup-channels/setup-channels-activity-renderers.ts @@ -67,6 +67,7 @@ import { createIrcActivityRenderer, createEmailActivityRenderer, createMSTeamsActivityRenderer, + createGoogleChatActivityRenderer, } from "@comis/channels"; import type { SignCallbackData, MintApprovalLink } from "@comis/channels"; import type { ComisLogger } from "@comis/infra"; @@ -127,7 +128,7 @@ type RendererFactoryMap = Readonly> /** Channel-type key unions, one per strategy — the closed sets `selectStrategy` * can route to each strategy. Adding a channelType to a strategy is a one-line * edit here that `tsc` then forces into the matching map literal. */ -type EditPlaceChannel = "telegram" | "discord" | "slack" | "whatsapp" | "msteams"; +type EditPlaceChannel = "telegram" | "discord" | "slack" | "whatsapp" | "msteams" | "googlechat"; type DeleteAndRepostChannel = "signal"; type AppendOnlyChannel = "imessage" | "line"; type LinePerEventChannel = "irc"; @@ -145,6 +146,7 @@ const EDIT_PLACE_RENDERER_FACTORIES: RendererFactoryMap = { slack: createSlackActivityRenderer, whatsapp: createWhatsAppActivityRenderer, msteams: createMSTeamsActivityRenderer, + googlechat: createGoogleChatActivityRenderer, }; /** DeleteAndRepost → Signal (deleteMessages, no edit). Uses {timer, clock}. */ From f0b5e40e4318148c9a9d27ad0b5b55eb5c837b36 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 00:33:25 +0300 Subject: [PATCH 091/200] fix(235): CR-01 send Chat message text verbatim (markdown field, not HTML) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message `text` field is Chat-markdown and does not decode HTML entities, so routing it through an HTML-escaping formatter turned "Tom & Jerry " into "Tom & Jerry <tag>" on the wire — corrupting every message carrying &, <, or >. Pass the text through verbatim; the HTML subset (and its escaping) stays on the card textParagraph surface in the rich renderer, which is unchanged. The escaping formatter had no other consumer, so the dead module and its test are removed. --- .../src/googlechat/format-googlechat.test.ts | 109 ------------------ .../src/googlechat/format-googlechat.ts | 85 -------------- .../src/googlechat/googlechat-adapter.test.ts | 10 +- .../src/googlechat/googlechat-adapter.ts | 19 +-- 4 files changed, 18 insertions(+), 205 deletions(-) delete mode 100644 packages/channels/src/googlechat/format-googlechat.test.ts delete mode 100644 packages/channels/src/googlechat/format-googlechat.ts diff --git a/packages/channels/src/googlechat/format-googlechat.test.ts b/packages/channels/src/googlechat/format-googlechat.test.ts deleted file mode 100644 index ded23bbb9..000000000 --- a/packages/channels/src/googlechat/format-googlechat.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -import { describe, expect, it } from "vitest"; -import { escapeGoogleChatText, formatGoogleChatText } from "./format-googlechat.js"; - -describe("format-googlechat", () => { - describe("formatGoogleChatText — plain-text passthrough (load-bearing)", () => { - it("returns markup-free plain text byte-identical", () => { - expect(formatGoogleChatText("hello world")).toBe("hello world"); - }); - - it("returns an empty string unchanged", () => { - expect(formatGoogleChatText("")).toBe(""); - }); - - it("leaves newlines and surrounding whitespace byte-identical", () => { - expect(formatGoogleChatText("line one\nline two\n")).toBe("line one\nline two\n"); - }); - }); - - describe("formatGoogleChatText — Slack-mrkdwn-shaped markers pass through", () => { - it("preserves bold markers (*text*)", () => { - expect(formatGoogleChatText("*bold*")).toBe("*bold*"); - }); - - it("preserves italic markers (_text_)", () => { - expect(formatGoogleChatText("_italic_")).toBe("_italic_"); - }); - - it("preserves inline code markers (`text`)", () => { - expect(formatGoogleChatText("`code`")).toBe("`code`"); - }); - - it("preserves strikethrough markers (~text~)", () => { - expect(formatGoogleChatText("~strike~")).toBe("~strike~"); - }); - - it("preserves a multi-marker line byte-identical when it has no stray brackets", () => { - expect(formatGoogleChatText("*b* and _i_ and `c` and ~s~")).toBe( - "*b* and _i_ and `c` and ~s~", - ); - }); - }); - - describe("formatGoogleChatText — link and mention tokens are preserved", () => { - it("preserves a hyperlink token ", () => { - expect(formatGoogleChatText("See ")).toBe( - "See ", - ); - }); - - it("preserves a bare https link token", () => { - expect(formatGoogleChatText("Visit ")).toBe( - "Visit ", - ); - }); - - it("preserves a user mention token ", () => { - expect(formatGoogleChatText("Hi ")).toBe("Hi "); - }); - }); - - describe("formatGoogleChatText — stray HTML-significant characters are escaped", () => { - it("escapes a stray less-than so agent text cannot open a tag", () => { - expect(formatGoogleChatText("a < b")).toBe("a < b"); - }); - - it("escapes a stray greater-than", () => { - expect(formatGoogleChatText("a > b")).toBe("a > b"); - }); - - it("escapes a stray ampersand", () => { - expect(formatGoogleChatText("Tom & Jerry")).toBe("Tom & Jerry"); - }); - - it("escapes an unrecognized angle-bracket token (an injected tag)", () => { - expect(formatGoogleChatText("")).toBe( - "<script>alert(1)</script>", - ); - }); - - it("escapes a stray bracket after a valid link token while preserving the token", () => { - expect(formatGoogleChatText("see then 5 > 6")).toBe( - "see then 5 > 6", - ); - }); - - it("conservatively escapes a token an unmatched < swallows (never emits unbalanced markup)", () => { - // An unmatched "<" pairs with the next token's ">"; escaping the whole span - // is the safe outcome — no half-open tag ever reaches the wire. - expect(formatGoogleChatText("5 < 6 see ")).toBe( - "5 < 6 see <https://x.com|x>", - ); - }); - }); - - describe("escapeGoogleChatText — raw escaping helper", () => { - it("escapes &, <, > to HTML entities", () => { - expect(escapeGoogleChatText("a & b < c > d")).toBe("a & b < c > d"); - }); - - it("returns text with no special characters byte-identical", () => { - expect(escapeGoogleChatText("plain text")).toBe("plain text"); - }); - - it("escapes an angle-bracket token unconditionally (no token preservation)", () => { - expect(escapeGoogleChatText("")).toBe("<https://x.com>"); - }); - }); -}); diff --git a/packages/channels/src/googlechat/format-googlechat.ts b/packages/channels/src/googlechat/format-googlechat.ts deleted file mode 100644 index 7efff0f5c..000000000 --- a/packages/channels/src/googlechat/format-googlechat.ts +++ /dev/null @@ -1,85 +0,0 @@ -// SPDX-License-Identifier: Apache-2.0 -/** - * Google Chat message-text formatting: a conservative escaping boundary for the - * outbound message `text` field. - * - * Google Chat message text is Slack-mrkdwn-shaped — bold `*text*`, italic - * `_text_`, monospace `` `text` ``, strikethrough `~text~`, hyperlinks - * ``, and user mentions `` — and it ALSO - * interprets a basic HTML subset (``, `
`, …) in the same field. So the - * agent's markup is already in the shape Chat renders and is passed through - * unchanged; the boundary's only job is to escape stray `&`, `<`, `>` so a - * literal angle bracket in agent text cannot be read as an HTML tag, while the - * genuine `<…>` link/mention tokens are preserved. - * - * Markup-free plain text is returned byte-identical — an existing plain-text - * send is never altered by routing through this module. - * - * This module serves the MESSAGE body text only. Card `textParagraph` content is - * a separate surface escaped inside the rich renderer, not here. - * - * @module - */ - -const GCHAT_ANGLE_TOKEN_RE = /<[^>\n]+>/g; - -/** - * Whether an angle-bracket token is a genuine Google Chat token that must be - * preserved rather than escaped. Allowed: `` mentions and `` - * links, including the `` form (the inner still begins with the - * scheme). Anything else is treated as literal text and escaped. - */ -function isAllowedGoogleChatAngleToken(token: string): boolean { - if (!token.startsWith("<") || !token.endsWith(">")) return false; - const inner = token.slice(1, -1); - return ( - inner.startsWith("users/") || - inner.startsWith("http://") || - inner.startsWith("https://") - ); -} - -/** - * Escape the HTML-significant characters `&`, `<`, `>` to their entities. `&` is - * escaped first so an already escaped entity is never doubled. This is the raw - * helper — it escapes unconditionally and preserves no tokens. - */ -export function escapeGoogleChatText(text: string): string { - return text.replace(/&/g, "&").replace(//g, ">"); -} - -/** - * Format outbound Google Chat message text. - * - * Escapes stray `&`, `<`, `>` (so agent text cannot open an HTML tag) while - * preserving genuine `` / `` / `` tokens and leaving - * every Slack-mrkdwn-shaped marker (`*bold*`, `_italic_`, `` `code` ``, - * `~strike~`) byte-identical. Markup-free text is returned unchanged. - * - * @param text - The raw outbound message text - * @returns The text with stray HTML-significant characters escaped - */ -export function formatGoogleChatText(text: string): string { - if (!text.includes("&") && !text.includes("<") && !text.includes(">")) { - return text; - } - - GCHAT_ANGLE_TOKEN_RE.lastIndex = 0; - const out: string[] = []; - let lastIndex = 0; - - for ( - let match = GCHAT_ANGLE_TOKEN_RE.exec(text); - match; - match = GCHAT_ANGLE_TOKEN_RE.exec(text) - ) { - const matchIndex = match.index ?? 0; - out.push(escapeGoogleChatText(text.slice(lastIndex, matchIndex))); - const token = match[0] ?? ""; - out.push(isAllowedGoogleChatAngleToken(token) ? token : escapeGoogleChatText(token)); - lastIndex = matchIndex + token.length; - } - - out.push(escapeGoogleChatText(text.slice(lastIndex))); - return out.join(""); -} diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 050fde484..d58beb9f6 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -915,16 +915,20 @@ describe("createGoogleChatAdapter — sendMessage cardsV2 (cards/buttons)", () = expect("cardsV2" in body).toBe(false); }); - it("routes the message text through formatGoogleChatText (a stray angle bracket is escaped)", async () => { + it("sends the message text field byte-identical — Chat text is markdown, not HTML, so &/ are NEVER entity-escaped", async () => { const { fetchImpl, spy } = makeChatFetch(); const { deps } = await makeDeps({ fetchImpl }); const adapter = createGoogleChatAdapter(deps); - await adapter.sendMessage("spaces/AAAA", "1 < 2 & 3"); + // A plain agent message with an ampersand, an angle span, and an mrkdwn + // marker. The Chat `text` field is Chat-markdown and does NOT decode HTML + // entities, so entity-escaping it (& / < / >) would surface the + // literal entities and corrupt ordinary output. It must pass through verbatim. + await adapter.sendMessage("spaces/AAAA", "Tom & Jerry _x_"); const [, init] = sendCallOf(spy); const body = JSON.parse(String(init.body)) as { text?: string }; - expect(body.text).toBe("1 < 2 & 3"); + expect(body.text).toBe("Tom & Jerry _x_"); }); it("threads a card send: thread{name} + reply query alongside cardsV2 when threadId and buttons are both set", async () => { diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index acf944d1b..21e7e6875 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -72,7 +72,6 @@ import { renderGoogleChatCards, renderGoogleChatButtons, } from "./googlechat-rich-renderer.js"; -import { formatGoogleChatText } from "./format-googlechat.js"; // --------------------------------------------------------------------------- // Send-safety knobs for the 429-only bounded resend @@ -544,14 +543,18 @@ export function createGoogleChatAdapter( const url = threadName ? `${chatBase}/${channelId}/messages?messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD` : `${chatBase}/${channelId}/messages`; - // Format the message text through the conservative escape boundary — a - // markup-free string is returned byte-identical, so a plain send is - // unchanged. Attach a cardsV2 body ONLY when the caller supplies cards or - // button rows; otherwise the body stays the bare { text } (optionally with - // a thread) shape rather than carrying an empty cardsV2 key. + // The message `text` field is Chat-markdown (`*bold*`, `_italic_`, + // `` `code` ``, ``, ``) and does NOT decode HTML + // entities — entity-escaping it would surface literal `&`/`<` and + // corrupt ordinary output — so the agent text passes through verbatim. The + // HTML subset (and thus escaping) lives on the card `textParagraph` surface, + // handled in the rich renderer, not here. Attach a cardsV2 body ONLY when + // the caller supplies cards or button rows; otherwise the body stays the + // bare { text } (optionally with a thread) shape rather than carrying an + // empty cardsV2 key. const body: Record = threadName - ? { text: formatGoogleChatText(text), thread: { name: threadName } } - : { text: formatGoogleChatText(text) }; + ? { text, thread: { name: threadName } } + : { text }; const hasButtons = (options?.buttons?.length ?? 0) > 0; const hasCards = (options?.cards?.length ?? 0) > 0; if (hasButtons || hasCards) { From 2b37264d9897a59ed1eb48ef4815713de30fb95d Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 00:35:55 +0300 Subject: [PATCH 092/200] fix(235): WR-01 attach structural status to Chat REST errors editMessage/sendMessage/deleteMessage returned bare Errors with the HTTP status only in the message string, so the EditPlace render classifier saw status===undefined and always fell to "internal". That left the edit-path 429 retry buffer and the 404 message-gone drop as dead code in production. The REST-error branches now build a GoogleChatRestError carrying the numeric status (+ parsed Retry-After seconds on a 429), mirroring the MS Teams connector's structural REST error, so the classifier engages: 429 -> rate_limited, 404 -> not_supported (drop edits), 401/403 -> permission. Tests drive a real HTTP 429/404 through editMessage and assert the returned error's structural .status feeds the real classifier -- not an injected fake. --- .../src/googlechat/googlechat-adapter.test.ts | 51 +++++++++++++++ .../src/googlechat/googlechat-adapter.ts | 64 ++++++++++++++++++- 2 files changed, 112 insertions(+), 3 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index d58beb9f6..fe337d039 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -7,6 +7,7 @@ import { type GoogleChatAdapterDeps, } from "./googlechat-adapter.js"; import { GOOGLECHAT_APPROVAL_FUNCTION } from "./googlechat-actions.js"; +import { classifyGoogleChatRenderError } from "./googlechat-activity.js"; import type { PubSubSource, PubSubSourceDeps, @@ -1095,6 +1096,56 @@ describe("createGoogleChatAdapter — editMessage (messages.patch)", () => { const adapter = createGoogleChatAdapter(deps); expect(typeof adapter.editMessage).toBe("function"); }); + + it("a 429 PATCH returns an Error carrying structural .status + .retryAfter so the render classifier yields rate_limited (not internal)", async () => { + // Drive a REAL 429 (with a Retry-After) through the REAL adapter — no + // injected `.status`. The returned error must carry the numeric HTTP status + // and the parsed Retry-After seconds as STRUCTURAL fields; the render-error + // classifier reads those, never the message string. Without them the whole + // edit-path 429 retry buffer is dead in production. + const { fetchImpl } = makeChatFetch({ sendStatus: 429, retryAfter: "3" }); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC", + "retry me", + ); + + expect(result?.ok).toBe(false); + if (result && !result.ok) { + expect((result.error as { status?: number }).status).toBe(429); + expect((result.error as { retryAfter?: number }).retryAfter).toBe(3); + // The REAL classifier, fed the REAL adapter error, must pick rate_limited. + expect(classifyGoogleChatRenderError(result.error)).toEqual({ + kind: "rate_limited", + retryAfterMs: 3000, + }); + } + }); + + it("a 404 PATCH (message gone) returns an Error carrying .status so the classifier yields not_supported:edit (drop further edits)", async () => { + const { fetchImpl } = makeChatFetch({ sendStatus: 404 }); + const { deps } = await makeDeps({ fetchImpl }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC", + "gone", + ); + + expect(result?.ok).toBe(false); + if (result && !result.ok) { + expect((result.error as { status?: number }).status).toBe(404); + // 404 → the render classifier drops all further edits in place. + expect(classifyGoogleChatRenderError(result.error)).toEqual({ + kind: "not_supported", + capability: "edit", + }); + } + }); }); describe("createGoogleChatAdapter — editMessage cardsV2 patch", () => { diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 21e7e6875..15f507935 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -110,6 +110,37 @@ function isSafeMessageName(id: string): boolean { return id.length > 0 && !id.includes("..") && /^[A-Za-z0-9._/-]+$/.test(id); } +// --------------------------------------------------------------------------- +// Structural REST error (the edit-in-place render classifier reads it) +// --------------------------------------------------------------------------- + +/** + * A Chat REST failure carrying the numeric HTTP `status` — and, on a 429, the + * parsed `Retry-After` seconds — as STRUCTURAL fields. The EditPlace render + * classifier (`classifyGoogleChatRenderError`) picks its variant off these + * (429 → back off and retry the latest text, 404 → drop further edits, 401/403 + * → permission); a bare `Error(message)` with the status only in the string + * classifies as `internal`, so neither the rate-limit retry buffer nor the + * message-gone drop would ever engage. Mirrors the MS Teams connector's + * structural REST error. + */ +interface GoogleChatRestError extends Error { + status: number; + retryAfter?: number; +} + +/** Build a {@link GoogleChatRestError} carrying the status (+ Retry-After seconds on a 429). */ +function googleChatRestError( + message: string, + status: number, + retryAfter?: number, +): GoogleChatRestError { + const error = new Error(message) as GoogleChatRestError; + error.status = status; + if (retryAfter !== undefined) error.retryAfter = retryAfter; + return error; +} + /** Dependencies for the Google Chat adapter. */ export interface GoogleChatAdapterDeps { /** The resolved service-account key JSON string (a SecretRef resolved upstream); never logged. */ @@ -655,8 +686,17 @@ export function createGoogleChatAdapter( }, "Google Chat send failed: error status", ); + // Attach the structural status (+ Retry-After on a 429) so a send that + // exhausts its bounded 429 resends surfaces as rate_limited to the + // activity renderer rather than a status-less internal fault. + const retryAfter = + res.status === 429 ? parseRetryAfterSeconds(res, now()) : undefined; return err( - new Error(`chat messages.create returned status ${res.status}`), + googleChatRestError( + `chat messages.create returned status ${res.status}`, + res.status, + retryAfter, + ), ); } const parsed = await fromPromise( @@ -747,8 +787,17 @@ export function createGoogleChatAdapter( }, "Google Chat edit failed: error status", ); + // Attach the structural status (+ Retry-After on a 429) so the render + // classifier picks the variant: 429 → rate_limited (retry the latest + // text), 404 → not_supported (drop further edits), 401/403 → permission. + const retryAfter = + res.status === 429 ? parseRetryAfterSeconds(res, now()) : undefined; return err( - new Error(`chat messages.patch returned status ${res.status}`), + googleChatRestError( + `chat messages.patch returned status ${res.status}`, + res.status, + retryAfter, + ), ); } return ok(undefined); @@ -807,8 +856,17 @@ export function createGoogleChatAdapter( }, "Google Chat delete failed: error status", ); + // The delete failure is consumed by the same render classifier as edit; + // attach the structural status (+ Retry-After on a 429) so it is + // classified rather than collapsed to a status-less internal fault. + const retryAfter = + res.status === 429 ? parseRetryAfterSeconds(res, now()) : undefined; return err( - new Error(`chat messages.delete returned status ${res.status}`), + googleChatRestError( + `chat messages.delete returned status ${res.status}`, + res.status, + retryAfter, + ), ); } return ok(undefined); From eb592375fb09aca80bd0cf4842d9e4b0cdda8a02 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 00:37:04 +0300 Subject: [PATCH 093/200] fix(235): IN-02 escape card button labels against the HTML subset buttonToWidget emitted btn.text raw while card title/description/fields run through escapeCardText, so an agent-supplied / in a button label rendered as card markup. Escape the label for parity; the opaque signed cb callback and the url target are left byte-exact. --- .../googlechat-rich-renderer.test.ts | 18 ++++++++++++++++++ .../src/googlechat/googlechat-rich-renderer.ts | 12 +++++++++--- 2 files changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-rich-renderer.test.ts b/packages/channels/src/googlechat/googlechat-rich-renderer.test.ts index ec9dd04e7..05f6e09e7 100644 --- a/packages/channels/src/googlechat/googlechat-rich-renderer.test.ts +++ b/packages/channels/src/googlechat/googlechat-rich-renderer.test.ts @@ -200,6 +200,24 @@ describe("renderGoogleChatButtons buttonList widget", () => { expect(btns).toHaveLength(3); expect(btns.map((b) => b.text)).toEqual(["A", "B", "C"]); }); + + it("escapes &, <, > in a plain button label against the HTML subset (parity with card text widgets)", () => { + const btn = buttonsOf(renderGoogleChatButtons([[{ text: "Fish & " }]]))[0]!; + expect(btn.text).toBe("Fish & <Chips>"); + }); + + it("escapes an interactive button label while leaving the opaque signed cb param byte-exact", () => { + const buttons: RichButton[][] = [ + [{ text: "Yes & go", callback_data: SIGNED_CB }], + ]; + const btn = buttonsOf(renderGoogleChatButtons(buttons))[0]!; + expect(btn.text).toBe("<b>Yes</b> & go"); + const action = (btn.onClick as Record).action as { + parameters: Array<{ key: string; value: string }>; + }; + // The signed callback wire is opaque — escaping it would break the HMAC. + expect(action.parameters[0]!.value).toBe(SIGNED_CB); + }); }); describe("renderGoogleChatCards nested card buttons", () => { diff --git a/packages/channels/src/googlechat/googlechat-rich-renderer.ts b/packages/channels/src/googlechat/googlechat-rich-renderer.ts index ad7703364..554c0d4a9 100644 --- a/packages/channels/src/googlechat/googlechat-rich-renderer.ts +++ b/packages/channels/src/googlechat/googlechat-rich-renderer.ts @@ -82,9 +82,15 @@ function cardToWidgets(card: RichCard): Record[] { * the interactive-first precedence of the other card-based channels. */ function buttonToWidget(btn: RichButton): Record { + // The button label renders in the same Cards v2 HTML subset as textParagraph + // content, so agent-supplied label text is escaped for parity with the card + // widgets — a ``/`` in a label can never inject card markup. The + // opaque signed `cb` callback and the `url` are NOT escaped (the callback is + // an HMAC-bearing wire string; the url is a plain openLink target). + const label = escapeCardText(btn.text); if (btn.callback_data !== undefined) { return { - text: btn.text, + text: label, onClick: { action: { function: GOOGLECHAT_APPROVAL_FUNCTION, @@ -94,9 +100,9 @@ function buttonToWidget(btn: RichButton): Record { }; } if (btn.url !== undefined) { - return { text: btn.text, onClick: { openLink: { url: btn.url } } }; + return { text: label, onClick: { openLink: { url: btn.url } } }; } - return { text: btn.text }; + return { text: label }; } /** Wrap flattened button rows in one `buttonList` widget. */ From 9bd2081bad7ca16518430865a0830339c9593b3d Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 00:38:14 +0300 Subject: [PATCH 094/200] test(235): IN-03 lock the successLabel resolve-render coupling for recovered failures The render-actions retires buttons by exact-matching successLabel(markers). That silently couples to the shared finalize calling successLabel(markers) without a recoveredFailures arg for BOTH success variants. Add a cross-file E2E guard driving the real renderer through a success_with_recovered_failures finalize: it fails loudly if finalize ever emits successLabel(markers, n) for that variant and the buttons stop retiring. --- .../googlechat/googlechat-activity.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-activity.test.ts b/packages/channels/src/googlechat/googlechat-activity.test.ts index 017aa9c3e..8a60e7447 100644 --- a/packages/channels/src/googlechat/googlechat-activity.test.ts +++ b/packages/channels/src/googlechat/googlechat-activity.test.ts @@ -548,6 +548,38 @@ describe("createGoogleChatActivityRenderer (EditPlace wiring + signer consumptio expect(fake.recorded.calls.some((c) => c.op === "delete")).toBe(false); }); + it("CONTRACT: a success_with_recovered_failures finalize ALSO retires buttons (successLabel coupling holds for both success variants)", async () => { + // The render-actions recognizes the terminal render by EXACT-matching + // successLabel(markers); the shared finalize emits successLabel(markers) + // (no recoveredFailures arg) for BOTH `success` and + // `success_with_recovered_failures`, so a recovered-failures resolve must + // retire the buttons exactly like a plain success. If finalize ever switches + // to successLabel(markers, n) for this variant, the exact-match silently + // breaks and buttons stop retiring — this cross-file guard fails loudly. + const timer = createFakeTimers(); + const clock = createFakeClock(0); + const fake = createFakeGoogleChatAdapter(); + const r = createGoogleChatActivityRenderer(fake, "spaces/AAAA", { timer, clock, signCallbackData: sign }); + + await r.apply(approvalFrame()); + + const deliveredAtMs = clock.now() + 10_000; + await r.finalize({ + kind: "success_with_recovered_failures", + trivial: false, + delivery: receiptAt(deliveredAtMs), + recoveredFailures: [makeEvent()] as [ActivityEvent, ...ActivityEvent[]], + }); + await Promise.resolve(); + await Promise.resolve(); + + const edits = fake.recorded.calls.filter((c): c is Extract => c.op === "edit"); + const resolveEdit = edits[edits.length - 1]; + expect(resolveEdit?.text).toBe(successLabel()); + expect(resolveEdit?.cards).toBeDefined(); + expect(resolveEdit?.cards?.[0]?.buttons).toBeUndefined(); + }); + it("END-TO-END: a non-approval completion stays TEXT-ONLY through its own finalize", async () => { const timer = createFakeTimers(); const clock = createFakeClock(0); From c7acd65c67758f74af864281a6e0dca3c33cf1ff Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 00:38:38 +0300 Subject: [PATCH 095/200] fix(235): IN-05 document the unreachable ignored card-drop branch The card-drop switch runs inside the type === CARD_CLICKED guard, but the normalizer only returns "ignored" for a non-CARD_CLICKED event, so that case is unreachable here. Note it, keeping the switch total over the closed CardActionDropReason union. --- packages/channels/src/googlechat/googlechat-adapter.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 15f507935..c86272a07 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -360,6 +360,11 @@ export function createGoogleChatAdapter( // mirrors the non-allowlisted-sender WARN below. switch (result.reason) { case "ignored": + // Unreachable in this branch: the normalizer returns "ignored" ONLY + // for a non-CARD_CLICKED event, but this switch runs inside the + // `type === "CARD_CLICKED"` guard above. Kept so the switch stays + // total over the closed CardActionDropReason union — a benign, + // silent drop either way. break; case "unrendered-method": deps.logger.warn( From 942c1e3f8ef210ecb93b77c084ffbba688210152 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 02:08:24 +0300 Subject: [PATCH 096/200] test(236-01): add failing happy-path test for the Google Chat media resolver - schemes = [googlechat-attachment], never https - decode ref -> build media.download URL -> SA Bearer fetch pinned to the single host - sniffed MIME overrides the declared content-type; bytes/size passed through --- .../googlechat/googlechat-resolver.test.ts | 133 ++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-resolver.test.ts diff --git a/packages/channels/src/googlechat/googlechat-resolver.test.ts b/packages/channels/src/googlechat/googlechat-resolver.test.ts new file mode 100644 index 000000000..11e903a92 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-resolver.test.ts @@ -0,0 +1,133 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Attachment } from "@comis/core"; +import { ok } from "@comis/shared"; +import { describe, expect, it, vi } from "vitest"; +import { + createGoogleChatResolver, + type GoogleChatResolverDeps, +} from "./googlechat-resolver.js"; + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +/** A real 1×1 PNG (magic bytes recognized by file-type), used to prove MIME sniff. */ +const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", + "base64", +); + +/** A representative uploaded-content resource name (media.download addresses these). */ +const RESOURCE_NAME = "spaces/AAA/attachments/CCC"; + +/** The one URL a valid resolve must fetch: the app-auth media.download endpoint. */ +const EXPECTED_URL = + "https://chat.googleapis.com/v1/media/spaces/AAA/attachments/CCC?alt=media"; + +const SA_TOKEN = "SA_TOKEN"; + +function mockDeps(overrides: Partial = {}): GoogleChatResolverDeps { + return { + ssrfFetcher: { + fetch: vi.fn().mockResolvedValue( + ok({ + buffer: PNG_1X1, + mimeType: "image/png", + sizeBytes: PNG_1X1.length, + }), + ), + }, + getToken: vi.fn().mockResolvedValue(ok(SA_TOKEN)), + maxBytes: 10 * 1024 * 1024, + logger: { debug: vi.fn(), warn: vi.fn() }, + ...overrides, + }; +} + +function makeAttachment(url: string): Attachment { + return { type: "image", url }; +} + +/** Wrap a resource name exactly as the message mapper does — proves decode is the inverse of encode. */ +function attachmentUrl(resourceName: string): string { + return `googlechat-attachment://${encodeURIComponent(resourceName)}`; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe("googlechat-resolver / createGoogleChatResolver", () => { + it("declares schemes = ['googlechat-attachment'] and never claims the https scheme", () => { + const resolver = createGoogleChatResolver(mockDeps()); + expect(resolver.schemes).toEqual(["googlechat-attachment"]); + expect(resolver.schemes).not.toContain("https"); + }); + + it("decodes the ref, builds the media.download URL, and fetches it with the SA Bearer pinned to the single host", async () => { + const deps = mockDeps(); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve(makeAttachment(attachmentUrl(RESOURCE_NAME))); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.buffer).toEqual(PNG_1X1); + expect(result.value.sizeBytes).toBe(PNG_1X1.length); + } + + // Exactly one fetch, to the media.download URL, with the Bearer pinned to the + // single Chat media host (no config escape hatch). + expect(deps.ssrfFetcher.fetch).toHaveBeenCalledTimes(1); + expect(deps.ssrfFetcher.fetch).toHaveBeenCalledWith( + EXPECTED_URL, + expect.objectContaining({ + authHeader: `Bearer ${SA_TOKEN}`, + authAllowHosts: ["chat.googleapis.com"], + }), + ); + }); + + it("returns the sniffed MIME type, overriding a mislabeled declared content-type", async () => { + const deps = mockDeps({ + ssrfFetcher: { + fetch: vi.fn().mockResolvedValue( + ok({ + buffer: PNG_1X1, + mimeType: "application/octet-stream", + sizeBytes: PNG_1X1.length, + }), + ), + }, + }); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve(makeAttachment(attachmentUrl(RESOURCE_NAME))); + + expect(result.ok).toBe(true); + if (result.ok) { + // Sniffed image/png wins over the declared application/octet-stream. + expect(result.value.mimeType).toBe("image/png"); + } + }); + + it("passes the fetched bytes and size straight through on the ok result (no envelope added)", async () => { + const raw = Buffer.from("raw external media bytes"); + const deps = mockDeps({ + ssrfFetcher: { + fetch: vi.fn().mockResolvedValue( + ok({ buffer: raw, mimeType: "application/octet-stream", sizeBytes: raw.length }), + ), + }, + }); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve(makeAttachment(attachmentUrl(RESOURCE_NAME))); + + expect(result.ok).toBe(true); + if (result.ok) { + expect(result.value.buffer.equals(raw)).toBe(true); + expect(result.value.sizeBytes).toBe(raw.length); + } + }); +}); From 3e9b24cec4918ac67c947dc1f02cbb98944291c8 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 02:09:35 +0300 Subject: [PATCH 097/200] =?UTF-8?q?feat(236-01):=20createGoogleChatResolve?= =?UTF-8?q?r=20=E2=80=94=20media.download=20over=20single-host=20SSRF=20fe?= =?UTF-8?q?tch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - decode googlechat-attachment:// ref -> build media.download URL via the URL API - fetch through the injected auth-capable SSRF fetcher, Bearer pinned to chat.googleapis.com - sniff MIME from the bytes (authoritative over the declared type); secondary size cap --- .../src/googlechat/googlechat-resolver.ts | 145 ++++++++++++++++++ 1 file changed, 145 insertions(+) create mode 100644 packages/channels/src/googlechat/googlechat-resolver.ts diff --git a/packages/channels/src/googlechat/googlechat-resolver.ts b/packages/channels/src/googlechat/googlechat-resolver.ts new file mode 100644 index 000000000..2504bbb3d --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-resolver.ts @@ -0,0 +1,145 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat MediaResolverPort adapter. + * + * Resolves `googlechat-attachment://` attachments + * (emitted by the message mapper) to downloaded media buffers over the supported + * bot path: the resource name is decoded, the download URL is built from a FIXED + * host, a service-account Bearer is minted, and the bytes are fetched through the + * INJECTED auth-capable SSRF-guarded fetcher — never a bare `fetch`. The fetcher + * DNS-pins each hop and attaches the Bearer only to the single allowlisted host, + * dropping it on any cross-host redirect. + * + * The request is pinned to `chat.googleapis.com/v1/media/…` and carries only + * `alt=media`. The attachment's browser-facing download link is never read or + * fetched — it is a human-user URL that rejects a service-account Bearer. + * + * Dependency-clean by construction: this file pulls in neither the SSRF-fetcher's + * home package nor its underlying HTTP transport. The fetcher arrives as a local + * structural interface, so the DNS-pinning + redirect machinery stays in the + * package that owns the transport. + * + * The returned MIME is sniffed (the port contract mandates a verified type; a + * platform can mislabel bytes and the model vision API rejects a declared/actual + * mismatch). Raw bytes are returned — the pipeline applies the external-content + * fence on the DERIVED text (transcription/vision/doc-extract), not this resolver. + * + * @module + */ + +import type { Attachment, MediaResolverPort, ResolvedMedia } from "@comis/core"; +import { systemNowMs } from "@comis/core"; +import type { Result } from "@comis/shared"; +import { ok, err, tryCatch } from "@comis/shared"; +import { fileTypeFromBuffer } from "file-type"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +/** + * Structural interface for the auth-capable SSRF-guarded fetcher (avoids a + * circular dep on the package that owns the HTTP transport). It is the auth + * superset of the plain `fetch(url)` seam: `opts` carries the Authorization + * header value and the host allowlist the header may ride, and the fetcher + * enforces the per-hop attach/drop decision. + */ +interface SsrfFetcher { + fetch( + url: string, + opts?: { authHeader?: string; authAllowHosts?: readonly string[] }, + ): Promise>; +} + +/** Minimal logger interface for resolver logging. */ +interface ResolverLogger { + debug(obj: Record, msg: string): void; + warn(obj: Record, msg: string): void; +} + +export interface GoogleChatResolverDeps { + /** Auth-capable SSRF-guarded fetcher; the only path media bytes are fetched through. */ + ssrfFetcher: SsrfFetcher; + /** Mint the service-account Bearer for the download. A mint failure is fatal — the download always needs it. */ + getToken: () => Promise>; + /** Reject a fetched body whose reported size exceeds this many bytes. */ + maxBytes: number; + logger: ResolverLogger; +} + +/** + * The single host that may receive the service-account Bearer on a media download. + * There is no config escape hatch: the Bearer rides this host only and is dropped + * on any cross-host redirect by the injected fetcher. + */ +const CHAT_MEDIA_HOST = "chat.googleapis.com"; + +const GOOGLECHAT_ATTACHMENT_SCHEME = /^googlechat-attachment:\/\//; + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +/** + * Create a Google Chat media resolver implementing MediaResolverPort. + * + * Decodes `googlechat-attachment://` attachments, builds the download URL from the + * fixed host, mints the service-account Bearer, and drives the injected auth-capable + * SSRF-guarded fetcher pinned to the single host. Returns raw bytes with a sniffed + * MIME type. + */ +export function createGoogleChatResolver(deps: GoogleChatResolverDeps): MediaResolverPort { + return { + schemes: ["googlechat-attachment"], + + async resolve(attachment: Attachment): Promise> { + // Decode the exact inverse of the mapper's encodeURIComponent. decodeURIComponent + // throws on malformed percent-encoding and the payload is attacker-influenced, so + // the decode is guarded rather than trusted. + const decoded = tryCatch(() => + decodeURIComponent(attachment.url.replace(GOOGLECHAT_ATTACHMENT_SCHEME, "")), + ); + if (!decoded.ok) return err(decoded.error); + const resourceName = decoded.value; + + // Build the download URL via the URL API from the FIXED host. The multi-segment + // resource name goes into the path; `alt=media` is the only query parameter. + const built = tryCatch(() => new URL(`https://${CHAT_MEDIA_HOST}/v1/media/${resourceName}`)); + if (!built.ok) return err(built.error); + built.value.searchParams.set("alt", "media"); + const url = built.value.toString(); + + const tok = await deps.getToken(); + const token = tok.ok ? tok.value : undefined; + const authHeader = token !== undefined ? `Bearer ${token}` : undefined; + + const startMs = systemNowMs(); + const fetched = await deps.ssrfFetcher.fetch(url, { + authHeader, + authAllowHosts: [CHAT_MEDIA_HOST], + }); + const durationMs = systemNowMs() - startMs; + if (!fetched.ok) return err(fetched.error); + + const { buffer, mimeType: fetchedMime, sizeBytes } = fetched.value; + + // Secondary size cap at the resolver (defense-in-depth over the fetcher's own cap). + if (sizeBytes > deps.maxBytes) { + return err(new Error(`media size ${sizeBytes} exceeds limit of ${deps.maxBytes} bytes`)); + } + + // The port contract mandates a VERIFIED (sniffed) MIME; the recognized type is + // authoritative, else fall back to the fetched header. + const sniffed = await fileTypeFromBuffer(buffer); + const mimeType = sniffed?.mime ?? fetchedMime; + + deps.logger.debug( + { platform: "googlechat", sizeBytes, durationMs }, + "Google Chat media resolved", + ); + + // Raw bytes only — the pipeline fences the DERIVED text, not the media bytes here. + return ok({ buffer, mimeType, sizeBytes }); + }, + }; +} From d23068364c05a9a3f5d22b5c7f464db09e8529e7 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 02:12:09 +0300 Subject: [PATCH 098/200] test(236-01): add failing security + failure-branch tests for the resolver - malformed / empty ref rejected before fetch (validation WARN) - injection metacharacters (?, #, &, whitespace, control, DEL) + path traversal rejected before mint/fetch - opaque base64/token resource name RESOLVES (no charset allowlist); downloadUri never fetched - FATAL mint failure; platform/precondition WARNs; SA token + URL never leak to logs or errors --- .../googlechat/googlechat-resolver.test.ts | 224 +++++++++++++++++- 1 file changed, 223 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/googlechat-resolver.test.ts b/packages/channels/src/googlechat/googlechat-resolver.test.ts index 11e903a92..5e1868046 100644 --- a/packages/channels/src/googlechat/googlechat-resolver.test.ts +++ b/packages/channels/src/googlechat/googlechat-resolver.test.ts @@ -1,6 +1,6 @@ // SPDX-License-Identifier: Apache-2.0 import type { Attachment } from "@comis/core"; -import { ok } from "@comis/shared"; +import { ok, err } from "@comis/shared"; import { describe, expect, it, vi } from "vitest"; import { createGoogleChatResolver, @@ -130,4 +130,226 @@ describe("googlechat-resolver / createGoogleChatResolver", () => { expect(result.value.sizeBytes).toBe(raw.length); } }); + + // ------------------------------------------------------------------------- + // Validation guards: a malformed / empty / injection-bearing ref is rejected + // BEFORE any token mint or fetch. + // ------------------------------------------------------------------------- + + it("returns err WITHOUT fetching when the payload is not valid percent-encoding", async () => { + const deps = mockDeps(); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve(makeAttachment("googlechat-attachment://%E0%A4%A")); + + expect(result.ok).toBe(false); + expect(deps.ssrfFetcher.fetch).not.toHaveBeenCalled(); + expect(deps.logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + platform: "googlechat", + errorKind: "validation", + hint: expect.any(String), + }), + expect.any(String), + ); + }); + + it("returns err WITHOUT fetching when the decoded ref is empty", async () => { + const deps = mockDeps(); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve(makeAttachment("googlechat-attachment://")); + + expect(result.ok).toBe(false); + expect(deps.ssrfFetcher.fetch).not.toHaveBeenCalled(); + expect(deps.getToken).not.toHaveBeenCalled(); + }); + + it.each([ + ["query metacharacter (?)", "x?alt=evil"], + ["fragment metacharacter (#)", "x#frag"], + ["ampersand metacharacter (&)", "x&y=z"], + ["whitespace", "a b"], + ["control char", "a\u0001b"], + ["DEL char", "a\u007fb"], + ])( + "rejects an injection-bearing resource name (%s) BEFORE any mint or fetch", + async (_label, resourceName) => { + const deps = mockDeps(); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve(makeAttachment(attachmentUrl(resourceName))); + + expect(result.ok).toBe(false); + // Rejected before the Bearer is minted AND before the guarded fetch runs. + expect(deps.getToken).not.toHaveBeenCalled(); + expect(deps.ssrfFetcher.fetch).not.toHaveBeenCalled(); + const validationWarn = vi + .mocked(deps.logger.warn) + .mock.calls.map((c) => c[0]) + .find((p) => (p as { errorKind?: string }).errorKind === "validation"); + expect(validationWarn).toBeDefined(); + }, + ); + + it.each([ + ["shallow traversal", "spaces/AAA/../BBB"], + ["deep traversal that escapes /v1/media/", "spaces/AAA/../../../../etc"], + ])( + "rejects a path-traversal resource name (%s) before any fetch (off-path guard)", + async (_label, resourceName) => { + const deps = mockDeps(); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve(makeAttachment(attachmentUrl(resourceName))); + + expect(result.ok).toBe(false); + expect(deps.ssrfFetcher.fetch).not.toHaveBeenCalled(); + const validationWarn = vi + .mocked(deps.logger.warn) + .mock.calls.map((c) => c[0]) + .find((p) => (p as { errorKind?: string }).errorKind === "validation"); + expect(validationWarn).toBeDefined(); + }, + ); + + it("RESOLVES an opaque base64/token resource name — no charset allowlist drops it", async () => { + const deps = mockDeps(); + const resolver = createGoogleChatResolver(deps); + // Token/base64 chars (+, =, ~) and multiple segments — a strict [A-Za-z0-9._/-] + // message-name allowlist would wrongly drop this; the host + path assertion must not. + const opaque = "spaces/AAA/attachments/CiQ+tok=b64~xyz/abc"; + + const result = await resolver.resolve(makeAttachment(attachmentUrl(opaque))); + + expect(result.ok).toBe(true); + expect(deps.ssrfFetcher.fetch).toHaveBeenCalledTimes(1); + + const firstArg = (deps.ssrfFetcher.fetch as ReturnType).mock.calls[0][0] as string; + const parsed = new URL(firstArg); + expect(parsed.hostname).toBe("chat.googleapis.com"); + expect(parsed.pathname.startsWith("/v1/media/")).toBe(true); + // The raw token chars survive into the path (proving no charset allowlist ran). + expect(parsed.pathname).toContain("+"); + expect(parsed.pathname).toContain("="); + expect(parsed.pathname).toContain("~"); + expect(parsed.pathname).toContain("/attachments/"); + // The only query is the pinned alt=media. + expect(parsed.searchParams.get("alt")).toBe("media"); + expect([...parsed.searchParams.keys()]).toEqual(["alt"]); + }); + + it("never fetches the attachment's browser download link — only the media.download URL", async () => { + const deps = mockDeps(); + const resolver = createGoogleChatResolver(deps); + const DOWNLOAD_URI = + "https://chat.google.com/api/get_attachment_url?url_type=DOWNLOAD_URL&attachment_id=xyz"; + // A fixture that ALSO carries a browser download link the resolver must ignore. + const attachment = { + ...makeAttachment(attachmentUrl(RESOURCE_NAME)), + downloadUri: DOWNLOAD_URI, + } as unknown as Attachment; + + const result = await resolver.resolve(attachment); + + expect(result.ok).toBe(true); + const firstArg = (deps.ssrfFetcher.fetch as ReturnType).mock.calls[0][0]; + expect(firstArg).toBe(EXPECTED_URL); + expect(firstArg).not.toBe(DOWNLOAD_URI); + expect(firstArg).not.toContain("get_attachment_url"); + }); + + // ------------------------------------------------------------------------- + // Failure branches. + // ------------------------------------------------------------------------- + + it("treats a token-mint failure as fatal — returns err and never fetches header-less", async () => { + const deps = mockDeps({ + getToken: vi.fn().mockResolvedValue(err(new Error("token mint failed"))), + }); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve(makeAttachment(attachmentUrl(RESOURCE_NAME))); + + expect(result.ok).toBe(false); + expect(deps.ssrfFetcher.fetch).not.toHaveBeenCalled(); + }); + + it("returns err and emits a platform WARN with durationMs + hint when the guarded fetch fails", async () => { + const deps = mockDeps({ + ssrfFetcher: { fetch: vi.fn().mockResolvedValue(err(new Error("SSRF blocked"))) }, + }); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve(makeAttachment(attachmentUrl(RESOURCE_NAME))); + + expect(result.ok).toBe(false); + expect(deps.logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + platform: "googlechat", + errorKind: "platform", + durationMs: expect.any(Number), + hint: expect.any(String), + }), + expect.any(String), + ); + }); + + it("rejects an over-size body with a precondition WARN", async () => { + const deps = mockDeps({ + maxBytes: 10, + ssrfFetcher: { + fetch: vi.fn().mockResolvedValue( + ok({ buffer: Buffer.alloc(8), mimeType: "image/png", sizeBytes: 20 * 1024 * 1024 }), + ), + }, + }); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve(makeAttachment(attachmentUrl(RESOURCE_NAME))); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).toMatch(/exceeds limit/); + } + expect(deps.logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + platform: "googlechat", + errorKind: "precondition", + hint: expect.any(String), + }), + expect.any(String), + ); + }); + + it("never places the SA token or the constructed URL in a log field or the returned error", async () => { + const token = "SA_TOKEN_SUPERSECRET"; + const deps = mockDeps({ + getToken: vi.fn().mockResolvedValue(ok(token)), + ssrfFetcher: { + fetch: vi.fn().mockResolvedValue( + err(new Error(`fetch failed for ${EXPECTED_URL} using Bearer ${token}: HTTP 500`)), + ), + }, + }); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve(makeAttachment(attachmentUrl(RESOURCE_NAME))); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error.message).not.toContain(token); + expect(result.error.message).not.toContain(EXPECTED_URL); + } + + // No warn/debug log call carries the token or the constructed URL in any field. + const allLogArgs = [ + ...vi.mocked(deps.logger.warn).mock.calls, + ...vi.mocked(deps.logger.debug).mock.calls, + ] + .map((call) => JSON.stringify(call)) + .join(" "); + expect(allLogArgs).not.toContain(token); + expect(allLogArgs).not.toContain(EXPECTED_URL); + }); }); From 723f3bdc7b3893a4420b0a00a18dd4158f8fa09f Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 02:14:45 +0300 Subject: [PATCH 099/200] feat(236-01): harden the Google Chat media resolver - reject malformed/empty ref and injection/traversal metacharacters before any mint or fetch - assert the built URL host + /v1/media/ pathname; an opaque token resource name still resolves - treat a token-mint failure as fatal (no header-less fetch); strip the URL + Bearer from returned errors - platform/precondition WARNs carry errorKind + hint (+ durationMs); MIME sniffed and authoritative --- .../src/googlechat/googlechat-resolver.ts | 134 ++++++++++++++++-- 1 file changed, 124 insertions(+), 10 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-resolver.ts b/packages/channels/src/googlechat/googlechat-resolver.ts index 2504bbb3d..ac6d3242f 100644 --- a/packages/channels/src/googlechat/googlechat-resolver.ts +++ b/packages/channels/src/googlechat/googlechat-resolver.ts @@ -14,6 +14,13 @@ * `alt=media`. The attachment's browser-facing download link is never read or * fetched — it is a human-user URL that rejects a service-account Bearer. * + * The resource name is untrusted inbound JSON, so it is guarded twice before it can + * steer a request: a cheap denylist rejects genuine injection/traversal + * metacharacters before any mint or fetch, and the URL is then built via the URL + * API with its host and `/v1/media/` pathname asserted — the authoritative control. + * The guard is format-agnostic on purpose: an opaque base64/token resource name is + * NOT dropped by a charset allowlist. + * * Dependency-clean by construction: this file pulls in neither the SSRF-fetcher's * home package nor its underlying HTTP transport. The fetcher arrives as a local * structural interface, so the DNS-pinning + redirect machinery stays in the @@ -24,11 +31,14 @@ * mismatch). Raw bytes are returned — the pipeline applies the external-content * fence on the DERIVED text (transcription/vision/doc-extract), not this resolver. * + * Secret discipline: the service-account Bearer and the constructed URL are never + * placed in a log field, and are stripped from a returned Error before it surfaces. + * * @module */ import type { Attachment, MediaResolverPort, ResolvedMedia } from "@comis/core"; -import { systemNowMs } from "@comis/core"; +import { sanitizeLogString, systemNowMs } from "@comis/core"; import type { Result } from "@comis/shared"; import { ok, err, tryCatch } from "@comis/shared"; import { fileTypeFromBuffer } from "file-type"; @@ -76,6 +86,27 @@ const CHAT_MEDIA_HOST = "chat.googleapis.com"; const GOOGLECHAT_ATTACHMENT_SCHEME = /^googlechat-attachment:\/\//; +/** + * Reject a resource name carrying a genuine injection or traversal metacharacter, + * WITHOUT constraining its charset. The media.download resource name is an OPAQUE + * token — it may legitimately carry base64 (`+`, `=`), `~`, `:`, and `/` for a + * multi-segment name — so a strict character allowlist would silently drop a valid + * attachment. This denylist rejects only `?`, `#`, `&`, whitespace, any control + * character, and `..`, while permitting opaque token characters. The authoritative + * control remains the host + `/v1/media/` pathname assertion built in `resolve`; + * this is the cheap pre-check that keeps a hostile ref from ever being minted for + * or fetched. A char-code scan avoids a control-character regex. + */ +function hasResourceNameInjection(id: string): boolean { + if (id.includes("..")) return true; + for (let i = 0; i < id.length; i++) { + const c = id.charCodeAt(i); + if (c <= 0x20 || c === 0x7f) return true; // whitespace + all control chars + if (c === 0x3f || c === 0x23 || c === 0x26) return true; // ? # & + } + return false; +} + // --------------------------------------------------------------------------- // Factory // --------------------------------------------------------------------------- @@ -89,6 +120,14 @@ const GOOGLECHAT_ATTACHMENT_SCHEME = /^googlechat-attachment:\/\//; * MIME type. */ export function createGoogleChatResolver(deps: GoogleChatResolverDeps): MediaResolverPort { + /** Strip the constructed URL and the Bearer from an error message, then sanitize free-text. */ + function sanitizeError(message: string, url: string, token: string | undefined): string { + let stripped = message; + if (url.length > 0) stripped = stripped.replaceAll(url, "[REDACTED_URL]"); + if (token && token.length > 0) stripped = stripped.replaceAll(token, "[REDACTED_TOKEN]"); + return sanitizeLogString(stripped); + } + return { schemes: ["googlechat-attachment"], @@ -99,19 +138,61 @@ export function createGoogleChatResolver(deps: GoogleChatResolverDeps): MediaRes const decoded = tryCatch(() => decodeURIComponent(attachment.url.replace(GOOGLECHAT_ATTACHMENT_SCHEME, "")), ); - if (!decoded.ok) return err(decoded.error); + if (!decoded.ok || decoded.value.length === 0) { + deps.logger.warn( + { + platform: "googlechat", + errorKind: "validation" as const, + hint: "Drop the attachment: its googlechat-attachment:// payload is not valid percent-encoding", + }, + "Google Chat media resolve failed: malformed attachment ref", + ); + return err(new Error("Invalid googlechat-attachment:// URL")); + } const resourceName = decoded.value; - // Build the download URL via the URL API from the FIXED host. The multi-segment - // resource name goes into the path; `alt=media` is the only query parameter. + // Injection/traversal pre-check — BEFORE any mint or fetch. Permits opaque token + // characters; rejects only genuine query/fragment/traversal injection. + if (hasResourceNameInjection(resourceName)) { + deps.logger.warn( + { + platform: "googlechat", + errorKind: "validation" as const, + hint: "Drop the attachment: its resource name carries a disallowed metacharacter (query, fragment, whitespace, control, or ..)", + }, + "Google Chat media resolve blocked: unsafe resource name", + ); + return err(new Error("attachment resource name not permitted")); + } + + // Build the download URL via the URL API from the FIXED host, then ASSERT the host + // and `/v1/media/` pathname — the authoritative control. A traversal that slips the + // pre-check would collapse the pathname off `/v1/media/` and be caught here; the + // injected fetcher re-pins the host per hop as defense-in-depth. const built = tryCatch(() => new URL(`https://${CHAT_MEDIA_HOST}/v1/media/${resourceName}`)); - if (!built.ok) return err(built.error); + if ( + !built.ok || + built.value.hostname !== CHAT_MEDIA_HOST || + !built.value.pathname.startsWith("/v1/media/") + ) { + deps.logger.warn( + { + platform: "googlechat", + errorKind: "validation" as const, + hint: "Drop the attachment: the resource name did not resolve to a chat.googleapis.com /v1/media/ path", + }, + "Google Chat media resolve blocked: off-host or off-path ref", + ); + return err(new Error("attachment host not permitted")); + } built.value.searchParams.set("alt", "media"); const url = built.value.toString(); + // The download ALWAYS needs the Bearer, so a mint failure is fatal — never a + // header-less fetch. The token provider already logged a secret-free WARN. const tok = await deps.getToken(); - const token = tok.ok ? tok.value : undefined; - const authHeader = token !== undefined ? `Bearer ${token}` : undefined; + if (!tok.ok) return err(tok.error); + const authHeader = `Bearer ${tok.value}`; const startMs = systemNowMs(); const fetched = await deps.ssrfFetcher.fetch(url, { @@ -119,19 +200,52 @@ export function createGoogleChatResolver(deps: GoogleChatResolverDeps): MediaRes authAllowHosts: [CHAT_MEDIA_HOST], }); const durationMs = systemNowMs() - startMs; - if (!fetched.ok) return err(fetched.error); + + if (!fetched.ok) { + deps.logger.warn( + { + platform: "googlechat", + durationMs, + errorKind: "platform" as const, + hint: "Google Chat media fetch failed — verify the service account has the chat.bot scope and the attachment is uploaded content, not a Drive file", + }, + "Google Chat media fetch failed", + ); + // The constructed URL / Bearer must never surface in the returned error. + const msg = fetched.error instanceof Error ? fetched.error.message : String(fetched.error); + return err(new Error(sanitizeError(msg, url, tok.value))); + } const { buffer, mimeType: fetchedMime, sizeBytes } = fetched.value; // Secondary size cap at the resolver (defense-in-depth over the fetcher's own cap). if (sizeBytes > deps.maxBytes) { + deps.logger.warn( + { + platform: "googlechat", + sizeBytes, + maxBytes: deps.maxBytes, + durationMs, + errorKind: "precondition" as const, + hint: "Raise the Google Chat media size limit or ask the sender to shrink the file", + }, + "Google Chat media rejected: body exceeds the configured size cap", + ); return err(new Error(`media size ${sizeBytes} exceeds limit of ${deps.maxBytes} bytes`)); } - // The port contract mandates a VERIFIED (sniffed) MIME; the recognized type is - // authoritative, else fall back to the fetched header. + // The port contract mandates a VERIFIED (sniffed) MIME. The platform can mislabel + // bytes and the model vision API rejects a declared type that mismatches the actual + // bytes — sniff the downloaded bytes; the recognized type is authoritative, else + // fall back to the fetched header. const sniffed = await fileTypeFromBuffer(buffer); const mimeType = sniffed?.mime ?? fetchedMime; + if (sniffed && sniffed.mime !== fetchedMime) { + deps.logger.debug( + { platform: "googlechat", declaredMime: fetchedMime, sniffedMime: sniffed.mime }, + "Google Chat media MIME corrected from sniffed bytes (declared type mismatched)", + ); + } deps.logger.debug( { platform: "googlechat", sizeBytes, durationMs }, From 8fefe606ff9de3eab6e8067c22f8af67d6d533ab Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 02:30:25 +0300 Subject: [PATCH 100/200] test(236-02): add failing tests for extractGoogleChatAttachments + attachment wiring - extract branches on attachmentDataRef.resourceName presence (drag-drop resolves), never the source enum - a share without a resource name lands in skipped, not attachments - the browser-facing download link is never surfaced into att.url - the mapper now populates NormalizedMessage.attachments (was always []) --- .../src/googlechat/message-mapper.test.ts | 206 +++++++++++++++++- 1 file changed, 205 insertions(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/message-mapper.test.ts b/packages/channels/src/googlechat/message-mapper.test.ts index 9499959e0..798b3727e 100644 --- a/packages/channels/src/googlechat/message-mapper.test.ts +++ b/packages/channels/src/googlechat/message-mapper.test.ts @@ -1,7 +1,11 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect } from "vitest"; import { parseMessage } from "@comis/core"; -import { mapGoogleChatEventToNormalized, type GoogleChatEvent } from "./message-mapper.js"; +import { + mapGoogleChatEventToNormalized, + extractGoogleChatAttachments, + type GoogleChatEvent, +} from "./message-mapper.js"; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -275,3 +279,203 @@ describe("mapGoogleChatEventToNormalized — untrusted-input boundary", () => { } }); }); + +describe("extractGoogleChatAttachments", () => { + /** + * Build a message carrying the given raw attachment objects. The wire carries + * more fields than the mapper reads (a browser-facing download link among + * them), so the array is loosely typed on purpose — the extractor must ignore + * everything but the downloadable resource name. + */ + function messageWith(attachment: unknown[]): GoogleChatEvent["message"] { + return { + name: "spaces/AAAA/messages/1", + sender: { name: "users/1" }, + text: "see attached", + attachment, + } as unknown as GoogleChatEvent["message"]; + } + + it("surfaces an attachment carrying attachmentDataRef.resourceName as a googlechat-attachment:// ref", () => { + const resourceName = "spaces/A/attachments/C"; + const { attachments, skipped } = extractGoogleChatAttachments( + messageWith([ + { + contentType: "image/png", + contentName: "pic.png", + attachmentDataRef: { resourceName }, + }, + ]), + ); + expect(attachments).toHaveLength(1); + expect(attachments[0].url).toBe( + `googlechat-attachment://${encodeURIComponent(resourceName)}`, + ); + expect(attachments[0].type).toBe("image"); + expect(attachments[0].mimeType).toBe("image/png"); + expect(attachments[0].fileName).toBe("pic.png"); + expect(skipped).toEqual([]); + }); + + it("skips a share carrying no resource name (Drive-picker) and records it under skipped, not attachments", () => { + const { attachments, skipped } = extractGoogleChatAttachments( + messageWith([ + { source: "DRIVE_FILE", contentName: "doc", driveDataRef: { driveFileId: "x" } }, + ]), + ); + expect(attachments).toEqual([]); + expect(skipped).toEqual([{ source: "DRIVE_FILE", contentName: "doc" }]); + }); + + it("RESOLVES a drag-drop share that DOES carry a resource name — the branch is on resource-name presence, never the source enum", () => { + const resourceName = "spaces/A/attachments/D"; + const { attachments, skipped } = extractGoogleChatAttachments( + messageWith([{ source: "DRIVE_FILE", attachmentDataRef: { resourceName } }]), + ); + expect(attachments).toHaveLength(1); + expect(attachments[0].url).toBe( + `googlechat-attachment://${encodeURIComponent(resourceName)}`, + ); + expect(skipped).toEqual([]); + }); + + it("splits a mixed list: the resolvable ref into attachments, the resource-name-less share into skipped", () => { + const { attachments, skipped } = extractGoogleChatAttachments( + messageWith([ + { + contentType: "image/png", + attachmentDataRef: { resourceName: "spaces/A/attachments/C" }, + }, + { source: "DRIVE_FILE", contentName: "shared.pdf", driveDataRef: { driveFileId: "y" } }, + ]), + ); + expect(attachments).toHaveLength(1); + expect(attachments[0].url).toBe( + `googlechat-attachment://${encodeURIComponent("spaces/A/attachments/C")}`, + ); + expect(skipped).toEqual([{ source: "DRIVE_FILE", contentName: "shared.pdf" }]); + }); + + it("guards a null / non-object array element without throwing (neither surfaced nor skipped)", () => { + let out: ReturnType | undefined; + expect(() => { + out = extractGoogleChatAttachments( + messageWith([ + null, + 42, + "str", + { attachmentDataRef: { resourceName: "spaces/A/attachments/C" } }, + ]), + ); + }).not.toThrow(); + expect(out?.attachments).toHaveLength(1); + expect(out?.skipped).toEqual([]); + }); + + it("never surfaces a browser-facing download link into att.url — only the resource-name scheme", () => { + const resourceName = "spaces/A/attachments/C"; + const { attachments } = extractGoogleChatAttachments( + messageWith([ + { + contentType: "image/png", + attachmentDataRef: { resourceName }, + downloadUri: "https://chat.example.test/download/browser-only", + thumbnailUri: "https://chat.example.test/thumb/browser-only", + }, + ]), + ); + expect(attachments).toHaveLength(1); + expect(attachments[0].url).toBe( + `googlechat-attachment://${encodeURIComponent(resourceName)}`, + ); + expect(attachments[0].url).not.toContain("chat.example.test"); + }); + + it("classifies the coarse type from the MIME so the pipeline routes: audio/* → audio, application/pdf → file", () => { + const { attachments } = extractGoogleChatAttachments( + messageWith([ + { contentType: "audio/ogg", attachmentDataRef: { resourceName: "spaces/A/attachments/AUD" } }, + { contentType: "application/pdf", attachmentDataRef: { resourceName: "spaces/A/attachments/PDF" } }, + ]), + ); + expect(attachments).toHaveLength(2); + expect(attachments[0].type).toBe("audio"); + expect(attachments[1].type).toBe("file"); + }); + + it("omits mimeType and fileName when the attachment carries neither contentType nor contentName", () => { + const { attachments } = extractGoogleChatAttachments( + messageWith([{ attachmentDataRef: { resourceName: "spaces/A/attachments/BARE" } }]), + ); + expect(attachments).toHaveLength(1); + expect(attachments[0].type).toBe("file"); + expect(attachments[0].mimeType).toBeUndefined(); + expect(attachments[0].fileName).toBeUndefined(); + }); + + it("returns empty attachments and skipped for an undefined message", () => { + const { attachments, skipped } = extractGoogleChatAttachments(undefined); + expect(attachments).toEqual([]); + expect(skipped).toEqual([]); + }); +}); + +describe("mapGoogleChatEventToNormalized — inbound attachments", () => { + it("populates NormalizedMessage.attachments from a resolvable message.attachment ref (was always [])", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { + name: "spaces/AAAA/messages/1", + sender: { name: "users/1" }, + text: "see attached", + attachment: [ + { + contentType: "image/png", + contentName: "pic.png", + attachmentDataRef: { resourceName: "spaces/AAAA/attachments/C" }, + }, + ], + }, + }), + ); + expect(result?.attachments).toHaveLength(1); + expect(result?.attachments[0].type).toBe("image"); + expect(result?.attachments[0].url).toBe( + `googlechat-attachment://${encodeURIComponent("spaces/AAAA/attachments/C")}`, + ); + }); + + it("keeps attachments [] for a MESSAGE event carrying only a resource-name-less share", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { + name: "spaces/AAAA/messages/1", + sender: { name: "users/1" }, + text: "hi", + attachment: [{ source: "DRIVE_FILE", driveDataRef: { driveFileId: "z" } }], + }, + }), + ); + expect(result?.attachments).toEqual([]); + }); + + it("round-trips through parseMessage with a resolvable attachment present", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { + name: "spaces/AAAA/messages/1", + sender: { name: "users/1" }, + text: "see attached", + attachment: [ + { + contentType: "image/png", + attachmentDataRef: { resourceName: "spaces/AAAA/attachments/C" }, + }, + ], + }, + }), + ); + const parsed = parseMessage(result); + expect(parsed.ok).toBe(true); + }); +}); From 379647d9486af4e4bf138be0255f95cbb89dc19c Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 02:32:09 +0300 Subject: [PATCH 101/200] feat(236-02): surface inbound attachments via a pure extractGoogleChatAttachments - extract keys the resolve/skip decision on attachmentDataRef.resourceName presence, so a drag-drop upload tagged with a Drive source still resolves - resolvable refs become googlechat-attachment://encodeURIComponent(resourceName) with a coarse type/mimeType/fileName for pipeline routing - a share without a resource name is separated into skipped for the caller - the mapper populates NormalizedMessage.attachments and stays pure/loggerless; browser-facing download links are never surfaced into att.url --- .../channels/src/googlechat/message-mapper.ts | 74 ++++++++++++++++++- 1 file changed, 71 insertions(+), 3 deletions(-) diff --git a/packages/channels/src/googlechat/message-mapper.ts b/packages/channels/src/googlechat/message-mapper.ts index d1b600dc5..9b18098ba 100644 --- a/packages/channels/src/googlechat/message-mapper.ts +++ b/packages/channels/src/googlechat/message-mapper.ts @@ -27,9 +27,10 @@ * @module */ -import type { NormalizedMessage } from "@comis/core"; +import type { Attachment, NormalizedMessage } from "@comis/core"; import { systemNowMs } from "@comis/core"; import { randomUUID } from "node:crypto"; +import { mimeToAttachmentType } from "../shared/media-utils.js"; /** * Minimal classic Chat interaction-event shape — only the fields the inbound @@ -60,9 +61,74 @@ export interface GoogleChatEvent { annotations?: Array<{ type?: string }>; /** Per-message space; takes precedence over the top-level `space`. */ space?: { name?: string; type?: string; spaceType?: string }; + /** + * Inbound file/media attachments — the repeated Chat Message resource field + * is named `attachment` (SINGULAR), NOT `attachments`; reading the plural + * would always be empty. Only an attachment whose `attachmentDataRef` carries + * a resource name is downloadable by an app (over media.download); a + * `driveDataRef`-only share (source "DRIVE_FILE" from the Drive picker) has no + * downloadable resource name. The wire also carries browser-facing + * `downloadUri`/`thumbnailUri` links that reject an app bearer — they are + * deliberately NOT modeled here so they can never be surfaced as a fetch URL. + */ + attachment?: Array<{ + name?: string; + contentName?: string; + contentType?: string; + /** Upload-origin marker; classification keys on resource-name presence, not this. */ + source?: string; + /** Present with a resource name → the attachment is downloadable via media.download. */ + attachmentDataRef?: { resourceName?: string }; + /** A Drive-only reference → not downloadable by an app. */ + driveDataRef?: { driveFileId?: string }; + }>; }; } +/** + * Extract inbound attachments from a Chat message, keying the resolve/skip + * decision on the PRESENCE of a downloadable resource name — never on the upload + * source. An attachment carrying `attachmentDataRef.resourceName` is surfaced as a + * `googlechat-attachment://` ref the media resolver can fetch (with a coarse + * `type` + `mimeType` + `fileName` so the standard pipeline routes it); a share + * that carries no resource name is surfaced separately under `skipped` for the + * caller to log. The ref URL is only ever the resource-name scheme — a + * browser-facing download link is never read. + * + * Pure: no I/O, no logging. The caller (the adapter) logs the `skipped` half so + * the mapper stays transport- and logger-free. + * + * @param message - The decoded Chat message payload (may be undefined) + * @returns `{ attachments, skipped }` — resolvable refs and resource-name-less shares + */ +export function extractGoogleChatAttachments( + message: GoogleChatEvent["message"], +): { attachments: Attachment[]; skipped: Array<{ source?: string; contentName?: string }> } { + const attachments: Attachment[] = []; + const skipped: Array<{ source?: string; contentName?: string }> = []; + for (const a of message?.attachment ?? []) { + // Untrusted inbound JSON: a decoded array element can be the literal null or a + // non-object scalar. Guard before any dereference so a hostile element is + // dropped rather than crashing the mapper. + if (a === null || typeof a !== "object") continue; + const resourceName = a.attachmentDataRef?.resourceName; + if (typeof resourceName === "string" && resourceName.length > 0) { + // encodeURIComponent guarantees no bare "://" inside the payload, so the + // resolver's scheme split sees exactly `googlechat-attachment` and the + // decode is its exact inverse. + attachments.push({ + type: mimeToAttachmentType(a.contentType), + url: `googlechat-attachment://${encodeURIComponent(resourceName)}`, + ...(a.contentType != null && { mimeType: a.contentType }), + ...(a.contentName != null && { fileName: a.contentName }), + }); + } else { + skipped.push({ source: a.source, contentName: a.contentName }); + } + } + return { attachments, skipped }; +} + /** The non-empty sentinel space name. */ const UNKNOWN_SPACE = "spaces/unknown"; /** The non-empty sentinel sender id. */ @@ -118,8 +184,10 @@ export function mapGoogleChatEventToNormalized( // text is the faithful command without hand-stripping. text: message.argumentText ?? message.text ?? "", timestamp: systemNowMs(), - // Inbound media is resolved downstream; the mapper emits no attachments. - attachments: [], + // Attachments carrying a downloadable resource name are surfaced as + // googlechat-attachment:// refs the resolver fetches; a share without one is + // separated into `skipped` for the caller to log, so the mapper stays pure. + attachments: extractGoogleChatAttachments(message).attachments, chatType: isDm ? "dm" : "group", metadata, }; From ba7ef579e5da3762bac06a34b3ea4a035eace1cd Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 02:32:56 +0300 Subject: [PATCH 102/200] test(236-02): add failing tests for the adapter Drive-picker skip WARN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - an allowlisted message with a resource-name-less share emits one skip WARN (source, errorKind precondition, hint naming the user-OAuth + Drive-scope unlock) and still fans out - the WARN carries only content-free keys — no resource name, body, or token - a non-allowlisted sender's share emits no WARN (gate runs first) - one WARN per skipped share --- .../src/googlechat/googlechat-adapter.test.ts | 124 ++++++++++++++++++ 1 file changed, 124 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index fe337d039..99a29f1d2 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -1798,3 +1798,127 @@ describe("createGoogleChatAdapter — CARD_CLICKED routing + default-deny", () = expect(msg.metadata.isButtonCallback).toBeUndefined(); }); }); + +describe("createGoogleChatAdapter — inbound Drive-picker skip WARN", () => { + /** A MESSAGE event from `sender` carrying the given raw attachment objects. */ + function messageEventWithAttachments( + attachment: unknown[], + over: { senderName?: string; text?: string } = {}, + ): unknown { + const space = { name: "spaces/AAAA", spaceType: "SPACE" }; + return { + type: "MESSAGE", + space, + message: { + name: "spaces/AAAA/messages/CCC", + sender: { name: over.senderName ?? "users/123" }, + text: over.text ?? "see attached", + space, + attachment, + }, + }; + } + + /** The Drive-picker skip WARN calls (distinct from the non-allowlisted-sender WARN). */ + function skipWarns(spy: ReturnType): unknown[][] { + return spy.mock.calls.filter( + ([, msg]) => + typeof msg === "string" && msg.includes("Drive-file attachment skipped"), + ); + } + + it("emits ONE skip WARN (source, errorKind precondition, OAuth+Drive-scope hint) for a resource-name-less share on an allowlisted message, and still fans out", async () => { + const { deps, loggerSpy } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleChatEvent( + messageEventWithAttachments([ + { source: "DRIVE_FILE", contentName: "shared.pdf", driveDataRef: { driveFileId: "DRIVE_SECRET_ID" } }, + ]), + ); + + const warns = skipWarns(loggerSpy.warn); + expect(warns).toHaveLength(1); + const obj = warns[0][0] as Record; + expect(obj.channelType).toBe("googlechat"); + expect(obj.source).toBe("DRIVE_FILE"); + expect(obj.errorKind).toBe("precondition"); + expect(String(obj.hint).toLowerCase()).toContain("oauth"); + expect(String(obj.hint).toLowerCase()).toContain("drive"); + // The message still reaches the handler despite the un-fetchable share. + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("does NOT emit a skip WARN when every attachment carries a resolvable resource name", async () => { + const { deps, loggerSpy } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleChatEvent( + messageEventWithAttachments([ + { contentType: "image/png", attachmentDataRef: { resourceName: "spaces/AAAA/attachments/C" } }, + ]), + ); + + expect(skipWarns(loggerSpy.warn)).toHaveLength(0); + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("does NOT emit a skip WARN for a resource-name-less share from a NON-allowlisted sender (dropped by the gate first)", async () => { + const { deps, loggerSpy } = await makeDeps({ allowFrom: [] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await adapter.handleChatEvent( + messageEventWithAttachments( + [{ source: "DRIVE_FILE", driveDataRef: { driveFileId: "x" } }], + { senderName: "users/999" }, + ), + ); + + // The WARN sits AFTER the allowlist gate, so a dropped message announces no media. + expect(skipWarns(loggerSpy.warn)).toHaveLength(0); + expect(handler).not.toHaveBeenCalled(); + }); + + it("the skip WARN carries only content-free diagnostic keys — no resource name, message text, driveFileId, or token", async () => { + const { deps, loggerSpy } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + adapter.onMessage(vi.fn()); + + await adapter.handleChatEvent( + messageEventWithAttachments( + [{ source: "DRIVE_FILE", contentName: "shared.pdf", driveDataRef: { driveFileId: "DRIVE_SECRET_ID" } }], + { text: "SENSITIVE_MESSAGE_BODY" }, + ), + ); + + const warns = skipWarns(loggerSpy.warn); + expect(warns).toHaveLength(1); + const obj = warns[0][0] as Record; + expect(Object.keys(obj).sort()).toEqual(["channelType", "errorKind", "hint", "source"]); + const serialized = JSON.stringify(warns); + expect(serialized).not.toContain("DRIVE_SECRET_ID"); + expect(serialized).not.toContain("SENSITIVE_MESSAGE_BODY"); + expect(serialized).not.toContain(MINTED_TOKEN); + }); + + it("emits one skip WARN per resource-name-less share (two shares → two WARNs)", async () => { + const { deps, loggerSpy } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + adapter.onMessage(vi.fn()); + + await adapter.handleChatEvent( + messageEventWithAttachments([ + { source: "DRIVE_FILE", contentName: "a.pdf", driveDataRef: { driveFileId: "x" } }, + { source: "DRIVE_FILE", contentName: "b.pdf", driveDataRef: { driveFileId: "y" } }, + ]), + ); + + expect(skipWarns(loggerSpy.warn)).toHaveLength(2); + }); +}); From 4989a225297abc8908f3064333581db827f0fcfb Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 02:33:41 +0300 Subject: [PATCH 103/200] feat(236-02): emit an honest Drive-picker skip WARN in handleChatEvent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reuse the pure extractGoogleChatAttachments for its skipped half, keeping one source of truth for extraction while the mapper stays logger-free - WARN placed after the allowlist gate so media in a dropped message is never announced; carries only channelType/source/errorKind/hint — no resource name, body, or token - errorKind precondition; hint names the user-OAuth + Drive-scope unlock --- .../src/googlechat/googlechat-adapter.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index c86272a07..c1f056099 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -62,6 +62,7 @@ import { } from "./pubsub-source.js"; import { mapGoogleChatEventToNormalized, + extractGoogleChatAttachments, type GoogleChatEvent, } from "./message-mapper.js"; import { @@ -437,6 +438,29 @@ export function createGoogleChatAdapter( return; // drop BEFORE any processing → ack (resolve) } + // Surface an honest degrade for every share that carries no downloadable + // resource name. The mapper already used the extractor's `attachments` half + // to populate the message; calling the pure extractor again here for the + // `skipped` half keeps ONE source of truth for the extraction while the + // mapper stays logger-free (the skip half is never threaded through the + // NormalizedMessage). Placed AFTER the allowlist gate so media in a dropped + // message is never announced; the WARN carries no resource name, body, or + // token — only the content-free diagnostic fields an operator acts on. + const { skipped } = extractGoogleChatAttachments( + (event as GoogleChatEvent).message, + ); + for (const s of skipped) { + deps.logger.warn( + { + channelType: "googlechat" as const, + source: s.source, + errorKind: "precondition" as const, + hint: "Google Drive shared files need user-OAuth mode with a Drive scope; a service-account app can only download uploaded (drag-drop / paste) content", + }, + "Inbound Drive-file attachment skipped: no downloadable resource name", + ); + } + await fanOutMessage(normalized); } From 081fac5c36c92082f1c304a66125d4971190756a Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 02:49:52 +0300 Subject: [PATCH 104/200] test(236-03): add failing test for GoogleChatPluginHandle.createResolver over the shared chat.bot provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createResolver exposed on the plugin handle, returning a MediaResolverPort whose schemes include googlechat-attachment - the built resolver mints over adapter.getPubSubTokenProvider().getToken(CHAT_SCOPE) — the shared per-scope provider, asserted via a getToken spy - attachments stays false (inbound resolver orthogonal to the outbound upload flag) --- .../src/googlechat/googlechat-plugin.test.ts | 86 ++++++++++++++++++- 1 file changed, 84 insertions(+), 2 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-plugin.test.ts b/packages/channels/src/googlechat/googlechat-plugin.test.ts index e7f2b727e..3431e0caf 100644 --- a/packages/channels/src/googlechat/googlechat-plugin.test.ts +++ b/packages/channels/src/googlechat/googlechat-plugin.test.ts @@ -1,9 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 import { describe, it, expect, vi } from "vitest"; -import type { ComisLogger, PluginRegistryApi } from "@comis/core"; +import type { Attachment, ComisLogger, PluginRegistryApi } from "@comis/core"; +import { ok } from "@comis/shared"; import { generateKeyPair, exportPKCS8 } from "jose"; import { createGoogleChatPlugin } from "./googlechat-plugin.js"; -import type { GoogleChatAdapterDeps } from "./googlechat-adapter.js"; +import type { + GoogleChatAdapterDeps, + GoogleChatAdapterHandle, +} from "./googlechat-adapter.js"; +import { CHAT_SCOPE } from "./googlechat-auth.js"; import type { PubSubSource } from "./pubsub-source.js"; const SA_EMAIL = "comis-bot@my-project.iam.gserviceaccount.com"; @@ -206,3 +211,80 @@ describe("createGoogleChatPlugin — lifecycle delegation", () => { expect(fake.stop).toHaveBeenCalledTimes(1); }); }); + +describe("createGoogleChatPlugin — inbound media resolver handle", () => { + /** The resolver's structural logger — debug/warn only. */ + function makeResolverLogger() { + return { debug: vi.fn(), warn: vi.fn() }; + } + + it("exposes createResolver returning a googlechat-attachment MediaResolverPort", async () => { + const { deps } = await makeDeps(); + const plugin = createGoogleChatPlugin(deps); + + expect(typeof plugin.createResolver).toBe("function"); + + const resolver = plugin.createResolver({ + ssrfFetcher: { fetch: vi.fn() }, + maxBytes: 10_000_000, + logger: makeResolverLogger(), + }); + + expect(resolver.schemes).toContain("googlechat-attachment"); + }); + + it("builds a resolver that mints over the SHARED chat.bot provider (getToken(CHAT_SCOPE))", async () => { + const { deps } = await makeDeps(); + const plugin = createGoogleChatPlugin(deps); + + // Stub the adapter's SHARED per-scope token provider so getToken is a spy — + // proving createResolver closes over adapter.getPubSubTokenProvider().getToken( + // CHAT_SCOPE), the one provider minted for the pull loop and the send path, and + // never a freshly-built second provider (which would re-parse the SA key). + const adapterHandle = plugin.adapter as unknown as GoogleChatAdapterHandle; + const getToken = vi.fn(async () => ok(MINTED_TOKEN)); + vi.spyOn(adapterHandle, "getPubSubTokenProvider").mockReturnValue({ + getToken, + credentialError: () => undefined, + } as unknown as ReturnType); + + // The injected fetcher returns bytes so resolve reaches the mint + fetch. + const fetch = vi.fn(async () => + ok({ buffer: Buffer.from("img"), mimeType: "image/png", sizeBytes: 3 }), + ); + const resolver = plugin.createResolver({ + ssrfFetcher: { fetch }, + maxBytes: 10_000_000, + logger: makeResolverLogger(), + }); + + const resourceName = "spaces/AAA/messages/BBB/attachments/CCC"; + const result = await resolver.resolve({ + url: `googlechat-attachment://${encodeURIComponent(resourceName)}`, + type: "image", + } as Attachment); + + expect(result.ok).toBe(true); + // The mint rode the SHARED provider at the chat.bot scope. + expect(getToken).toHaveBeenCalledWith(CHAT_SCOPE); + // And that Bearer rode the single pinned media host — no host escape hatch. + expect(fetch).toHaveBeenCalledWith( + expect.stringContaining("https://chat.googleapis.com/v1/media/"), + { + authHeader: `Bearer ${MINTED_TOKEN}`, + authAllowHosts: ["chat.googleapis.com"], + }, + ); + }); + + it("leaves attachments:false — the inbound resolver is orthogonal to the outbound flag", async () => { + const { deps } = await makeDeps(); + const plugin = createGoogleChatPlugin(deps); + + // createResolver adds an INBOUND resolution path; the OUTBOUND upload capability + // stays honestly false (user-auth-only), so the daemon capability gate still + // blocks sendAttachment. The two are orthogonal — the flag does not flip. + expect(plugin.capabilities.features.attachments).toBe(false); + expect(typeof plugin.createResolver).toBe("function"); + }); +}); From 03375ba23c5deae134e9177ae5a7b9a2886229df Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 02:52:52 +0300 Subject: [PATCH 105/200] feat(236-03): widen googlechat plugin to GoogleChatPluginHandle with createResolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createGoogleChatPlugin now returns a GoogleChatPluginHandle exposing createResolver, which builds the inbound-media resolver over the SHARED per-scope chat.bot provider (adapter.getPubSubTokenProvider().getToken(CHAT_SCOPE)) — no second token provider, no host escape hatch - copy the structural SsrfFetcher + ResolverLogger interfaces (no @comis/skills dep); createResolver takes no mediaAuthAllowHosts (telegram-style) - attachments stays false (inbound resolver orthogonal to the outbound upload flag) - correct the stale module JSDoc; export the GoogleChatPluginHandle type from the barrel - track GoogleChatPluginHandle as an ahead-of-consumer public-API baseline (media wiring consumes it in a later wave) --- .../src/googlechat/googlechat-plugin.ts | 61 ++++++++++++++++++- packages/channels/src/index.ts | 1 + test/support/public-api-policy.ts | 8 +++ 3 files changed, 67 insertions(+), 3 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-plugin.ts b/packages/channels/src/googlechat/googlechat-plugin.ts index 242e26aee..dd512dcca 100644 --- a/packages/channels/src/googlechat/googlechat-plugin.ts +++ b/packages/channels/src/googlechat/googlechat-plugin.ts @@ -16,8 +16,10 @@ * method deliberately OMITTED — the honest-capability contract. * * activate() delegates to adapter.start() (which opens the pull loop) and - * deactivate() to adapter.stop(). The plugin returns a plain ChannelPluginPort: - * an inbound-media resolver handle is not part of this surface. + * deactivate() to adapter.stop(). Beyond the ChannelPluginPort surface the plugin + * is a GoogleChatPluginHandle: it exposes createResolver, which builds the + * inbound-media resolver that resolves attachments over the supported bot download + * path, closing over the adapter's shared per-scope chat.bot token provider. * * @module */ @@ -25,6 +27,7 @@ import type { ChannelCapability, ChannelPluginPort, + MediaResolverPort, PluginRegistryApi, } from "@comis/core"; import { ok, type Result } from "@comis/shared"; @@ -32,6 +35,46 @@ import { createGoogleChatAdapter, type GoogleChatAdapterDeps, } from "./googlechat-adapter.js"; +import { createGoogleChatResolver } from "./googlechat-resolver.js"; +import { CHAT_SCOPE } from "./googlechat-auth.js"; + +// --------------------------------------------------------------------------- +// Structural interfaces (avoid a circular dep on @comis/skills) +// --------------------------------------------------------------------------- + +/** + * Structural interface for the auth-capable SSRF-guarded fetcher (avoids a + * circular dep on the package that owns the HTTP transport). It is the auth + * superset of the plain `fetch(url)` seam: `opts` carries the Authorization + * header value and the host allowlist the header may ride. + */ +interface SsrfFetcher { + fetch( + url: string, + opts?: { authHeader?: string; authAllowHosts?: readonly string[] }, + ): Promise>; +} + +/** Minimal logger interface for the media resolver. */ +interface ResolverLogger { + debug(obj: Record, msg: string): void; + warn(obj: Record, msg: string): void; +} + +/** + * The Google Chat plugin handle. Widens ChannelPluginPort with `createResolver`, + * which builds the inbound-media resolver closing over the adapter's shared + * per-scope chat.bot token provider. Unlike the Teams handle it takes no host + * allowlist: the resolver pins the Bearer to the single media host with no config + * escape hatch. + */ +export interface GoogleChatPluginHandle extends ChannelPluginPort { + createResolver(deps: { + ssrfFetcher: SsrfFetcher; + maxBytes: number; + logger: ResolverLogger; + }): MediaResolverPort; +} /** Google Chat platform capabilities — the honest app-auth capability matrix. */ const CAPABILITIES: ChannelCapability = { @@ -58,7 +101,7 @@ const CAPABILITIES: ChannelCapability = { */ export function createGoogleChatPlugin( deps: GoogleChatAdapterDeps, -): ChannelPluginPort { +): GoogleChatPluginHandle { const adapter = createGoogleChatAdapter(deps); return { @@ -80,5 +123,17 @@ export function createGoogleChatPlugin( async deactivate(): Promise> { return adapter.stop(); }, + + createResolver({ ssrfFetcher, maxBytes, logger }) { + return createGoogleChatResolver({ + ssrfFetcher, + maxBytes, + logger, + // Close over the adapter's SHARED per-scope token provider at the chat.bot + // scope — the one provider minted for the pull loop and the send path, not a + // second one (which would re-parse the service-account key). + getToken: () => adapter.getPubSubTokenProvider().getToken(CHAT_SCOPE), + }); + }, }; } diff --git a/packages/channels/src/index.ts b/packages/channels/src/index.ts index 1c80c02b2..5c18c65b3 100644 --- a/packages/channels/src/index.ts +++ b/packages/channels/src/index.ts @@ -183,6 +183,7 @@ export type { GoogleChatAdapterHandle, } from "./googlechat/googlechat-adapter.js"; export { createGoogleChatPlugin } from "./googlechat/googlechat-plugin.js"; +export type { GoogleChatPluginHandle } from "./googlechat/googlechat-plugin.js"; // Google Chat utilities export { mapGoogleChatEventToNormalized } from "./googlechat/message-mapper.js"; diff --git a/test/support/public-api-policy.ts b/test/support/public-api-policy.ts index 89d782113..660dd03cf 100644 --- a/test/support/public-api-policy.ts +++ b/test/support/public-api-policy.ts @@ -636,6 +636,14 @@ export const PUBLIC_API_POLICY: ReadonlyMap> = "GoogleChatTokenProvider", "GoogleChatScope", "classifyGoogleChatError", + // GoogleChatPluginHandle: the plugin-handle type that widens + // createGoogleChatPlugin's return with createResolver (mirrors + // MsTeamsPluginHandle). The daemon media wiring + // (setup-channels-media.ts) name-imports it to build the inbound resolver + // in a later wave — surfaced here ahead of that consumer (the CHAT_SCOPE + // ahead-of-consumer precedent directly above). Shrink this entry once the + // media wiring name-imports it cross-package. + "GoogleChatPluginHandle", "createIMessageAdapter", "IMessageAdapterDeps", "mapImsgToNormalized", From 356098bcf60efa42e9eb9036a0d89a39fea2b708 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 03:03:13 +0300 Subject: [PATCH 106/200] test(236-04): add failing test for the googlechat resolver registration in buildMediaPipeline - supplying a gcPlugin handle must call createResolver once with { ssrfFetcher, maxBytes, logger } - the call carries NO mediaAuthAllowHosts (telegram-style, not msteams-style) - the googlechat-attachment resolver is registered into the composite; absent gcPlugin omits it --- .../src/wiring/setup-channels-media.test.ts | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/packages/daemon/src/wiring/setup-channels-media.test.ts b/packages/daemon/src/wiring/setup-channels-media.test.ts index 598364d79..c7dff07db 100644 --- a/packages/daemon/src/wiring/setup-channels-media.test.ts +++ b/packages/daemon/src/wiring/setup-channels-media.test.ts @@ -251,6 +251,38 @@ describe("buildMediaPipeline", () => { expect(hasMsteams).toBe(false); }); + it("creates the googlechat resolver from the gcPlugin handle", async () => { + const mockResolver = { resolve: vi.fn(), schemes: ["googlechat-attachment"] }; + const gcPlugin = { createResolver: vi.fn(() => mockResolver) } as any; + const deps = makeDeps({ gcPlugin }); + await buildMediaPipeline(deps); + + // The SAME injected auth-capable fetcher reaches createResolver — and, unlike + // msteams, NO mediaAuthAllowHosts (the single-host pin is the resolver's own). + expect(gcPlugin.createResolver).toHaveBeenCalledWith( + expect.objectContaining({ + ssrfFetcher: deps.ssrfFetcher, + maxBytes: 10_000_000, + logger: expect.anything(), + }), + ); + expect(gcPlugin.createResolver.mock.calls[0][0]).not.toHaveProperty("mediaAuthAllowHosts"); + // The googlechat-attachment resolver is registered in the composite. + const compositeCall = vi.mocked(createCompositeResolver).mock.calls[0][0]; + expect(compositeCall.resolvers).toContainEqual(mockResolver); + }); + + it("omits the googlechat resolver when gcPlugin is absent", async () => { + const deps = makeDeps(); // no gcPlugin + await buildMediaPipeline(deps); + + const compositeCall = vi.mocked(createCompositeResolver).mock.calls[0][0]; + const hasGc = (compositeCall.resolvers as Array<{ schemes?: string[] }>).some( + (r) => r.schemes?.includes("googlechat-attachment"), + ); + expect(hasGc).toBe(false); + }); + it("calls createCompositeResolver with empty resolvers when no adapters", async () => { const deps = makeDeps({ adaptersByType: new Map() }); await buildMediaPipeline(deps); From c5759e87588011bc92b493a4b19dd57c56196f74 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 03:04:11 +0300 Subject: [PATCH 107/200] feat(236-04): register the googlechat resolver in buildMediaPipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add MediaPipelineDeps.gcPlugin? (GoogleChatPluginHandle) + destructure - telegram-style registration block: createResolver({ ssrfFetcher, maxBytes, logger }), NO mediaAuthAllowHosts — the single-host Bearer pin is the resolver's own constant - the googlechat-attachment scheme now routes to the auth-bearing resolver in the composite - shrink GoogleChatPluginHandle out of the public-api-policy ahead-of-consumer baseline now that setup-channels-media.ts name-imports it cross-package (shrink-only ratchet) --- packages/daemon/src/wiring/setup-channels-media.ts | 13 +++++++++++++ test/support/public-api-policy.ts | 8 -------- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/daemon/src/wiring/setup-channels-media.ts b/packages/daemon/src/wiring/setup-channels-media.ts index 9c902ef1f..f4996d8f3 100644 --- a/packages/daemon/src/wiring/setup-channels-media.ts +++ b/packages/daemon/src/wiring/setup-channels-media.ts @@ -22,6 +22,7 @@ import { type TelegramPluginHandle, type LinePluginHandle, type MsTeamsPluginHandle, + type GoogleChatPluginHandle, } from "@comis/channels"; import { createCompositeResolver, @@ -66,6 +67,7 @@ export interface MediaPipelineDeps { tgPlugin?: TelegramPluginHandle; linePlugin?: LinePluginHandle; msTeamsPlugin?: MsTeamsPluginHandle; + gcPlugin?: GoogleChatPluginHandle; ssrfFetcher: SsrfGuardedFetcher; linkRunner: LinkRunner; transcriber?: TranscriptionPort; @@ -102,6 +104,7 @@ export async function buildMediaPipeline(deps: MediaPipelineDeps): Promise> = "GoogleChatTokenProvider", "GoogleChatScope", "classifyGoogleChatError", - // GoogleChatPluginHandle: the plugin-handle type that widens - // createGoogleChatPlugin's return with createResolver (mirrors - // MsTeamsPluginHandle). The daemon media wiring - // (setup-channels-media.ts) name-imports it to build the inbound resolver - // in a later wave — surfaced here ahead of that consumer (the CHAT_SCOPE - // ahead-of-consumer precedent directly above). Shrink this entry once the - // media wiring name-imports it cross-package. - "GoogleChatPluginHandle", "createIMessageAdapter", "IMessageAdapterDeps", "mapImsgToNormalized", From 4e56e0a8253f2c5b8034dca0776ccd9e08873db5 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 03:05:30 +0300 Subject: [PATCH 108/200] feat(236-04): thread the GoogleChatPluginHandle through the adapter bootstrap into buildMediaPipeline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - capture the googlechat plugin as GoogleChatPluginHandle in bootstrapAdapters (not down-cast to ChannelPluginPort — createResolver would be lost) and return it - add AdapterBootstrapResult.googlechatPlugin? beside msTeamsPlugin - registry destructures googlechatPlugin and passes gcPlugin into buildMediaPipeline - the real handle now flows bootstrapAdapters -> registry -> media pipeline, so the composite routes googlechat-attachment to the auth-bearing resolver end-to-end --- .../daemon/src/wiring/setup-channels-adapters.ts | 13 ++++++++++++- .../setup-channels/setup-channels-registry.ts | 3 ++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/wiring/setup-channels-adapters.ts b/packages/daemon/src/wiring/setup-channels-adapters.ts index 1741cbc55..3d3cc5818 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.ts @@ -38,6 +38,7 @@ import { type EmailAdapterDeps, type MsTeamsAdapterHandle, type MsTeamsPluginHandle, + type GoogleChatPluginHandle, type TeamsActivity, } from "@comis/channels"; import { createMsTeamsIngress } from "@comis/gateway"; @@ -73,6 +74,10 @@ export interface AdapterBootstrapResult { * creation — mirrors tgPlugin/linePlugin). Undefined when the channel is * disabled or its credentials are invalid. */ msTeamsPlugin?: MsTeamsPluginHandle; + /** Google Chat plugin handle (needed by the media pipeline to build the + * inbound resolver over the service-account chat.bot token provider). + * Undefined when the channel is disabled or its credentials are invalid. */ + googlechatPlugin?: GoogleChatPluginHandle; } // --------------------------------------------------------------------------- @@ -112,6 +117,7 @@ export async function bootstrapAdapters(deps: { let linePlugin: LinePluginHandle | undefined; let msTeamsIngress: import("hono").Hono | undefined; let msTeamsPlugin: MsTeamsPluginHandle | undefined; + let googlechatPlugin: GoogleChatPluginHandle | undefined; // Helper: attempt to get a secret, return undefined if not found const getSecret = (name: string): string | undefined => { @@ -526,6 +532,11 @@ export async function bootstrapAdapters(deps: { }); adaptersByType.set("googlechat", plugin.adapter); channelPlugins.set("googlechat", plugin); + // Capture the handle so the media pipeline can build the inbound resolver + // over the shared service-account chat.bot token provider (mirrors the + // tgPlugin/msTeamsPlugin capture). Kept as a GoogleChatPluginHandle — a + // down-cast to ChannelPluginPort would lose createResolver. + googlechatPlugin = plugin as GoogleChatPluginHandle; channelsLogger.info({ channelType: "googlechat", mode: channelConfig.googlechat.mode }, "Channel adapter initialized"); } else { channelsLogger.warn({ hint: "Set channels.googlechat.serviceAccountKey (SecretRef) or GOOGLECHAT_SA_KEY, and channels.googlechat.subscriptionName", errorKind: "auth" as const }, "Google Chat credential validation failed"); @@ -539,5 +550,5 @@ export async function bootstrapAdapters(deps: { } } // end if (channelConfig) - return { adaptersByType, tgPlugin, linePlugin, channelPlugins, msTeamsIngress, msTeamsPlugin }; + return { adaptersByType, tgPlugin, linePlugin, channelPlugins, msTeamsIngress, msTeamsPlugin, googlechatPlugin }; } diff --git a/packages/daemon/src/wiring/setup-channels/setup-channels-registry.ts b/packages/daemon/src/wiring/setup-channels/setup-channels-registry.ts index 1d9ffe277..0b1be2aa6 100644 --- a/packages/daemon/src/wiring/setup-channels/setup-channels-registry.ts +++ b/packages/daemon/src/wiring/setup-channels/setup-channels-registry.ts @@ -373,7 +373,7 @@ export async function setupChannels(deps: ChannelsDeps): Promise // + the daemon TimerPort are injected into createMsTeamsPlugin here (both are // optional @comis/core-port seams on the adapter): capture + proactive recovery // + the typing keepalive. - const { adaptersByType, tgPlugin, linePlugin, channelPlugins, msTeamsIngress, msTeamsPlugin } = await bootstrapAdapters({ + const { adaptersByType, tgPlugin, linePlugin, channelPlugins, msTeamsIngress, msTeamsPlugin, googlechatPlugin } = await bootstrapAdapters({ container, channelsLogger, msTeamsConversationStore: deps.msTeamsConversationStore, @@ -396,6 +396,7 @@ export async function setupChannels(deps: ChannelsDeps): Promise tgPlugin, linePlugin, msTeamsPlugin, + gcPlugin: googlechatPlugin, ssrfFetcher, linkRunner, transcriber, From fd1869731678931d17907116de727949c3b46d8f Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 03:10:25 +0300 Subject: [PATCH 109/200] test(236-04): expose createGoogleChatActivityRenderer on the registry-test @comis/channels mock MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The EDIT_PLACE_RENDERER_FACTORIES const references createGoogleChatActivityRenderer at module load, but the explicit (non-importOriginal) @comis/channels mock in the registry test never listed it, so import-time factory collection threw and the whole suite failed to load. Add the factory to the mock (EditPlace strategy), mirroring the msteams entry — the mock's own contract is to expose every per-channel renderer factory the maps reference. --- .../src/wiring/setup-channels/setup-channels-registry.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/daemon/src/wiring/setup-channels/setup-channels-registry.test.ts b/packages/daemon/src/wiring/setup-channels/setup-channels-registry.test.ts index 6642b9f57..cebe804bc 100644 --- a/packages/daemon/src/wiring/setup-channels/setup-channels-registry.test.ts +++ b/packages/daemon/src/wiring/setup-channels/setup-channels-registry.test.ts @@ -47,6 +47,7 @@ vi.mock("@comis/channels", () => ({ createSlackActivityRenderer: vi.fn(() => ({ strategy: "EditPlace", apply: vi.fn(), finalize: vi.fn() })), createWhatsAppActivityRenderer: vi.fn(() => ({ strategy: "EditPlace", apply: vi.fn(), finalize: vi.fn() })), createMSTeamsActivityRenderer: vi.fn(() => ({ strategy: "EditPlace", apply: vi.fn(), finalize: vi.fn() })), + createGoogleChatActivityRenderer: vi.fn(() => ({ strategy: "EditPlace", apply: vi.fn(), finalize: vi.fn() })), createSignalActivityRenderer: vi.fn(() => ({ strategy: "DeleteAndRepost", apply: vi.fn(), finalize: vi.fn() })), createIMessageActivityRenderer: vi.fn(() => ({ strategy: "AppendOnly", apply: vi.fn(), finalize: vi.fn() })), createLineActivityRenderer: vi.fn(() => ({ strategy: "AppendOnly", apply: vi.fn(), finalize: vi.fn() })), From 04c7002414f76434b07035f47579f4fac41b6290 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 03:14:29 +0300 Subject: [PATCH 110/200] chore(236-04): audit-stamp MediaPipelineDeps optional-field count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding the gcPlugin? handle took MediaPipelineDeps to 13 optional fields, tripping the optional-field-bloat guard (threshold 12). MediaPipelineDeps is the media-pipeline DI bag — each optional field is an independently-optional per-platform plugin handle or media processor port — so carry the sanctioned // @optional-field-count: audit-stamp. --- packages/daemon/src/wiring/setup-channels-media.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/daemon/src/wiring/setup-channels-media.ts b/packages/daemon/src/wiring/setup-channels-media.ts index f4996d8f3..68b01f16e 100644 --- a/packages/daemon/src/wiring/setup-channels-media.ts +++ b/packages/daemon/src/wiring/setup-channels-media.ts @@ -60,6 +60,10 @@ export interface MediaPipelineResult { // --------------------------------------------------------------------------- /** Dependencies for media pipeline assembly. */ +// @optional-field-count: 13 — the media-pipeline DI bag. Each optional field is an +// independently-optional per-platform plugin handle or media-processor port, wired +// only when that channel/processor is configured; the count tracks the number of +// channels and processors, not incidental bloat. export interface MediaPipelineDeps { container: AppContainer; channelsLogger: ComisLogger; From d772889210d3647e44e456651f7274e751ef64b7 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 03:44:29 +0300 Subject: [PATCH 111/200] fix(236): guard non-array message.attachment/annotations in the Chat mapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A truthy non-array message.attachment ({}, a number, a boolean) in an untrusted decoded Pub/Sub event made extractGoogleChatAttachments' for...of throw TypeError; message.annotations had the same latent class via .some(). Because the adapter calls the mapper unwrapped, the throw escaped the mapper's never-crash contract, was counted by the pull loop as an enqueue failure, and skipped the ack — Pub/Sub then redelivered the same message forever (dedup never engages since the name is marked seen only on the success path). Guard both containers with Array.isArray (degrade to empty) and the annotation element with optional chaining, mirroring the existing attachment element guard. Adds RED-first coverage: the pure extractor, the mapper, the adapter handleChatEvent path, and a pull-source poll that now acks-and-processes the malformed event instead of looping. --- .../src/googlechat/googlechat-adapter.test.ts | 46 +++++++++++ .../src/googlechat/message-mapper.test.ts | 76 +++++++++++++++++++ .../channels/src/googlechat/message-mapper.ts | 20 +++-- .../src/googlechat/pubsub-source.test.ts | 41 ++++++++++ 4 files changed, 178 insertions(+), 5 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 99a29f1d2..67c7dc491 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -1922,3 +1922,49 @@ describe("createGoogleChatAdapter — inbound Drive-picker skip WARN", () => { expect(skipWarns(loggerSpy.warn)).toHaveLength(2); }); }); + +describe("createGoogleChatAdapter — non-array message.attachment (untrusted)", () => { + /** A MESSAGE event from an allowlisted sender whose message.attachment field is `raw`. */ + function messageEventWithRawAttachment(raw: unknown): unknown { + const space = { name: "spaces/AAAA", spaceType: "SPACE" }; + return { + type: "MESSAGE", + space, + message: { + name: "spaces/AAAA/messages/CCC", + sender: { name: "users/123" }, + text: "see attached", + space, + attachment: raw, + }, + }; + } + + // handleChatEvent calls the mapper UNWRAPPED (mapping the event) and then the + // pure extractor AGAIN for the skip WARN — two sites that iterate + // `message.attachment`. A truthy non-iterable container would throw at either, + // escape handleChatEvent (the pull loop's onEvent boundary), and be counted as + // an enqueue failure → skip-ack → infinite redelivery. It must instead degrade + // to empty: no throw, and the message still fans out to the handler. + it.each([ + ["an empty object", {}], + ["a number", 42], + ["a boolean", true], + ])( + "does not throw and still fans out when message.attachment is a non-array container (%s)", + async (_label, raw) => { + const { deps } = await makeDeps({ allowFrom: ["users/123"] }); + const adapter = createGoogleChatAdapter(deps); + const handler = vi.fn(); + adapter.onMessage(handler); + + await expect( + adapter.handleChatEvent(messageEventWithRawAttachment(raw)), + ).resolves.not.toThrow(); + + expect(handler).toHaveBeenCalledTimes(1); + const msg = handler.mock.calls[0][0] as NormalizedMessage; + expect(msg.attachments).toEqual([]); + }, + ); +}); diff --git a/packages/channels/src/googlechat/message-mapper.test.ts b/packages/channels/src/googlechat/message-mapper.test.ts index 798b3727e..547782b3d 100644 --- a/packages/channels/src/googlechat/message-mapper.test.ts +++ b/packages/channels/src/googlechat/message-mapper.test.ts @@ -418,6 +418,27 @@ describe("extractGoogleChatAttachments", () => { expect(attachments).toEqual([]); expect(skipped).toEqual([]); }); + + it.each([ + ["an empty object", {}], + ["a number", 42], + ["a boolean", true], + ])( + "degrades to empty (never throws) when message.attachment is a truthy non-array container (%s)", + (_label, attachment) => { + // `message.attachment` is untrusted decoded JSON. The `?? []` fallback only + // covers null/undefined; a truthy NON-ITERABLE container (`{}`, `42`, + // `true`) makes `for...of` throw `TypeError: … is not iterable`. Guard the + // container as the elements are guarded so a hostile shape degrades to empty. + let out: ReturnType | undefined; + expect(() => { + out = extractGoogleChatAttachments( + { attachment } as unknown as GoogleChatEvent["message"], + ); + }).not.toThrow(); + expect(out).toEqual({ attachments: [], skipped: [] }); + }, + ); }); describe("mapGoogleChatEventToNormalized — inbound attachments", () => { @@ -478,4 +499,59 @@ describe("mapGoogleChatEventToNormalized — inbound attachments", () => { const parsed = parseMessage(result); expect(parsed.ok).toBe(true); }); + + // A non-array `message.attachment`/`message.annotations` in an untrusted decoded + // event would make `for...of`/`.some` throw. Because the adapter calls the mapper + // UNWRAPPED, that TypeError escapes the mapper's documented "never crash → return + // null/valid" contract and lands on the pull loop's skip-ack path — the malformed + // message is never ACKed and Pub/Sub redelivers it forever (dedup never engages + // because the name is marked seen only on the success path). Each of these must + // map to a schema-valid message, never throw. + it.each([ + ["message.attachment = {} (non-iterable object)", { attachment: {} }], + ["message.attachment = 42 (non-iterable number)", { attachment: 42 }], + ["message.attachment = true (non-iterable boolean)", { attachment: true }], + ["message.annotations = 'x' (non-array string — .some is not a function)", { annotations: "x" }], + ["message.annotations = 42 (non-array number)", { annotations: 42 }], + ])( + "maps a MESSAGE event with a non-array container (%s) to a valid NormalizedMessage, never throwing into the redelivery path", + (_label, badField) => { + let result: ReturnType | undefined; + expect(() => { + result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { + name: "spaces/AAAA/messages/1", + sender: { name: "users/1" }, + text: "hi", + ...badField, + } as unknown as GoogleChatEvent["message"], + }), + ); + }).not.toThrow(); + expect(result).not.toBeNull(); + expect(result?.attachments).toEqual([]); + expect(parseMessage(result).ok).toBe(true); + }, + ); + + it("guards a null annotation element without throwing, still detecting a real USER_MENTION alongside it", () => { + // A decoded annotations array can carry a literal null element; `a.type` on it + // throws. Guard the element (a?.type) so the mapper never crashes and still + // reads a genuine mention in the same array. + let result: ReturnType | undefined; + expect(() => { + result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { + name: "spaces/AAAA/messages/1", + sender: { name: "users/1" }, + text: "hi", + annotations: [null, { type: "USER_MENTION" }], + } as unknown as GoogleChatEvent["message"], + }), + ); + }).not.toThrow(); + expect(result?.metadata.wasMentioned).toBe(true); + }); }); diff --git a/packages/channels/src/googlechat/message-mapper.ts b/packages/channels/src/googlechat/message-mapper.ts index 9b18098ba..889e01952 100644 --- a/packages/channels/src/googlechat/message-mapper.ts +++ b/packages/channels/src/googlechat/message-mapper.ts @@ -106,10 +106,15 @@ export function extractGoogleChatAttachments( ): { attachments: Attachment[]; skipped: Array<{ source?: string; contentName?: string }> } { const attachments: Attachment[] = []; const skipped: Array<{ source?: string; contentName?: string }> = []; - for (const a of message?.attachment ?? []) { - // Untrusted inbound JSON: a decoded array element can be the literal null or a - // non-object scalar. Guard before any dereference so a hostile element is - // dropped rather than crashing the mapper. + // Untrusted inbound JSON: guard the CONTAINER before iterating. `?? []` only + // covers null/undefined; a truthy non-iterable (`{}`, a number, a boolean) would + // make `for...of` throw TypeError, escape the mapper's "never crash" contract, + // and poison-pill the pull loop (skip-ack → infinite redelivery). Degrade a + // non-array shape to empty, exactly as the elements below are guarded. + const list = Array.isArray(message?.attachment) ? message.attachment : []; + for (const a of list) { + // A decoded array element can be the literal null or a non-object scalar. + // Guard before any dereference so a hostile element is dropped, not crashed on. if (a === null || typeof a !== "object") continue; const resourceName = a.attachmentDataRef?.resourceName; if (typeof resourceName === "string" && resourceName.length > 0) { @@ -155,7 +160,12 @@ export function mapGoogleChatEventToNormalized( // Accept both the current and the legacy DM encoding; anything else is a // multi-person space (a "group"). const isDm = space?.spaceType === "DIRECT_MESSAGE" || space?.type === "DM"; - const wasMentioned = (message.annotations ?? []).some((a) => a.type === "USER_MENTION"); + // Same untrusted-container class as message.attachment above: a truthy non-array + // `annotations` throws (`.some` is not a function), and a null element throws on + // `.type`. Guard the container with Array.isArray and the element with `?.` so a + // malformed payload degrades to "not mentioned" rather than crashing the mapper. + const annotations = Array.isArray(message.annotations) ? message.annotations : []; + const wasMentioned = annotations.some((a) => a?.type === "USER_MENTION"); const metadata: Record = { isGroup: !isDm, wasMentioned }; // The message resource name is the platform reply target the plugin advertises diff --git a/packages/channels/src/googlechat/pubsub-source.test.ts b/packages/channels/src/googlechat/pubsub-source.test.ts index 2723762f3..1ec16aefa 100644 --- a/packages/channels/src/googlechat/pubsub-source.test.ts +++ b/packages/channels/src/googlechat/pubsub-source.test.ts @@ -306,6 +306,47 @@ describe("createPubSubSource — pull + ack-on-enqueue + dedup (pollOnce)", () = expect(fetch.allAckedIds()).toContain("ack-null"); }); + it("acks-and-processes a MESSAGE event whose message.attachment is a non-array — never skip-acks it into infinite redelivery", async () => { + // A hostile/malformed decoded event whose `message.attachment` is a truthy + // non-iterable ({}) makes the mapper's `for...of` throw. Wired to the real + // map-then-drop dispatch contract (the adapter's handleChatEvent), that throw + // would escape onEvent → the pull loop counts it as an enqueue failure → + // SKIPS the ack → Pub/Sub redelivers forever (dedup never engages, name is + // marked seen only on the success path). The mapper must instead degrade the + // bad shape to empty so this event ACKs and makes progress. + const poison = { + type: "MESSAGE", + eventTime: "2026-07-05T00:00:00Z", + user: { name: "users/1" }, + space: { name: "spaces/AAAA", spaceType: "SPACE" }, + message: { + name: "spaces/AAAA/messages/poison", + sender: { name: "users/1" }, + text: "hi", + attachment: {}, // non-array container: `for...of` throws pre-fix + }, + }; + const fetch = makeFetch([ + { receivedMessages: [{ ackId: "ack-poison", message: { data: encodeEvent(poison) } }] }, + ]); + // Mirror handleChatEvent's contract: map the untrusted event; only a REAL + // enqueue failure rejects. A mapper throw here (pre-fix) is exactly the bug. + const onEvent = vi.fn(async (event: unknown) => { + const normalized = mapGoogleChatEventToNormalized( + event as Parameters[0], + ); + if (!normalized) return; + }); + const { deps } = makeDeps({ fetchImpl: fetch.fetchImpl, onEvent }); + const source = createPubSubSource(deps); + + const out = await source.pollOnce(); + + expect(out.skippedCount).toBe(0); + expect(out.ackedCount).toBe(1); + expect(fetch.allAckedIds()).toContain("ack-poison"); + }); + it("acks and skips an unparseable data payload without dispatching or throwing", async () => { const bad = Buffer.from("not json {{{", "utf8").toString("base64"); const fetch = makeFetch([ From fa737c99641a7ced10981081bc5946a718eada7f Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 03:46:28 +0300 Subject: [PATCH 112/200] fix(236): aggregate Google Chat Drive-skip WARN to one per message The inbound path emitted one WARN per skipped Drive-picker attachment. Sharing Drive files in Chat is routine, so a multi-share message produced a burst of WARNs that inflated the cross-session degraded rate and top-errorKind tallies the fleet lens aggregates, masking acute signals. Emit a single WARN per message carrying skippedCount and the distinct sources. It stays content-free (no resource name, body, or token) and the hint still names the unlock (user-OAuth mode + a Drive scope). --- .../src/googlechat/googlechat-adapter.test.ts | 26 ++++++++++++++----- .../src/googlechat/googlechat-adapter.ts | 17 ++++++++---- 2 files changed, 32 insertions(+), 11 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 67c7dc491..eae7ca01d 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -1823,11 +1823,11 @@ describe("createGoogleChatAdapter — inbound Drive-picker skip WARN", () => { function skipWarns(spy: ReturnType): unknown[][] { return spy.mock.calls.filter( ([, msg]) => - typeof msg === "string" && msg.includes("Drive-file attachment skipped"), + typeof msg === "string" && msg.includes("Drive-file attachment"), ); } - it("emits ONE skip WARN (source, errorKind precondition, OAuth+Drive-scope hint) for a resource-name-less share on an allowlisted message, and still fans out", async () => { + it("emits ONE aggregate skip WARN (skippedCount, distinct sources, errorKind precondition, OAuth+Drive-scope hint) for a resource-name-less share on an allowlisted message, and still fans out", async () => { const { deps, loggerSpy } = await makeDeps({ allowFrom: ["users/123"] }); const adapter = createGoogleChatAdapter(deps); const handler = vi.fn(); @@ -1843,7 +1843,9 @@ describe("createGoogleChatAdapter — inbound Drive-picker skip WARN", () => { expect(warns).toHaveLength(1); const obj = warns[0][0] as Record; expect(obj.channelType).toBe("googlechat"); - expect(obj.source).toBe("DRIVE_FILE"); + // Aggregate shape: a count + the distinct sources, not one WARN per share. + expect(obj.skippedCount).toBe(1); + expect(obj.sources).toEqual(["DRIVE_FILE"]); expect(obj.errorKind).toBe("precondition"); expect(String(obj.hint).toLowerCase()).toContain("oauth"); expect(String(obj.hint).toLowerCase()).toContain("drive"); @@ -1900,14 +1902,20 @@ describe("createGoogleChatAdapter — inbound Drive-picker skip WARN", () => { const warns = skipWarns(loggerSpy.warn); expect(warns).toHaveLength(1); const obj = warns[0][0] as Record; - expect(Object.keys(obj).sort()).toEqual(["channelType", "errorKind", "hint", "source"]); + expect(Object.keys(obj).sort()).toEqual([ + "channelType", + "errorKind", + "hint", + "skippedCount", + "sources", + ]); const serialized = JSON.stringify(warns); expect(serialized).not.toContain("DRIVE_SECRET_ID"); expect(serialized).not.toContain("SENSITIVE_MESSAGE_BODY"); expect(serialized).not.toContain(MINTED_TOKEN); }); - it("emits one skip WARN per resource-name-less share (two shares → two WARNs)", async () => { + it("aggregates multiple resource-name-less shares into ONE WARN carrying the count and the distinct sources (routine Drive shares must not inflate the fleet WARN rate)", async () => { const { deps, loggerSpy } = await makeDeps({ allowFrom: ["users/123"] }); const adapter = createGoogleChatAdapter(deps); adapter.onMessage(vi.fn()); @@ -1919,7 +1927,13 @@ describe("createGoogleChatAdapter — inbound Drive-picker skip WARN", () => { ]), ); - expect(skipWarns(loggerSpy.warn)).toHaveLength(2); + const warns = skipWarns(loggerSpy.warn); + // ONE WARN for the whole message, not one per share. + expect(warns).toHaveLength(1); + const obj = warns[0][0] as Record; + expect(obj.skippedCount).toBe(2); + // Distinct sources only (both were DRIVE_FILE → a single entry). + expect(obj.sources).toEqual(["DRIVE_FILE"]); }); }); diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index c1f056099..d564e9be0 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -438,8 +438,8 @@ export function createGoogleChatAdapter( return; // drop BEFORE any processing → ack (resolve) } - // Surface an honest degrade for every share that carries no downloadable - // resource name. The mapper already used the extractor's `attachments` half + // Surface an honest degrade when a message carries shares with no + // downloadable resource name. The mapper already used the extractor's `attachments` half // to populate the message; calling the pure extractor again here for the // `skipped` half keeps ONE source of truth for the extraction while the // mapper stays logger-free (the skip half is never threaded through the @@ -449,15 +449,22 @@ export function createGoogleChatAdapter( const { skipped } = extractGoogleChatAttachments( (event as GoogleChatEvent).message, ); - for (const s of skipped) { + if (skipped.length > 0) { + // Aggregate to ONE WARN per message. Sharing Drive files in Chat is + // routine, not exceptional; a WARN per share would let an ordinary user + // action dominate the cross-session degraded rate + top-errorKind tallies + // the fleet lens aggregates, masking an acute signal. The count + the + // distinct sources keep the diagnostic while staying content-free (no + // resource name, body, or token), and the hint still names the unlock. deps.logger.warn( { channelType: "googlechat" as const, - source: s.source, + skippedCount: skipped.length, + sources: [...new Set(skipped.map((s) => s.source))], errorKind: "precondition" as const, hint: "Google Drive shared files need user-OAuth mode with a Drive scope; a service-account app can only download uploaded (drag-drop / paste) content", }, - "Inbound Drive-file attachment skipped: no downloadable resource name", + "Inbound Drive-file attachment(s) skipped: no downloadable resource name", ); } From 5a4894ccfe68d051653d43f5d745541b0d604b8e Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 03:47:34 +0300 Subject: [PATCH 113/200] test(236): pin encoded-traversal/separator rejection in the Chat media resolver MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The path-traversal tests covered only a literal '..'. Encoded forms (%2e%2e, %2E%2E, %2f) are not scanned by the injection denylist; their safety rests on the URL-API build + host/'/v1/media/' pathname assertion. Pin that invariant so a refactor of the assertion cannot silently regress it: every encoded form either rejects before any fetch or keeps the single fetched URL on chat.googleapis.com under /v1/media/, and %2f stays one opaque segment (never decoded to a separator). No production change — this locks existing behavior. --- .../googlechat/googlechat-resolver.test.ts | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-resolver.test.ts b/packages/channels/src/googlechat/googlechat-resolver.test.ts index 5e1868046..967113db6 100644 --- a/packages/channels/src/googlechat/googlechat-resolver.test.ts +++ b/packages/channels/src/googlechat/googlechat-resolver.test.ts @@ -213,6 +213,64 @@ describe("googlechat-resolver / createGoogleChatResolver", () => { }, ); + // Encoded traversal/separators are NOT scanned by hasResourceNameInjection (it + // only catches a literal ".."); their safety rests entirely on the downstream + // URL-API build + host/`/v1/media/` pathname assertion (the WHATWG parser + // normalizes %2e%2e as a double-dot segment, collapsing an off-path result; + // %2f stays a single opaque segment). Pin that load-bearing invariant so a + // future refactor of the assertion cannot silently regress it: no encoded form + // may steer the fetch off chat.googleapis.com or off the /v1/media/ prefix — + // either the ref is rejected before any fetch, or the ONE fetched URL stays + // on-host and on-path. + it.each([ + ["encoded dot-dot, lowercase %2e%2e", "spaces/AAA/%2e%2e/%2e%2e/etc"], + ["encoded dot-dot, uppercase %2E%2E", "spaces/AAA/%2E%2E/CCC"], + ["encoded dot-dot mixed with a literal traversal", "spaces/AAA/%2E%2E/../secret"], + ["encoded slash, lowercase %2f", "spaces/AAA/%2f/CCC"], + ["encoded slash, uppercase %2F", "spaces/AAA/%2F/CCC"], + ])( + "keeps an encoded-traversal/separator resource name (%s) on chat.googleapis.com/v1/media/ or rejects it before any fetch", + async (_label, resourceName) => { + const deps = mockDeps(); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve(makeAttachment(attachmentUrl(resourceName))); + + if (result.ok) { + // Resolved → the ONE fetched URL must stay on the pinned host and path; + // an encoded form must never collapse or decode the request off /v1/media/. + expect(deps.ssrfFetcher.fetch).toHaveBeenCalledTimes(1); + const fetched = new URL( + (deps.ssrfFetcher.fetch as ReturnType).mock.calls[0][0] as string, + ); + expect(fetched.hostname).toBe("chat.googleapis.com"); + expect(fetched.pathname.startsWith("/v1/media/")).toBe(true); + } else { + // Rejected → the guard fired before any (cross-host) fetch could run. + expect(deps.ssrfFetcher.fetch).not.toHaveBeenCalled(); + } + }, + ); + + it("keeps a percent-encoded slash (%2f) as a single opaque path segment — never decoded to a separator that could break out", async () => { + const deps = mockDeps(); + const resolver = createGoogleChatResolver(deps); + + const result = await resolver.resolve( + makeAttachment(attachmentUrl("spaces/AAA/%2f/CCC")), + ); + + // On-host, on-path, and the %2f survived as an opaque token (not decoded to /). + expect(result.ok).toBe(true); + expect(deps.ssrfFetcher.fetch).toHaveBeenCalledTimes(1); + const fetched = new URL( + (deps.ssrfFetcher.fetch as ReturnType).mock.calls[0][0] as string, + ); + expect(fetched.hostname).toBe("chat.googleapis.com"); + expect(fetched.pathname.startsWith("/v1/media/")).toBe(true); + expect(fetched.pathname.toLowerCase()).toContain("%2f"); + }); + it("RESOLVES an opaque base64/token resource name — no charset allowlist drops it", async () => { const deps = mockDeps(); const resolver = createGoogleChatResolver(deps); From 0d5b37e1181d998ff0367b8de1a5dcb63a6f1f99 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 08:44:47 +0300 Subject: [PATCH 114/200] test(237-01): add failing inbound project-number verifier tests - createGoogleChatInboundVerifier project-number matrix: happy path, fail-closed empty audience (no key-set access), bearer pre-gate, wrong-audience/issuer/key, alg:none, expired - assert the token bytes never reach a log field on rejection - local RS256 JWKS seam (no real Google network) --- .../src/googlechat/googlechat-auth.test.ts | 174 ++++++++++++++++++ 1 file changed, 174 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-auth.test.ts b/packages/channels/src/googlechat/googlechat-auth.test.ts index 4e5418547..c572deff7 100644 --- a/packages/channels/src/googlechat/googlechat-auth.test.ts +++ b/packages/channels/src/googlechat/googlechat-auth.test.ts @@ -4,9 +4,12 @@ import type { ComisLogger } from "@comis/core"; import { generateKeyPair, exportPKCS8, + exportJWK, + createLocalJWKSet, jwtVerify, decodeJwt, decodeProtectedHeader, + SignJWT, } from "jose"; import { createGoogleChatTokenProvider, @@ -14,6 +17,10 @@ import { PUBSUB_SCOPE, type GoogleChatTokenDeps, } from "./googlechat-auth.js"; +// Namespace import for the INBOUND verify half so a not-yet-exported symbol reads +// as `undefined` (a clean, isolated per-test failure) rather than breaking the +// whole module load — mirrors the barrel index.test.ts technique. +import * as gcAuth from "./googlechat-auth.js"; const SA_EMAIL = "comis-bot@my-project.iam.gserviceaccount.com"; const TOKEN_URL = "https://oauth2.googleapis.com/token"; @@ -484,3 +491,170 @@ describe("createGoogleChatTokenProvider — SA-JWT-bearer mint", () => { expect(spy).toHaveBeenCalledTimes(2); }); }); + +// --- Inbound dual-audience Bearer-JWT verify (the webhook-mode trust anchor) --- + +const PROJECT_NUMBER = "1234567890"; +const CHAT_SYSTEM_ISS = "chat@system.gserviceaccount.com"; + +/** + * A locally-generated RS256 keypair + local JWK set stands in for Google's + * signing keys, so the verifier runs fully offline — no network to real Google + * endpoints. `jwk` is the raw public JWK (for the raw-JWKS seam entry point); + * `jwks` is the constructed local key set (for the `jwks` option). + */ +async function makeInboundKeyContext() { + const { publicKey, privateKey } = await generateKeyPair("RS256"); + const jwk = await exportJWK(publicKey); + jwk.alg = "RS256"; + jwk.kid = "k1"; + const jwks = createLocalJWKSet({ keys: [jwk] }); + return { privateKey: privateKey as CryptoKey, jwk, jwks }; +} + +/** Mint a project-number-audience token (a self-signed Chat-system JWT shape). */ +function mintProjectToken( + privateKey: CryptoKey, + opts: { iss?: string; aud?: string; exp?: number | string } = {}, +): Promise { + return new SignJWT({}) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuer(opts.iss ?? CHAT_SYSTEM_ISS) + .setAudience(opts.aud ?? PROJECT_NUMBER) + .setExpirationTime(opts.exp ?? "5m") + .sign(privateKey); +} + +/** A hand-built alg:none unsecured JWT — jose must reject it by contract. */ +function makeUnsignedToken(payload: Record): string { + const seg = (o: unknown): string => + Buffer.from(JSON.stringify(o)).toString("base64url"); + return `${seg({ alg: "none", typ: "JWT" })}.${seg(payload)}.`; +} + +describe("createGoogleChatInboundVerifier — project-number audience", () => { + it("verifies a Chat-system token (iss/aud/RS256) signed by the trusted key", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "project-number", + audience: PROJECT_NUMBER, + jwks, + }); + const token = await mintProjectToken(privateKey); + expect((await verify(`Bearer ${token}`)).ok).toBe(true); + }); + + it("fails closed on a blank expected audience BEFORE any key-set access", async () => { + const { privateKey } = await makeInboundKeyContext(); + // A key set whose access is observable: it must never be consulted. + const jwksSpy = vi.fn(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "project-number", + audience: "", + jwks: jwksSpy as unknown as Parameters[1], + }); + const token = await mintProjectToken(privateKey); + const result = await verify(`Bearer ${token}`); + expect(result.ok).toBe(false); + expect(jwksSpy).not.toHaveBeenCalled(); + }); + + it("rejects missing / non-Bearer / bare-Bearer headers via the pre-gate (no key-set access)", async () => { + const jwksSpy = vi.fn(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "project-number", + audience: PROJECT_NUMBER, + jwks: jwksSpy as unknown as Parameters[1], + }); + expect((await verify(undefined)).ok).toBe(false); + expect((await verify("Basic dXNlcjpwYXNz")).ok).toBe(false); + expect((await verify("")).ok).toBe(false); + expect((await verify("Bearer")).ok).toBe(false); + expect(jwksSpy).not.toHaveBeenCalled(); + }); + + it("rejects a wrong audience", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "project-number", + audience: PROJECT_NUMBER, + jwks, + }); + const token = await mintProjectToken(privateKey, { aud: "9999999999" }); + expect((await verify(`Bearer ${token}`)).ok).toBe(false); + }); + + it("rejects a wrong issuer", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "project-number", + audience: PROJECT_NUMBER, + jwks, + }); + const token = await mintProjectToken(privateKey, { iss: "https://evil.example" }); + expect((await verify(`Bearer ${token}`)).ok).toBe(false); + }); + + it("rejects a token signed by a key absent from the trusted key set", async () => { + const { jwks } = await makeInboundKeyContext(); // trusted set + const foreign = await generateKeyPair("RS256"); // a DIFFERENT, untrusted signer + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "project-number", + audience: PROJECT_NUMBER, + jwks, + }); + const token = await mintProjectToken(foreign.privateKey as CryptoKey); + expect((await verify(`Bearer ${token}`)).ok).toBe(false); + }); + + it("rejects an alg:none / unsigned token", async () => { + const { jwks } = await makeInboundKeyContext(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "project-number", + audience: PROJECT_NUMBER, + jwks, + }); + const token = makeUnsignedToken({ + iss: CHAT_SYSTEM_ISS, + aud: PROJECT_NUMBER, + exp: Math.floor(Date.now() / 1000) + 300, + }); + expect((await verify(`Bearer ${token}`)).ok).toBe(false); + }); + + it("rejects an expired token", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "project-number", + audience: PROJECT_NUMBER, + jwks, + }); + // Absolute epoch second 2 (1970) — unambiguously expired, no wall-clock read. + const token = await mintProjectToken(privateKey, { exp: 2 }); + expect((await verify(`Bearer ${token}`)).ok).toBe(false); + }); + + it("warns on rejection but never records the token bytes in a log field", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const loggerSpy = makeLoggerSpy(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "project-number", + audience: PROJECT_NUMBER, + jwks, + logger: loggerSpy.logger, + }); + const token = await mintProjectToken(privateKey, { aud: "9999999999" }); + const result = await verify(`Bearer ${token}`); + expect(result.ok).toBe(false); + const authWarn = loggerSpy.warn.mock.calls + .map((c) => c[0]) + .find( + (p) => + p !== null && + typeof p === "object" && + (p as { errorKind?: string }).errorKind === "auth", + ); + expect(authWarn).toBeDefined(); + expect(loggerSpy.serialized()).not.toContain(token); + }); +}); From 95bdf60b0870c9a82481ed2bf1ccabf40b856ca7 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 08:48:12 +0300 Subject: [PATCH 115/200] =?UTF-8?q?feat(237-01):=20inbound=20dual-audience?= =?UTF-8?q?=20JWT=20verifier=20=E2=80=94=20project-number=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - createGoogleChatInboundVerifier closes the expected audience over per audienceType: chat-system JWKS + issuer for project-number, Google OIDC certs + issuer for app-url - fail-closed on blank audience before any key-set access; cheap Bearer pre-gate; jose jwtVerify pinned to algorithms: ["RS256"] - opaque rejection: WARN carries only channelType/hint/errorKind, never the token; verify error stays internal --- .../src/googlechat/googlechat-auth.ts | 162 +++++++++++++++++- 1 file changed, 153 insertions(+), 9 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-auth.ts b/packages/channels/src/googlechat/googlechat-auth.ts index 83be3ded0..3d23b4156 100644 --- a/packages/channels/src/googlechat/googlechat-auth.ts +++ b/packages/channels/src/googlechat/googlechat-auth.ts @@ -1,10 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 /** - * Google Chat outbound auth: a service-account JWT-bearer access-token provider - * with a PER-SCOPE expiry+skew cache. + * Google Chat auth — the two security-critical, transport-agnostic halves. * - * Every Chat API and Pub/Sub REST call rides a `Bearer` token minted here. On a - * cache miss the provider loads the service-account private key with `jose` + * 1. Inbound event JWT verification (webhook mode): a cheap Bearer-presence + * pre-gate that short-circuits the no-token case with no network, then + * library-backed (jose) signature + issuer + audience + expiry verification + * against Google's signing keys — dual-audience by config (`project-number` + * verifies against the Chat-system JWK set; `app-url` against Google's OIDC + * certs). Never hand-rolled crypto. + * 2. Outbound service-account token mint: a per-scope expiry+skew-cached + * JWT-bearer access token. + * + * Every Chat API and Pub/Sub REST call rides a `Bearer` token minted by half (2). + * On a cache miss the provider loads the service-account private key with `jose` * (`importPKCS8`, RS256), signs a short-lived assertion (`iss` = `sub` = the SA * client email, `aud` = the token endpoint, `scope` = the requested grant, * `exp - iat <= 1h`), and exchanges it at the OAuth2 token endpoint for an access @@ -17,10 +25,10 @@ * service-account assertion) and the per-scope cache differ. `jose` is the crypto * layer — the JWT is never hand-assembled. * - * Secret discipline: the private key, the signed assertion, and the minted access - * token are only ever handed to `jose` or the request body. They are NEVER placed - * in a log field — failure branches log only `errorKind` + `hint` (+ `status` / - * `durationMs`). + * Secret discipline: the private key, the signed assertion, the minted access + * token, and every inbound token are only ever handed to `jose` or the request + * body. They are NEVER placed in a log field — failure branches log only + * `errorKind` + `hint` (+ `status` / `durationMs`). * * Framework-agnostic on purpose: no HTTP-framework import lives here, and the * logger is injected (this package must not import the infra logger). @@ -31,7 +39,12 @@ import { systemNowMs } from "@comis/core"; import type { ComisLogger } from "@comis/core"; import { ok, err, fromPromise, type Result } from "@comis/shared"; -import { importPKCS8, SignJWT } from "jose"; +import { + importPKCS8, + SignJWT, + createRemoteJWKSet, + jwtVerify, +} from "jose"; import { classifyGoogleChatError } from "./errors.js"; /** The Google OAuth2 endpoint that exchanges an SA assertion for an access token. */ @@ -318,3 +331,134 @@ export function createGoogleChatTokenProvider( }, }; } + +// --- Inbound event JWT verification (the webhook-mode trust anchor) --- + +/** + * The issuer of a project-number-audience Chat event token: Google's Chat system + * service account. The same string appears as the `email` claim on an app-url + * OIDC token — a distinct claim on a distinct token shape; the two are never + * conflated, and an app-url token's issuer is Google's OIDC issuer, not this. + */ +const CHAT_SYSTEM_ISSUER = "chat@system.gserviceaccount.com"; + +/** + * Accepted issuers for an app-url OIDC ID token — Google's OIDC issuer in both + * the scheme-qualified and bare forms (jose's `issuer` option accepts an array). + */ +const GOOGLE_OIDC_ISSUERS = [ + "https://accounts.google.com", + "accounts.google.com", +]; + +/** + * The Chat-system JWK set — verifies a project-number-audience token's signature. + * A remote key set with jose's built-in caching and `kid` rotation; the accepted + * signature algorithm is pinned separately as an explicit `algorithms` allowlist + * on the verify call. Constructed once at module scope. The JWK endpoint (not the + * x509/PEM one) is used because jose consumes a JWKS. + */ +const CHAT_SYSTEM_JWKS = createRemoteJWKSet( + new URL( + "https://www.googleapis.com/service_accounts/v1/jwk/chat@system.gserviceaccount.com", + ), +); + +/** + * Google's OIDC certificate JWK set — verifies an app-url OIDC token's signature. + * Constructed once at module scope; the JWK endpoint (`/oauth2/v3/certs`), not the + * x509/PEM one, so jose can consume it. + */ +const GOOGLE_OIDC_JWKS = createRemoteJWKSet( + new URL("https://www.googleapis.com/oauth2/v3/certs"), +); + +/** Options for building an inbound Chat-event JWT verifier. */ +export interface GoogleChatInboundVerifierOpts { + /** + * Which audience shape the configured endpoint expects: + * - `project-number` → a self-signed Chat-system JWT (`aud` = the Cloud + * project number, `iss` = the Chat system SA), verified against the + * Chat-system JWK set. + * - `app-url` → a Google OIDC ID token (`aud` = the endpoint URL, `iss` = + * Google's OIDC issuer), verified against Google's OIDC certs. + */ + audienceType: "project-number" | "app-url"; + /** + * The expected `aud` claim — the project number or the endpoint URL. A blank + * audience fails closed BEFORE any key-set access (jose treats an empty + * `audience` as "no audience constraint", which would accept a token minted for + * any project/endpoint). + */ + audience: string; + /** + * Key set used to verify the signature. Defaults to the audienceType's remote + * Google JWK set; injected with a local key set in tests so verification runs + * offline. + */ + jwks?: Parameters[1]; + /** Expected issuer override. Defaults to the audienceType's Google issuer. */ + issuer?: string; + /** + * Optional logger for a rejection WARN carrying only `channelType` / `hint` / + * `errorKind`. The token is never logged. + */ + logger?: ComisLogger; +} + +/** + * Build an inbound Chat-event JWT verifier. The returned closure takes the raw + * `Authorization` header and resolves to `ok(undefined)` for a token verified + * against the configured audience shape, `err(...)` otherwise — the expected + * audience is closed over at construction. + * + * A missing or non-Bearer header is rejected by a cheap pre-gate with no key-set + * access (no network). A blank expected audience fails closed before any key-set + * access. A present token is verified by jose against the issuer, the audience, + * the signature, the expiry, and an `["RS256"]` algorithm allowlist. The verify + * error stays here (the caller surfaces an opaque rejection); the token is never + * logged. + */ +export function createGoogleChatInboundVerifier( + opts: GoogleChatInboundVerifierOpts, +): (authHeader: string | undefined) => Promise> { + const isProjectNumber = opts.audienceType === "project-number"; + const keySet = + opts.jwks ?? (isProjectNumber ? CHAT_SYSTEM_JWKS : GOOGLE_OIDC_JWKS); + const issuer = + opts.issuer ?? (isProjectNumber ? CHAT_SYSTEM_ISSUER : GOOGLE_OIDC_ISSUERS); + const logger = opts.logger; + + return async (authHeader) => { + // Fail closed on a blank expected audience before any key-set access: jose + // treats an empty `audience` as "no audience constraint", which would accept + // a token minted for any other project/endpoint. + if (!opts.audience) { + return err(new Error("missing expected audience")); + } + // Cheap pre-gate: no Bearer token → reject before any key-set access. + if (!authHeader?.startsWith("Bearer ")) { + return err(new Error("missing bearer token")); + } + const token = authHeader.slice("Bearer ".length); + const verified = await fromPromise( + jwtVerify(token, keySet, { + issuer, + audience: opts.audience, + algorithms: ["RS256"], + }), + ); + if (!verified.ok) { + logger?.warn( + { + channelType: "googlechat" as const, + hint: "Reject the unverified inbound event; confirm the caller is Google Chat", + errorKind: "auth" as const, + }, + "Inbound event rejected: token verification failed", + ); + return err(verified.error); + } + return ok(undefined); + }; +} From 6a874c85d58c29bdd8f9d507fa2d642faa1a3274 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 08:50:24 +0300 Subject: [PATCH 116/200] test(237-01): add failing app-url sender-binding + offline-seam tests - app-url matrix: a right-audience OIDC token with a DIFFERENT email is rejected (the auth-bypass hole the email binding closes), missing email, email_verified false/absent, wrong-issuer, bare-form issuer accepted, wrong-aud/expired/wrong-key - createLocalGoogleChatInboundVerifier verifies both audience types OFFLINE and still enforces the email binding (a full verify, not a bypass) --- .../src/googlechat/googlechat-auth.test.ts | 196 ++++++++++++++++++ 1 file changed, 196 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-auth.test.ts b/packages/channels/src/googlechat/googlechat-auth.test.ts index c572deff7..28ece5937 100644 --- a/packages/channels/src/googlechat/googlechat-auth.test.ts +++ b/packages/channels/src/googlechat/googlechat-auth.test.ts @@ -658,3 +658,199 @@ describe("createGoogleChatInboundVerifier — project-number audience", () => { expect(loggerSpy.serialized()).not.toContain(token); }); }); + +const APP_URL = "https://chat.example.com/hooks/googlechat"; +const GOOGLE_OIDC_ISS = "https://accounts.google.com"; +const CHAT_SYSTEM_EMAIL = "chat@system.gserviceaccount.com"; + +/** + * Mint an app-url-audience token (a Google OIDC ID token shape). The default + * payload carries the Chat-system sender-binding claims; a test overrides the + * payload to drop/alter `email` / `email_verified`. + */ +function mintAppUrlToken( + privateKey: CryptoKey, + payload: Record = { + email: CHAT_SYSTEM_EMAIL, + email_verified: true, + }, + opts: { iss?: string; aud?: string; exp?: number | string } = {}, +): Promise { + return new SignJWT(payload) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuer(opts.iss ?? GOOGLE_OIDC_ISS) + .setAudience(opts.aud ?? APP_URL) + .setExpirationTime(opts.exp ?? "5m") + .sign(privateKey); +} + +describe("createGoogleChatInboundVerifier — app-url audience (sender-binding)", () => { + it("verifies a Google OIDC token bound to the Chat system (email + email_verified)", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "app-url", + audience: APP_URL, + jwks, + }); + const token = await mintAppUrlToken(privateKey); + expect((await verify(`Bearer ${token}`)).ok).toBe(true); + }); + + it("REJECTS a right-audience OIDC token whose email is NOT the Chat system (the auth-bypass hole)", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "app-url", + audience: APP_URL, + jwks, + }); + // A perfectly valid Google-signed OIDC token for the SAME audience but minted + // for a DIFFERENT principal — without the email binding this would pass. + const token = await mintAppUrlToken(privateKey, { + email: "attacker@evil.example", + email_verified: true, + }); + expect((await verify(`Bearer ${token}`)).ok).toBe(false); + }); + + it("rejects a token missing the email claim entirely", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "app-url", + audience: APP_URL, + jwks, + }); + const token = await mintAppUrlToken(privateKey, { email_verified: true }); + expect((await verify(`Bearer ${token}`)).ok).toBe(false); + }); + + it("rejects a token whose email_verified is false or absent", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "app-url", + audience: APP_URL, + jwks, + }); + const falseVerified = await mintAppUrlToken(privateKey, { + email: CHAT_SYSTEM_EMAIL, + email_verified: false, + }); + expect((await verify(`Bearer ${falseVerified}`)).ok).toBe(false); + const absentVerified = await mintAppUrlToken(privateKey, { + email: CHAT_SYSTEM_EMAIL, + }); + expect((await verify(`Bearer ${absentVerified}`)).ok).toBe(false); + }); + + it("rejects a project-number-issuer token presented to an app-url verifier", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "app-url", + audience: APP_URL, + jwks, + }); + // Right email + audience, but the WRONG issuer for the OIDC shape. + const token = await mintAppUrlToken( + privateKey, + { email: CHAT_SYSTEM_EMAIL, email_verified: true }, + { iss: CHAT_SYSTEM_ISS }, + ); + expect((await verify(`Bearer ${token}`)).ok).toBe(false); + }); + + it("accepts the bare-form Google OIDC issuer (accounts.google.com, no scheme)", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "app-url", + audience: APP_URL, + jwks, + }); + const token = await mintAppUrlToken( + privateKey, + { email: CHAT_SYSTEM_EMAIL, email_verified: true }, + { iss: "accounts.google.com" }, + ); + expect((await verify(`Bearer ${token}`)).ok).toBe(true); + }); + + it("rejects wrong-audience / expired / wrong-key on the app-url branch", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "app-url", + audience: APP_URL, + jwks, + }); + const wrongAud = await mintAppUrlToken(privateKey, undefined, { + aud: "https://evil.example/hook", + }); + expect((await verify(`Bearer ${wrongAud}`)).ok).toBe(false); + const expired = await mintAppUrlToken(privateKey, undefined, { exp: 2 }); + expect((await verify(`Bearer ${expired}`)).ok).toBe(false); + const foreign = await generateKeyPair("RS256"); + const wrongKey = await mintAppUrlToken(foreign.privateKey as CryptoKey); + expect((await verify(`Bearer ${wrongKey}`)).ok).toBe(false); + }); + + it("never records the token on the sender-binding rejection", async () => { + const { privateKey, jwks } = await makeInboundKeyContext(); + const loggerSpy = makeLoggerSpy(); + const verify = gcAuth.createGoogleChatInboundVerifier({ + audienceType: "app-url", + audience: APP_URL, + jwks, + logger: loggerSpy.logger, + }); + const token = await mintAppUrlToken(privateKey, { + email: "attacker@evil.example", + email_verified: true, + }); + expect((await verify(`Bearer ${token}`)).ok).toBe(false); + expect(loggerSpy.serialized()).not.toContain(token); + }); +}); + +describe("createLocalGoogleChatInboundVerifier — offline full verify (test seam)", () => { + it("verifies a project-number token OFFLINE against an injected raw JWKS", async () => { + const { privateKey, jwk } = await makeInboundKeyContext(); + const verify = gcAuth.createLocalGoogleChatInboundVerifier( + { keys: [jwk] }, + { audienceType: "project-number", audience: PROJECT_NUMBER }, + ); + const token = await mintProjectToken(privateKey); + expect((await verify(`Bearer ${token}`)).ok).toBe(true); + // A wrong-key token is still rejected — a FULL verify, never a bypass. + const foreign = await generateKeyPair("RS256"); + const bad = await mintProjectToken(foreign.privateKey as CryptoKey); + expect((await verify(`Bearer ${bad}`)).ok).toBe(false); + }); + + it("verifies an app-url token OFFLINE and STILL enforces the email sender-binding", async () => { + const { privateKey, jwk } = await makeInboundKeyContext(); + const verify = gcAuth.createLocalGoogleChatInboundVerifier( + { keys: [jwk] }, + { audienceType: "app-url", audience: APP_URL }, + ); + const good = await mintAppUrlToken(privateKey); + expect((await verify(`Bearer ${good}`)).ok).toBe(true); + const wrongEmail = await mintAppUrlToken(privateKey, { + email: "attacker@evil.example", + email_verified: true, + }); + expect((await verify(`Bearer ${wrongEmail}`)).ok).toBe(false); + }); + + it("honors an issuer override for a fully-synthetic emulator issuer", async () => { + const { privateKey, jwk } = await makeInboundKeyContext(); + const verify = gcAuth.createLocalGoogleChatInboundVerifier( + { keys: [jwk] }, + { + audienceType: "project-number", + audience: PROJECT_NUMBER, + issuer: "https://emulator.test", + }, + ); + const token = await mintProjectToken(privateKey, { + iss: "https://emulator.test", + }); + expect((await verify(`Bearer ${token}`)).ok).toBe(true); + }); +}); From ba16835370525b1fe8bd6c03817a8e7a1749c9a8 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 08:52:29 +0300 Subject: [PATCH 117/200] feat(237-01): app-url sender-binding + offline verifier + barrel export MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app-url branch asserts payload.email === chat-system email AND email_verified === true after jose verify — binds the generic Google OIDC token to the Chat system sender (closes the auth-bypass hole) - createLocalGoogleChatInboundVerifier wraps a raw JWKS via createLocalJWKSet for the offline daemon test-seam; a FULL verify (incl. email), never a bypass - barrel-export both verifiers + GoogleChatInboundVerifierOpts from @comis/channels --- .../src/googlechat/googlechat-auth.ts | 68 +++++++++++++++++-- packages/channels/src/index.ts | 3 + 2 files changed, 67 insertions(+), 4 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-auth.ts b/packages/channels/src/googlechat/googlechat-auth.ts index 3d23b4156..0a872a1c0 100644 --- a/packages/channels/src/googlechat/googlechat-auth.ts +++ b/packages/channels/src/googlechat/googlechat-auth.ts @@ -7,7 +7,8 @@ * library-backed (jose) signature + issuer + audience + expiry verification * against Google's signing keys — dual-audience by config (`project-number` * verifies against the Chat-system JWK set; `app-url` against Google's OIDC - * certs). Never hand-rolled crypto. + * certs PLUS a sender-binding `email` claim that ties the generic OIDC token + * to the Chat system). Never hand-rolled crypto. * 2. Outbound service-account token mint: a per-scope expiry+skew-cached * JWT-bearer access token. * @@ -43,7 +44,9 @@ import { importPKCS8, SignJWT, createRemoteJWKSet, + createLocalJWKSet, jwtVerify, + type JSONWebKeySet, } from "jose"; import { classifyGoogleChatError } from "./errors.js"; @@ -342,6 +345,14 @@ export function createGoogleChatTokenProvider( */ const CHAT_SYSTEM_ISSUER = "chat@system.gserviceaccount.com"; +/** + * The sender-binding `email` claim an app-url OIDC token must carry (with + * `email_verified === true`) to prove the Chat system is the sender. Without this + * assertion any Google-signed OIDC token minted for the endpoint URL would verify; + * the email claim is what binds the generic OIDC token to Google Chat. + */ +const CHAT_SYSTEM_EMAIL = "chat@system.gserviceaccount.com"; + /** * Accepted issuers for an app-url OIDC ID token — Google's OIDC issuer in both * the scheme-qualified and bare forms (jose's `issuer` option accepts an array). @@ -415,9 +426,10 @@ export interface GoogleChatInboundVerifierOpts { * A missing or non-Bearer header is rejected by a cheap pre-gate with no key-set * access (no network). A blank expected audience fails closed before any key-set * access. A present token is verified by jose against the issuer, the audience, - * the signature, the expiry, and an `["RS256"]` algorithm allowlist. The verify - * error stays here (the caller surfaces an opaque rejection); the token is never - * logged. + * the signature, the expiry, and an `["RS256"]` algorithm allowlist. For the + * `app-url` shape the verified token must additionally carry the Chat-system + * sender-binding claims (`email` + `email_verified`). The verify error stays here + * (the caller surfaces an opaque rejection); the token is never logged. */ export function createGoogleChatInboundVerifier( opts: GoogleChatInboundVerifierOpts, @@ -459,6 +471,54 @@ export function createGoogleChatInboundVerifier( ); return err(verified.error); } + // An app-url token is a generic Google OIDC ID token; the `email` + + // `email_verified` claims are what bind it to the Chat system as the sender. + // Without this, any Google-signed OIDC token for the endpoint URL would pass. + if (!isProjectNumber) { + const { payload } = verified.value; + if ( + payload.email !== CHAT_SYSTEM_EMAIL || + payload.email_verified !== true + ) { + logger?.warn( + { + channelType: "googlechat" as const, + hint: "Reject the unverified inbound event; confirm the caller is Google Chat", + errorKind: "auth" as const, + }, + "Inbound event rejected: token not bound to the Chat system sender", + ); + return err(new Error("token not bound to the Chat system sender")); + } + } return ok(undefined); }; } + +/** + * Build an inbound Chat-event verifier over a LOCAL JWKS (a `{ keys: [...] }` + * set) instead of the default remote Google JWK set — verification runs with NO + * network, against a key set the caller supplies. The offline analog of + * {@link createGoogleChatInboundVerifier}, for a test rig / live-test emulator + * that holds its own signing key. Production keeps the default remote-JWKS + * verifier untouched: this path is reached only when a caller opts in with a + * local key set. The FULL verify still runs — signature, issuer, audience, and + * (for app-url) the sender-binding email claims — so this is never a verification + * bypass. The issuer defaults to the audienceType's Google issuer and can be + * overridden for a fully-synthetic emulator issuer. + */ +export function createLocalGoogleChatInboundVerifier( + jwks: JSONWebKeySet, + opts: { + audienceType: "project-number" | "app-url"; + audience: string; + issuer?: string; + }, +): (authHeader: string | undefined) => Promise> { + return createGoogleChatInboundVerifier({ + audienceType: opts.audienceType, + audience: opts.audience, + jwks: createLocalJWKSet(jwks), + ...(opts.issuer !== undefined ? { issuer: opts.issuer } : {}), + }); +} diff --git a/packages/channels/src/index.ts b/packages/channels/src/index.ts index 5c18c65b3..cdb099f9b 100644 --- a/packages/channels/src/index.ts +++ b/packages/channels/src/index.ts @@ -190,6 +190,8 @@ export { mapGoogleChatEventToNormalized } from "./googlechat/message-mapper.js"; export type { GoogleChatEvent } from "./googlechat/message-mapper.js"; export { createGoogleChatTokenProvider, + createGoogleChatInboundVerifier, + createLocalGoogleChatInboundVerifier, CHAT_SCOPE, PUBSUB_SCOPE, } from "./googlechat/googlechat-auth.js"; @@ -197,6 +199,7 @@ export type { GoogleChatTokenDeps, GoogleChatTokenProvider, GoogleChatScope, + GoogleChatInboundVerifierOpts, } from "./googlechat/googlechat-auth.js"; export { validateGoogleChatCredentials } from "./googlechat/credential-validator.js"; export { classifyGoogleChatError } from "./googlechat/errors.js"; From 8a08545ef23ba20c75fc2eca6788b4c3b953e7d5 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 09:02:25 +0300 Subject: [PATCH 118/200] test(237-02): add failing test for Google Chat ingress gate order + opaque 401 - verify-before-parse: dispatch never called on either 401 branch - opaque 401 leaks no validator detail; malformed body 4xx; throwing dispatch still acks - content-free onAuthRejected(missing_bearer|invalid_token), no-op when absent --- .../googlechat-ingress.test.ts | 223 ++++++++++++++++++ 1 file changed, 223 insertions(+) create mode 100644 packages/gateway/src/channel-ingress/googlechat-ingress.test.ts diff --git a/packages/gateway/src/channel-ingress/googlechat-ingress.test.ts b/packages/gateway/src/channel-ingress/googlechat-ingress.test.ts new file mode 100644 index 000000000..b2ca6d5ee --- /dev/null +++ b/packages/gateway/src/channel-ingress/googlechat-ingress.test.ts @@ -0,0 +1,223 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, vi, type Mock } from "vitest"; +import { ok, err, type Result } from "@comis/shared"; +import type { ComisLogger } from "@comis/core"; +import type { GoogleChatIngressDeps } from "./googlechat-ingress.js"; +import { createGoogleChatIngress } from "./googlechat-ingress.js"; + +/** A no-op logger satisfying the structural ComisLogger contract. */ +function noopLogger(): ComisLogger { + const noop = (): void => {}; + return { + level: "silent", + trace: noop, + debug: noop, + info: noop, + warn: noop, + error: noop, + fatal: noop, + audit: noop, + child: () => noopLogger(), + }; +} + +// A stub validator so the handler test needs no real signing keys: only +// "Bearer good" verifies; every other token is rejected. The rejection carries +// an internal-looking message the opacity assertion proves never reaches the +// 401 response body. +const stubValidator = async ( + authHeader: string | undefined, +): Promise> => + authHeader === "Bearer good" + ? ok(undefined) + : err(new Error("token signature invalid: kid=rotated-key issuer=api.mismatch")); + +interface AppOverrides { + validateInboundJwt?: GoogleChatIngressDeps["validateInboundJwt"]; + handleWebhookEvents?: Mock; + onAuthRejected?: Mock; +} + +function createApp(overrides: AppOverrides = {}) { + const handleWebhookEvents: Mock = overrides.handleWebhookEvents ?? vi.fn(); + // The auth-reject hook is OPTIONAL: it is threaded only when a test supplies + // it, so the existing cases exercise the no-op-when-absent composition path. + const deps: GoogleChatIngressDeps = { + validateInboundJwt: overrides.validateInboundJwt ?? stubValidator, + handleWebhookEvents, + logger: noopLogger(), + ...(overrides.onAuthRejected ? { onAuthRejected: overrides.onAuthRejected } : {}), + }; + const app = createGoogleChatIngress(deps); + return { app, handleWebhookEvents, onAuthRejected: overrides.onAuthRejected }; +} + +function post( + app: ReturnType, + headers: Record, + body: string, +) { + return app.request("/", { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body, + }); +} + +describe("createGoogleChatIngress", () => { + it("returns 401 and does NOT dispatch when the Authorization header is missing", async () => { + const { app, handleWebhookEvents } = createApp(); + + const res = await post(app, {}, JSON.stringify({ message: { text: "hi" } })); + + expect(res.status).toBe(401); + expect(handleWebhookEvents).not.toHaveBeenCalled(); + }); + + it("returns 401 with no dispatch and no internal detail when the validator rejects the token", async () => { + const { app, handleWebhookEvents } = createApp(); + + const res = await post( + app, + { authorization: "Bearer bad" }, + JSON.stringify({ message: { text: "hi" } }), + ); + + expect(res.status).toBe(401); + expect(handleWebhookEvents).not.toHaveBeenCalled(); + + // Opacity: the injected validator's error detail must not leak into the body. + const serialized = JSON.stringify(await res.json()); + expect(serialized).not.toContain("token signature invalid"); + expect(serialized).not.toContain("kid=rotated-key"); + expect(serialized).not.toContain("issuer="); + }); + + it("fast-acks (200/202) and dispatches the event exactly once on a valid token", async () => { + const { app, handleWebhookEvents } = createApp(); + + const res = await post( + app, + { authorization: "Bearer good" }, + JSON.stringify({ type: "MESSAGE", message: { text: "hi" } }), + ); + + expect([200, 202]).toContain(res.status); + expect(handleWebhookEvents).toHaveBeenCalledOnce(); + + const [events] = handleWebhookEvents.mock.calls[0] as [unknown[]]; + expect(Array.isArray(events)).toBe(true); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "MESSAGE", message: { text: "hi" } }); + }); + + it("rejects a malformed JSON body with a 4xx and does not dispatch garbage", async () => { + const { app, handleWebhookEvents } = createApp(); + + const res = await post(app, { authorization: "Bearer good" }, "not-json{{{"); + + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + expect(handleWebhookEvents).not.toHaveBeenCalled(); + }); + + it("still fast-acks and leaks nothing when the injected dispatch throws", async () => { + const throwing: Mock = vi.fn(() => { + throw new Error("pipeline exploded at 10.0.0.5: connection refused"); + }); + const { app } = createApp({ handleWebhookEvents: throwing }); + + const res = await post( + app, + { authorization: "Bearer good" }, + JSON.stringify({ type: "MESSAGE", message: { text: "hi" } }), + ); + + // A throwing downstream must neither break the fast ack nor surface detail. + expect([200, 202]).toContain(res.status); + expect(throwing).toHaveBeenCalledOnce(); + + const text = await res.text(); + expect(text).not.toContain("pipeline exploded"); + expect(text).not.toContain("10.0.0.5"); + }); +}); + +// --------------------------------------------------------------------------- +// Content-free auth-reject fleet signal. +// +// Each 401 gate fires an injected content-free hook so a forged / expired / +// wrong-audience / missing-token FLOOD is COUNTABLE by the fleet lens — while +// the rejection behavior and the opaque 401 response stay exactly as they were. +// --------------------------------------------------------------------------- +describe("createGoogleChatIngress — content-free auth-reject signal", () => { + it("signals reason 'missing_bearer' on the missing-bearer 401, behavior + opaque body unchanged", async () => { + const onAuthRejected: Mock = vi.fn(); + const { app, handleWebhookEvents } = createApp({ onAuthRejected }); + + const res = await post(app, {}, JSON.stringify({ message: { text: "hi" } })); + + // The 401 gate + fixed opaque body are UNCHANGED — the signal is additive. + expect(res.status).toBe(401); + expect(await res.json()).toEqual({ error: "unauthorized" }); + expect(handleWebhookEvents).not.toHaveBeenCalled(); + + // The content-free signal fired exactly once with ONLY the closed reason. + expect(onAuthRejected).toHaveBeenCalledOnce(); + expect(onAuthRejected).toHaveBeenCalledWith("missing_bearer"); + }); + + it("signals reason 'invalid_token' on the JWT-invalid 401 and carries no token material", async () => { + const onAuthRejected: Mock = vi.fn(); + const { app, handleWebhookEvents } = createApp({ onAuthRejected }); + + const res = await post( + app, + { authorization: "Bearer bad" }, + JSON.stringify({ message: { text: "hi" } }), + ); + + expect(res.status).toBe(401); + expect(handleWebhookEvents).not.toHaveBeenCalled(); + expect(onAuthRejected).toHaveBeenCalledOnce(); + + // Content-free: the ONLY argument is the closed reason string — no token, + // no Authorization header, no body can ride the signal. + const call = onAuthRejected.mock.calls[0] as unknown[]; + expect(call).toEqual(["invalid_token"]); + expect(JSON.stringify(call)).not.toContain("Bearer"); + expect(JSON.stringify(call)).not.toContain("bad"); + }); + + it("does NOT signal on a valid event (no false-positive flood counts)", async () => { + const onAuthRejected: Mock = vi.fn(); + const { app, handleWebhookEvents } = createApp({ onAuthRejected }); + + const res = await post( + app, + { authorization: "Bearer good" }, + JSON.stringify({ type: "MESSAGE", message: { text: "hi" } }), + ); + + expect([200, 202]).toContain(res.status); + expect(handleWebhookEvents).toHaveBeenCalledOnce(); + expect(onAuthRejected).not.toHaveBeenCalled(); + }); + + it("still rejects 401 with no onAuthRejected hook injected (no-op when absent)", async () => { + // The composition path may omit the hook — the gate stays intact and must + // not throw for a missing bearer or an invalid token. + const { app, handleWebhookEvents } = createApp(); // no onAuthRejected + + const missing = await post(app, {}, JSON.stringify({ message: { text: "hi" } })); + expect(missing.status).toBe(401); + + const invalid = await post( + app, + { authorization: "Bearer bad" }, + JSON.stringify({ message: { text: "hi" } }), + ); + expect(invalid.status).toBe(401); + expect(handleWebhookEvents).not.toHaveBeenCalled(); + }); +}); From d8b3d3022b1118f726055f6b72f941af3635baae Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 09:04:24 +0300 Subject: [PATCH 119/200] =?UTF-8?q?feat(237-02):=20createGoogleChatIngress?= =?UTF-8?q?=20=E2=80=94=20Hono=20sub-app=20over=20injected=20deps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - gate order: Bearer pre-gate -> validateInboundJwt -> parse -> fire-and-forget ack - opaque {error:"unauthorized"} 401 on both auth branches; validator detail never surfaced - content-free onAuthRejected(missing_bearer|invalid_token); no-op when absent - imports only hono + @comis/core + @comis/shared (no @comis/channels, no jose) - bare 202 ack for every event (no synchronous invoke branch) --- .../src/channel-ingress/googlechat-ingress.ts | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 packages/gateway/src/channel-ingress/googlechat-ingress.ts diff --git a/packages/gateway/src/channel-ingress/googlechat-ingress.ts b/packages/gateway/src/channel-ingress/googlechat-ingress.ts new file mode 100644 index 000000000..38a9ba4c6 --- /dev/null +++ b/packages/gateway/src/channel-ingress/googlechat-ingress.ts @@ -0,0 +1,173 @@ +// SPDX-License-Identifier: Apache-2.0 +import { Hono } from "hono"; +import type { Result } from "@comis/shared"; +import { systemNowMs, type ComisLogger } from "@comis/core"; + +/** Pipeline-stage tag for this boundary's structured logs. */ +const INGRESS_STEP = "googlechat-ingress"; +/** Authorization scheme the inbound event must carry. */ +const BEARER_PREFIX = "Bearer "; + +/** + * The closed rejection class passed to the content-free auth-reject hook. + * `missing_bearer` — the request carried no bearer token (the cheap pre-gate); + * `invalid_token` — a bearer token was present but failed signed-token + * validation (forged / unsigned / expired / wrong-audience). The class is the + * ONLY thing the hook ever receives — never the token, header, or body. + */ +export type GoogleChatIngressAuthRejectReason = "missing_bearer" | "invalid_token"; + +/** + * Dependencies for the Google Chat inbound ingress. + * + * The factory is framework-agnostic over these injected closures: it holds + * no channel-adapter or JWT-library imports, so the gateway gains no + * dependency on the channels package. The composition root binds the + * concrete validator (with the expected audience already closed over) and + * the adapter's inbound driver. + */ +export interface GoogleChatIngressDeps { + /** + * Validates the inbound `Authorization` header (a signed bearer token). The + * caller has already bound the expected audience. Resolves `ok` when the + * token verifies, `err` otherwise — the handler rejects with 401 and never + * surfaces the error detail to the caller. + */ + readonly validateInboundJwt: ( + authHeader: string | undefined, + ) => Promise>; + /** + * Hands the validated events to the adapter's inbound pipeline. The body is + * treated as opaque here and cast to the concrete event type at the adapter + * boundary. + */ + readonly handleWebhookEvents: (events: unknown[]) => void; + /** + * Optional content-free hook fired on every auth-gate rejection (before any + * body parse or adapter dispatch), carrying ONLY the closed rejection class. + * The composition root binds it to a daemon eventBus emit so an ingress + * forged/expired/wrong-audience/missing-token FLOOD is COUNTABLE by the fleet + * lens instead of living only in a raw WARN. A no-op when absent — the gate + * and its opaque 401 are unchanged either way; the hook NEVER receives the + * token, the Authorization header, or the request body. + */ + readonly onAuthRejected?: (reason: GoogleChatIngressAuthRejectReason) => void; + readonly logger: ComisLogger; +} + +/** + * Create the Hono sub-application that receives Google Chat webhook events. + * + * The daemon mounts it at `/channels/googlechat`, so the effective public + * route is `POST /channels/googlechat/`. Every request is untrusted until the + * injected validator passes: + * + * 1. Cheap Bearer-presence pre-gate — a request with no bearer token is + * rejected 401 before any validation or body work (avoids expensive work + * on unauthenticated floods). + * 2. Full token validation via `validateInboundJwt` — a forged / unsigned / + * wrong-audience token is rejected 401 with the body left unparsed and the + * adapter never reached. + * 3. Parse guard — a malformed JSON body is rejected 4xx. + * 4. Ack — the handler dispatches the events to `handleWebhookEvents` + * defensively (a throwing dispatch is contained so it neither blocks the + * ack nor surfaces internal detail), then acks with a bare 202. Delivery is + * fire-and-forget: the normalizer runs out-of-band and any reply reaches the + * space asynchronously. + * + * All responses are opaque: every error carries a fixed message — neither the + * validator's nor the dispatch's internal error text is ever surfaced to the + * caller. + */ +export function createGoogleChatIngress(deps: GoogleChatIngressDeps): Hono { + const { validateInboundJwt, handleWebhookEvents, onAuthRejected, logger } = + deps; + const app = new Hono(); + + app.post("/", async (c) => { + const startedAt = systemNowMs(); + + // (1) Bearer-presence pre-gate — reject before any validation or body read. + const authHeader = + c.req.header("authorization") ?? c.req.header("Authorization"); + if (authHeader === undefined || !authHeader.startsWith(BEARER_PREFIX)) { + logger.warn( + { + step: INGRESS_STEP, + hint: "Reject inbound event without a bearer token", + errorKind: "auth" as const, + }, + "Rejected inbound event: missing bearer token", + ); + // Fleet-visible, content-free: the class only — never the (absent) token. + onAuthRejected?.("missing_bearer"); + return c.json({ error: "unauthorized" }, 401); + } + + // (2) Full token validation — no body is processed on failure, and the + // validator's error detail is never surfaced to the caller. + logger.debug({ step: INGRESS_STEP }, "Validating inbound event token"); + const verdict = await validateInboundJwt(authHeader); + if (!verdict.ok) { + logger.warn( + { + step: INGRESS_STEP, + hint: "Reject unverified inbound event", + errorKind: "auth" as const, + }, + "Rejected inbound event: token validation failed", + ); + // Fleet-visible, content-free: the class only — never the forged token. + onAuthRejected?.("invalid_token"); + return c.json({ error: "unauthorized" }, 401); + } + + // (3) Parse guard — the body is opaque and only touched after validation. + let body: unknown; + try { + body = await c.req.json(); + } catch { + logger.warn( + { + step: INGRESS_STEP, + hint: "Send a JSON event body", + errorKind: "validation" as const, + }, + "Rejected inbound event: malformed JSON body", + ); + return c.json({ error: "invalid body" }, 400); + } + + // A single-event POST is normalized to the array the adapter expects. + const events: unknown[] = Array.isArray(body) ? body : [body]; + + // (4) Fast ack. Dispatch defensively so a downstream failure neither + // blocks the ack nor leaks internal detail through the public endpoint. + logger.debug( + { step: INGRESS_STEP, eventCount: events.length }, + "Dispatching inbound events", + ); + try { + handleWebhookEvents(events); + } catch (dispatchErr) { + logger.error( + { + step: INGRESS_STEP, + err: dispatchErr, + hint: "Inspect the channel inbound pipeline", + errorKind: "internal" as const, + }, + "Inbound event dispatch failed", + ); + } + + const durationMs = systemNowMs() - startedAt; + logger.info( + { step: INGRESS_STEP, durationMs }, + "Inbound event accepted", + ); + return c.body(null, 202); + }); + + return app; +} From f0dfadd8edf07ce9dfd44aae793c683a465e218f Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 09:11:43 +0300 Subject: [PATCH 120/200] feat(237-02): barrel-export createGoogleChatIngress + import-hygiene guard - export createGoogleChatIngress + GoogleChatIngressDeps from @comis/gateway - baseline both as ahead-of-consumer orphans (daemon wiring lands in a later wave) - also baseline the three @comis/channels inbound-verifier orphans the prior plan surfaced but never tracked (its gate set omitted the architecture project) - gateway gains no @comis/channels or jose dep; cycles:refs clean --- packages/gateway/src/index.ts | 6 ++++++ test/support/public-api-policy.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/gateway/src/index.ts b/packages/gateway/src/index.ts index 0d23d0b3e..7013d655a 100644 --- a/packages/gateway/src/index.ts +++ b/packages/gateway/src/index.ts @@ -47,6 +47,12 @@ export { getPresetMappings } from "./webhook/webhook-presets.js"; export { createMsTeamsIngress } from "./channel-ingress/msteams-ingress.js"; export type { MsTeamsIngressDeps } from "./channel-ingress/msteams-ingress.js"; +// Channel ingress -- Google Chat inbound events (mounted per-channel by the +// daemon; framework-agnostic over injected validator + adapter driver, so the +// gateway gains no @comis/channels or jose dependency) +export { createGoogleChatIngress } from "./channel-ingress/googlechat-ingress.js"; +export type { GoogleChatIngressDeps } from "./channel-ingress/googlechat-ingress.js"; + // OAuth callback route exports export { createOAuthCallbackRoute, diff --git a/test/support/public-api-policy.ts b/test/support/public-api-policy.ts index 89d782113..f021ef1ca 100644 --- a/test/support/public-api-policy.ts +++ b/test/support/public-api-policy.ts @@ -636,6 +636,15 @@ export const PUBLIC_API_POLICY: ReadonlyMap> = "GoogleChatTokenProvider", "GoogleChatScope", "classifyGoogleChatError", + // Google Chat INBOUND verify closures, surfaced AHEAD of their consumer. + // createGoogleChatInboundVerifier (dual-audience remote-JWKS) + its + // local-JWKS offline twin createLocalGoogleChatInboundVerifier + the shared + // GoogleChatInboundVerifierOpts shape are consumed by the daemon test-seam + // that binds the gateway ingress's validateInboundJwt in a later wave. + // Shrink each once that cross-package consumer lands. + "createGoogleChatInboundVerifier", + "createLocalGoogleChatInboundVerifier", + "GoogleChatInboundVerifierOpts", "createIMessageAdapter", "IMessageAdapterDeps", "mapImsgToNormalized", @@ -2609,6 +2618,13 @@ export const PUBLIC_API_POLICY: ReadonlyMap> = // MsTeamsIngressDeps is the factory's deps shape, which the daemon // constructs inline (like ApprovalTokenDeps) — tracked here for parity. "MsTeamsIngressDeps", + // Google Chat inbound ingress, surfaced AHEAD of its consumer. Unlike the + // shipped msteams factory, createGoogleChatIngress has no cross-package + // importer yet — the daemon composition root (setup-channels-adapters.ts) + // builds it in a later wave, so both the factory and its inline-constructed + // deps shape are tracked here. Shrink both once that consumer lands. + "createGoogleChatIngress", + "GoogleChatIngressDeps", ])], // @comis/infra: baseline orphans + transient orphans. // createSystemClock/createSystemEnv/createSystemTimers are Node-backed From fa93ee9be0d9f2607c62a399683f6b00fe1b2c85 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 09:21:39 +0300 Subject: [PATCH 121/200] test(237-03): webhook-mode start() skips the pull loop, keeps the SA-key check - webhook + valid key + no subscriptionName -> ok, connected, createSource never called - webhook + valid key + subscriptionName present -> still skips the loop - webhook + blank SA key -> err (webhook still sends replies, so it needs the key) - pubsub + blank subscriptionName -> still err (pull subscription precondition unchanged) --- .../src/googlechat/googlechat-adapter.test.ts | 61 ++++++++++++++++--- 1 file changed, 52 insertions(+), 9 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index eae7ca01d..1c93a02a5 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -523,20 +523,63 @@ describe("createGoogleChatAdapter — lifecycle", () => { expect(typeof holder.sourceDeps?.getPubSubToken).toBe("function"); }); - it("WARNs (errorKind 'config', knob-naming hint) but still boots the pull loop when mode is 'webhook'", async () => { - // The webhook transport is not wired; mode:"webhook" must not silently pass. - // Emit a clear config WARN naming the knob, then run the Pub/Sub pull loop. - const { deps, loggerSpy, fake } = await makeDeps({ mode: "webhook" }); + it("start() in webhook mode with valid creds and NO subscriptionName returns ok, marks connected, and never opens the pull loop", async () => { + // Webhook mode has no Pub/Sub subscription — inbound arrives through the + // gateway ingress driving handleChatEvent — so start() must skip the pull + // loop entirely and must NOT require a subscriptionName. + const createSource = vi.fn((): PubSubSource => makeFakeSource().source); + const { deps } = await makeDeps({ + mode: "webhook", + subscriptionName: "", + createSource, + }); const adapter = createGoogleChatAdapter(deps); const result = await adapter.start(); expect(result.ok).toBe(true); - expect(fake.start).toHaveBeenCalledTimes(1); - const warn = findByErrorKind(loggerSpy.warn, "config"); - expect(warn).toBeDefined(); - expect(String(warn?.hint)).toContain("channels.googlechat.mode"); - expect(String(warn?.hint).toLowerCase()).toContain("pubsub"); + expect(createSource).not.toHaveBeenCalled(); + expect(adapter.getStatus?.().connected).toBe(true); + }); + + it("start() in webhook mode skips the pull loop even when a subscriptionName is present (the transport is the only mode difference)", async () => { + const createSource = vi.fn((): PubSubSource => makeFakeSource().source); + const { deps } = await makeDeps({ mode: "webhook", createSource }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.start(); + + expect(result.ok).toBe(true); + // Even with a subscription configured, webhook mode never opens the loop. + expect(createSource).not.toHaveBeenCalled(); + }); + + it("start() in webhook mode with a blank service-account key returns err and never opens the pull loop (webhook still sends replies, so it needs the key)", async () => { + const createSource = vi.fn((): PubSubSource => makeFakeSource().source); + const { deps, loggerSpy } = await makeDeps({ + mode: "webhook", + serviceAccountKey: "", + createSource, + }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.start(); + + expect(result.ok).toBe(false); + expect(createSource).not.toHaveBeenCalled(); + expect(adapter.getStatus?.().connected).toBe(false); + expect(loggerSpy.error).toHaveBeenCalled(); + }); + + it("start() in pubsub mode with a blank subscriptionName still returns err (the pull subscription precondition is unchanged)", async () => { + const createSource = vi.fn((): PubSubSource => makeFakeSource().source); + const { deps } = await makeDeps({ subscriptionName: "", createSource }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.start(); + + expect(result.ok).toBe(false); + expect(createSource).not.toHaveBeenCalled(); }); it("start() is idempotent: a second start() without an intervening stop() does not create/boot a second source", async () => { From 60fdd4a9f7dff564a9b736848cb515fe11ea4f5b Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 09:22:47 +0300 Subject: [PATCH 122/200] feat(237-03): fork Google Chat start() on transport mode - webhook mode keeps the SA-key precondition (it still sends replies), drops the subscriptionName precondition, and never opens the Pub/Sub pull loop - inbound arrives through the gateway ingress driving handleChatEvent - pubsub mode is unchanged: SA key + subscriptionName required, pull loop opens - remove the stub WARN that fell through to the pull loop in webhook mode --- .../src/googlechat/googlechat-adapter.ts | 51 +++++++++++++------ 1 file changed, 36 insertions(+), 15 deletions(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index d564e9be0..46c787500 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -498,9 +498,43 @@ export function createGoogleChatAdapter( sendAbort = new AbortController(); // The token provider already parsed the service-account key once at - // construction; reuse that result rather than re-parsing here. The - // subscription is the only additional precondition (it is not a parse). + // construction; reuse that result rather than re-parsing here. Both modes + // need a valid key: webhook mode opens no pull loop but still sends replies + // via the Chat REST API with the chat.bot bearer. const credErr = tokens.credentialError(); + + if (deps.mode === "webhook") { + // Webhook mode opens no Pub/Sub pull loop — inbound arrives through the + // gateway ingress driving handleChatEvent — so it has no subscription + // precondition. It still needs the service-account key to send replies, + // so a missing or invalid key fails start() exactly as the pull path does. + if (credErr) { + const startErr = new Error( + `Google Chat credentials invalid: ${credErr.hint}`, + ); + deps.logger.error( + { + channelType: "googlechat" as const, + err: startErr, + hint: "Set channels.googlechat.serviceAccountKey (SecretRef) or GOOGLECHAT_SA_KEY", + errorKind: "auth" as const, + }, + "Adapter start failed", + ); + _lastError = startErr.message; + return err(startErr); + } + _connected = true; + _startedAt = now(); + deps.logger.info( + { channelType: "googlechat" as const, mode: "webhook" }, + "Adapter started", + ); + return ok(undefined); + } + + // Pull (Pub/Sub) mode: the subscription is the only additional precondition + // (it is not a parse). const subMissing = !deps.subscriptionName || deps.subscriptionName.trim() === ""; if (credErr || subMissing) { @@ -522,19 +556,6 @@ export function createGoogleChatAdapter( return err(startErr); } - if (deps.mode && deps.mode !== "pubsub") { - // The webhook transport is not wired: name the knob, state what is - // actually running, and do not silently pretend webhook ingress is live. - deps.logger.warn( - { - channelType: "googlechat" as const, - hint: "Webhook ingress is not active; set channels.googlechat.mode to 'pubsub' — the Pub/Sub pull loop is being used instead", - errorKind: "config" as const, - }, - "Webhook ingress unavailable; running the Pub/Sub pull loop", - ); - } - const make = deps.createSource ?? createPubSubSource; source = make({ subscriptionName: deps.subscriptionName, From e8871e0a8c42187a2fafb466b442fd3e377258db Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 09:23:24 +0300 Subject: [PATCH 123/200] test(237-03): getStatus() reports connectionMode 'webhook' in webhook mode - webhook mode -> connectionMode 'webhook' (the liveness-monitor arming switch) - a webhook-driven handleChatEvent still bumps lastInboundAt (timer input) --- .../src/googlechat/googlechat-adapter.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/channels/src/googlechat/googlechat-adapter.test.ts b/packages/channels/src/googlechat/googlechat-adapter.test.ts index 1c93a02a5..f8bcab293 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.test.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -470,6 +470,25 @@ describe("createGoogleChatAdapter — status + lastInboundAt semantics", () => { expect(status?.lastInboundAt).toBeUndefined(); }); + it("reports connectionMode 'webhook' in webhook mode (the switch the liveness monitor keys on)", async () => { + const { deps } = await makeDeps({ mode: "webhook", subscriptionName: "" }); + const adapter = createGoogleChatAdapter(deps); + expect(adapter.getStatus?.().connectionMode).toBe("webhook"); + }); + + it("in webhook mode, an admitted inbound through handleChatEvent bumps lastInboundAt — both modes converge on the one normalizer feeding the liveness timer", async () => { + const { deps } = await makeDeps({ + mode: "webhook", + subscriptionName: "", + allowFrom: ["users/123"], + }); + const adapter = createGoogleChatAdapter(deps); + adapter.onMessage(vi.fn()); + await adapter.handleChatEvent(makeEvent()); + expect(adapter.getStatus?.().lastInboundAt).toBe(NOW); + expect(adapter.getStatus?.().connectionMode).toBe("webhook"); + }); + it("sets lastInboundAt after an allowed inbound", async () => { const { deps } = await makeDeps({ allowFrom: ["users/123"] }); const adapter = createGoogleChatAdapter(deps); From d867c88a8f7353256e221c22ee0cdc31a533dd1e Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 09:24:18 +0300 Subject: [PATCH 124/200] feat(237-03): report connectionMode 'webhook' in webhook mode - getStatus().connectionMode branches on the transport mode: 'webhook' in webhook mode, 'polling' in pull mode (previously hardcoded) - this is the switch the liveness monitor arms on and the health monitor treats as stale-reap-exempt --- packages/channels/src/googlechat/googlechat-adapter.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts index 46c787500..ccb77e19f 100644 --- a/packages/channels/src/googlechat/googlechat-adapter.ts +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -941,7 +941,7 @@ export function createGoogleChatAdapter( lastMessageAt: _lastMessageAt, lastInboundAt: _lastInboundAt, error: _lastError ?? source?.lastError, - connectionMode: "polling", + connectionMode: deps.mode === "webhook" ? "webhook" : "polling", }; }, From ff2c01a8f2b65f3164135a26c9367e70fa0a1e57 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 09:39:22 +0300 Subject: [PATCH 125/200] test(237-04): missed-inbound liveness arms per webhook channelType Add failing cases proving the monitor is hardwired to msteams: a googlechat-only webhook deployment never arms, and with both channels enabled a googlechat adapter is timed on msteams' threshold instead of its own. Extend the harness with a per-channel config shape and a caller-supplied adapter channelType; keep the existing msteams cases plus explicit generalization guards green. --- .../setup-channel-liveness-monitor.test.ts | 129 ++++++++++++++++-- 1 file changed, 118 insertions(+), 11 deletions(-) diff --git a/packages/daemon/src/wiring/setup-channel-liveness-monitor.test.ts b/packages/daemon/src/wiring/setup-channel-liveness-monitor.test.ts index 76039624e..11bac26d9 100644 --- a/packages/daemon/src/wiring/setup-channel-liveness-monitor.test.ts +++ b/packages/daemon/src/wiring/setup-channel-liveness-monitor.test.ts @@ -9,16 +9,19 @@ import type { BootContext } from "../daemon-types.js"; const THRESHOLD = 21_600_000; // the 6h MsTeamsChannelEntrySchema default /** A stub ChannelPort exposing only getStatus(). The status may be a static - * partial or a thunk so a test can advance lastInboundAt between checks. */ + * partial or a thunk so a test can advance lastInboundAt between checks. The + * channelType defaults to "msteams" but a caller can stamp another (e.g. + * "googlechat") so a test can exercise a second webhook channel. */ function makeAdapter( status: Partial | (() => Partial), + channelType = "msteams", ): ChannelPort { const resolve = typeof status === "function" ? status : () => status; return { getStatus: (): ChannelStatus => ({ connected: true, - channelId: "msteams-1", - channelType: "msteams", + channelId: `${channelType}-1`, + channelType, ...resolve(), }), } as unknown as ChannelPort; @@ -29,6 +32,12 @@ function makeHarness(opts: { enabled?: boolean; thresholdMs?: number; initialMs?: number; + /** Explicit per-channel config; when omitted, defaults to a single enabled + * msteams entry driven by `enabled`/`thresholdMs` (the shipped shape). */ + channels?: Record< + string, + { enabled?: boolean; missedInboundThresholdMs?: number } | undefined + >; }): { deps: Parameters[0]; emit: ReturnType; @@ -41,15 +50,14 @@ function makeHarness(opts: { const warn = vi.fn(); const timer = createFakeTimers(initialMs); const clock = createFakeClock(initialMs); - const container = { - config: { - channels: { - msteams: { - enabled: opts.enabled ?? true, - missedInboundThresholdMs: opts.thresholdMs ?? THRESHOLD, - }, - }, + const channels = opts.channels ?? { + msteams: { + enabled: opts.enabled ?? true, + missedInboundThresholdMs: opts.thresholdMs ?? THRESHOLD, }, + }; + const container = { + config: { channels }, eventBus: { emit }, } as unknown as BootContext["container"]; const daemonLogger = { @@ -191,4 +199,103 @@ describe("setupChannelLivenessMonitor", () => { // No interval was ever scheduled. expect(timer.unrefRecord().some((e) => e.kind === "interval")).toBe(false); }); + + it("arms for a googlechat-only webhook deployment (msteams absent) and emits with googlechat's own threshold", () => { + const G = 3_600_000; // googlechat's own 1h window + const { deps, emit, warn, clock, timer } = makeHarness({ + channels: { googlechat: { enabled: true, missedInboundThresholdMs: G } }, + adapters: [ + ["googlechat", makeAdapter({ connectionMode: "webhook", lastInboundAt: 0 }, "googlechat")], + ], + }); + const { stop } = setupChannelLivenessMonitor(deps); + clock.advance(G + 1_000_000); + timer.advance(G + 1_000_000); + + expect(emit).toHaveBeenCalledTimes(1); + const [event, payload] = emit.mock.calls[0]! as [ + string, + { channelType: string; thresholdMs: number; silentForMs: number }, + ]; + expect(event).toBe("channel:inbound_silent"); + expect(payload.channelType).toBe("googlechat"); + expect(payload.thresholdMs).toBe(G); + expect(payload.silentForMs).toBeGreaterThan(G); + + const warnFields = warn.mock.calls[0]![0] as { channelType: string }; + expect(warnFields.channelType).toBe("googlechat"); + stop?.(); + }); + + it("alerts each adapter at its OWN threshold when both msteams and googlechat webhook are enabled", () => { + const M = 21_600_000; // msteams 6h + const G = 3_600_000; // googlechat 1h (a smaller window) + const { deps, emit, clock, timer } = makeHarness({ + channels: { + msteams: { enabled: true, missedInboundThresholdMs: M }, + googlechat: { enabled: true, missedInboundThresholdMs: G }, + }, + // Only a googlechat adapter is in webhook mode here. + adapters: [ + ["googlechat", makeAdapter({ connectionMode: "webhook", lastInboundAt: 0 }, "googlechat")], + ], + }); + setupChannelLivenessMonitor(deps); + // Silence past googlechat's window G but NOT past msteams' larger window M. + const between = (G + M) / 2; + clock.advance(between); + timer.advance(between); + + expect(emit).toHaveBeenCalledTimes(1); + const payload = emit.mock.calls[0]![1] as { channelType: string; thresholdMs: number }; + expect(payload.channelType).toBe("googlechat"); + expect(payload.thresholdMs).toBe(G); // its own window, never msteams' + }); + + it("preserves msteams behavior: a msteams-only webhook deployment still arms and alerts at its own threshold", () => { + const M = 7_200_000; // a custom 2h msteams window + const { deps, emit, clock, timer } = makeHarness({ + channels: { msteams: { enabled: true, missedInboundThresholdMs: M } }, + adapters: [ + ["msteams", makeAdapter({ connectionMode: "webhook", lastInboundAt: 0 }, "msteams")], + ], + }); + setupChannelLivenessMonitor(deps); + clock.advance(M + 1_000_000); + timer.advance(M + 1_000_000); + + expect(emit).toHaveBeenCalledTimes(1); + const payload = emit.mock.calls[0]![1] as { channelType: string; thresholdMs: number }; + expect(payload.channelType).toBe("msteams"); + expect(payload.thresholdMs).toBe(M); + }); + + it("is a no-op when NO webhook channel is enabled (msteams and googlechat both disabled)", () => { + const { deps, timer } = makeHarness({ + channels: { + msteams: { enabled: false, missedInboundThresholdMs: THRESHOLD }, + googlechat: { enabled: false, missedInboundThresholdMs: THRESHOLD }, + }, + adapters: [ + ["googlechat", makeAdapter({ connectionMode: "webhook", lastInboundAt: 0 }, "googlechat")], + ], + }); + const result = setupChannelLivenessMonitor(deps); + expect(result.monitor).toBeUndefined(); + expect(result.stop).toBeUndefined(); + expect(timer.unrefRecord().some((e) => e.kind === "interval")).toBe(false); + }); + + it("is a no-op when a googlechat webhook channel is enabled but no adapter is in webhook mode (socket-only fleet)", () => { + const { deps, timer } = makeHarness({ + channels: { googlechat: { enabled: true, missedInboundThresholdMs: THRESHOLD } }, + adapters: [ + ["telegram", makeAdapter({ connectionMode: "socket", lastInboundAt: 0 }, "telegram")], + ], + }); + const result = setupChannelLivenessMonitor(deps); + expect(result.monitor).toBeUndefined(); + expect(result.stop).toBeUndefined(); + expect(timer.unrefRecord().some((e) => e.kind === "interval")).toBe(false); + }); }); From 63b2e5c20759f5dfb6724130a7dfdf7e799006e3 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 09:41:34 +0300 Subject: [PATCH 126/200] feat(237-04): resolve missed-inbound liveness per webhook channelType Arm the monitor when ANY webhook-capable channel is enabled (not msteams alone), and resolve each adapter's missed-inbound window from its own channelType config inside the scan loop. A googlechat-only webhook deployment now emits channel:inbound_silent on googlechat's window; with both enabled each adapter alerts on its own threshold. Poll cadence follows the tightest enabled window. msteams behavior is unchanged. --- .../wiring/setup-channel-liveness-monitor.ts | 39 +++++++++++++++---- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/packages/daemon/src/wiring/setup-channel-liveness-monitor.ts b/packages/daemon/src/wiring/setup-channel-liveness-monitor.ts index f4a26390a..d0f3b8398 100644 --- a/packages/daemon/src/wiring/setup-channel-liveness-monitor.ts +++ b/packages/daemon/src/wiring/setup-channel-liveness-monitor.ts @@ -30,6 +30,13 @@ import type { LoggingResult } from "./setup-logging.js"; */ const MAX_LIVENESS_CHECK_INTERVAL_MS = 900_000; // 15 minutes +/** + * Fallback missed-inbound window when a webhook channel config omits its own — + * the shared 6h schema default. A fully-defaulted config always carries the + * value; this guards a hand-written config that leaves it unset. + */ +const DEFAULT_MISSED_INBOUND_MS = 21_600_000; // 6 hours + /** The handle returned to the composition root; `stop()` cancels the timer. */ export interface ChannelLivenessMonitor { stop(): void; @@ -37,8 +44,8 @@ export interface ChannelLivenessMonitor { /** * Wire the missed-inbound liveness monitor. Returns `{ monitor, stop }`; both - * are `undefined` (a no-op) when the msteams webhook channel is disabled or no - * webhook adapter is present, mirroring `setupChannelHealthMonitor`. + * are `undefined` (a no-op) when no webhook channel is enabled or no webhook + * adapter is present, mirroring `setupChannelHealthMonitor`. */ export function setupChannelLivenessMonitor(deps: { adaptersByType: NonNullable; @@ -50,11 +57,16 @@ export function setupChannelLivenessMonitor(deps: { const { adaptersByType, daemonLogger, container, timer } = deps; const now = deps.now ?? systemNowMs; - // The threshold lives on the msteams config — the only webhook channel today - // (a fully-defaulted config always carries it). Disabled → nothing to watch. - const msteamsConfig = container.config.channels?.msteams; - if (!msteamsConfig?.enabled) return { monitor: undefined, stop: undefined }; - const thresholdMs = msteamsConfig.missedInboundThresholdMs; + // Arm when ANY webhook-capable channel is enabled. Each such channel carries + // its own missed-inbound window at the same config path, resolved per adapter + // below; a disabled channel contributes nothing to watch. + const webhookConfigs = { + msteams: container.config.channels?.msteams, + googlechat: container.config.channels?.googlechat, + }; + if (!Object.values(webhookConfigs).some((c) => c?.enabled)) { + return { monitor: undefined, stop: undefined }; + } /** Read an adapter's connection mode without letting a throwing getStatus() * abort the scan (mirrors the health monitor's defensive probe). */ @@ -93,6 +105,12 @@ export function setupChannelLivenessMonitor(deps: { } if (status?.connectionMode !== "webhook") continue; + // Resolve the window from this adapter's own channel config, so each + // webhook channel alerts on its own threshold. + const thresholdMs = + container.config.channels?.[channelType as "msteams" | "googlechat"] + ?.missedInboundThresholdMs ?? DEFAULT_MISSED_INBOUND_MS; + const lastInboundAt = status.lastInboundAt ?? null; const baselineMs = lastInboundAt ?? daemonStartMs; const silentForMs = currentMs - baselineMs; @@ -125,7 +143,12 @@ export function setupChannelLivenessMonitor(deps: { } } - const checkIntervalMs = Math.min(thresholdMs, MAX_LIVENESS_CHECK_INTERVAL_MS); + // Poll on the tightest enabled window (so the smallest threshold still + // detects on time), capped so detection latency stays bounded. + const enabledThresholds = Object.values(webhookConfigs) + .filter((c) => c?.enabled) + .map((c) => c?.missedInboundThresholdMs ?? DEFAULT_MISSED_INBOUND_MS); + const checkIntervalMs = Math.min(...enabledThresholds, MAX_LIVENESS_CHECK_INTERVAL_MS); const handle = timer.setInterval(checkOnce, checkIntervalMs); handle.unref(); From a85ceddc60f4780d5df5ebc6e695d9b1202c09bb Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 10:05:20 +0300 Subject: [PATCH 127/200] test(237-05): resolveTestGoogleChatVerifier default path returns the production verifier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Env unset ⇒ the seam returns the live remote-JWKS verifier - No file read and no activation WARN on the default path - Both audience types (project-number, app-url) resolve; deps bag optional - Fails RED: the seam module does not exist yet --- .../src/wiring/googlechat-test-seams.test.ts | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 packages/daemon/src/wiring/googlechat-test-seams.test.ts diff --git a/packages/daemon/src/wiring/googlechat-test-seams.test.ts b/packages/daemon/src/wiring/googlechat-test-seams.test.ts new file mode 100644 index 000000000..5e1f78de6 --- /dev/null +++ b/packages/daemon/src/wiring/googlechat-test-seams.test.ts @@ -0,0 +1,94 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Unit tests for the OFF-BY-DEFAULT Google Chat inbound-verify wiring seam. + * + * `resolveTestGoogleChatVerifier` builds the `validateInboundJwt` closure the + * gateway ingress consumes. It is gated on `COMIS_GOOGLECHAT_TEST_JWKS`, which is + * UNSET in production — with the env unset the daemon behaves byte-identically to + * today (the live remote-JWKS verifier). When the env names a JWKS file the seam + * swaps in a LOCAL-JWKS verifier that STILL fully verifies (signature + issuer + + * audience, plus the app-url sender-binding email claim) — it only changes the + * key source, never relaxes a control, so it is never an auth bypass. Env is read + * through the injected getter (never `process.env`); the file is read through an + * injected `readFileImpl`. + * + * @module + */ + +import { describe, expect, it, vi } from "vitest"; +import type { ComisLogger } from "@comis/core"; +import { resolveTestGoogleChatVerifier } from "./googlechat-test-seams.js"; + +/** A ComisLogger whose warn spy records every argument for content-free asserts. */ +function makeLoggerSpy(): { + logger: ComisLogger; + warn: ReturnType; + serialized: () => string; +} { + const warn = vi.fn(); + const noop = vi.fn(); + const logger = { + level: "debug", + trace: noop, + debug: noop, + info: noop, + warn, + error: noop, + fatal: noop, + audit: noop, + child: vi.fn().mockReturnThis(), + } as unknown as ComisLogger; + const serialized = (): string => JSON.stringify(warn.mock.calls); + return { logger, warn, serialized }; +} + +describe("resolveTestGoogleChatVerifier — default (production remote-JWKS) path", () => { + it("env unset ⇒ returns the production verifier; reads no file, logs no WARN (project-number)", async () => { + const readFileImpl = vi.fn((_p: string): string => { + throw new Error("readFileImpl must not be called on the default path"); + }); + const { logger, warn } = makeLoggerSpy(); + const getEnv = (_k: string): string | undefined => undefined; + + const verify = resolveTestGoogleChatVerifier( + { audienceType: "project-number", audience: "1234567890" }, + getEnv, + { readFileImpl, logger }, + ); + + expect(typeof verify).toBe("function"); + // The default path forwards to the live remote-JWKS verifier — no seam work. + expect(readFileImpl).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + // The production verifier's cheap pre-gate rejects a missing bearer with no + // network — proof the default (not the local-JWKS) path is wired. + expect((await verify(undefined)).ok).toBe(false); + }); + + it("env unset ⇒ works for the app-url audience type too (no file read, no WARN)", async () => { + const readFileImpl = vi.fn((_p: string): string => { + throw new Error("readFileImpl must not be called on the default path"); + }); + const { logger, warn } = makeLoggerSpy(); + const getEnv = (_k: string): string | undefined => undefined; + + const verify = resolveTestGoogleChatVerifier( + { audienceType: "app-url", audience: "https://example.com/app/" }, + getEnv, + { readFileImpl, logger }, + ); + + expect(readFileImpl).not.toHaveBeenCalled(); + expect(warn).not.toHaveBeenCalled(); + expect((await verify(undefined)).ok).toBe(false); + }); + + it("env unset ⇒ resolves with no deps bag at all (logger + readFileImpl optional)", async () => { + const getEnv = (_k: string): string | undefined => undefined; + const verify = resolveTestGoogleChatVerifier( + { audienceType: "project-number", audience: "1234567890" }, + getEnv, + ); + expect((await verify(undefined)).ok).toBe(false); + }); +}); From b660f659dda46d458d202f36aef542782f4adf7e Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 10:09:08 +0300 Subject: [PATCH 128/200] feat(237-05): resolveTestGoogleChatVerifier default remote-JWKS path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New off-by-default daemon seam building the ingress validateInboundJwt closure - Env unset ⇒ the live remote-JWKS verifier, closed over audienceType + audience - Exports EnvGetter + the two COMIS_GOOGLECHAT_TEST_* env-var name consts - Env read via the injected getter; local-JWKS branch follows --- .../src/wiring/googlechat-test-seams.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 packages/daemon/src/wiring/googlechat-test-seams.ts diff --git a/packages/daemon/src/wiring/googlechat-test-seams.ts b/packages/daemon/src/wiring/googlechat-test-seams.ts new file mode 100644 index 000000000..680368a23 --- /dev/null +++ b/packages/daemon/src/wiring/googlechat-test-seams.ts @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat inbound-verify live-test seam — OFF BY DEFAULT. + * + * The Google Chat webhook ingress verifies every inbound event against Google's + * remote signing keys (the Chat-system JWK set for a project-number audience, or + * Google's OIDC certs plus a sender-binding email claim for an app-url audience). + * A live-test rig that mints its own tokens cannot reach those remote keys, so + * this resolver bridges the gap WITHOUT weakening the trust anchor: it activates + * ONLY when its `COMIS_GOOGLECHAT_TEST_*` env var is set, and with the env unset + * the daemon behaves byte-identically to production (the live remote-JWKS verifier). + * + * - `COMIS_GOOGLECHAT_TEST_JWKS` = a path to a public JWKS JSON. The emulator + * holds the matching private key and signs inbound event tokens; this swaps + * the ingress verifier for a LOCAL-JWKS one — a FULL signature + issuer + + * audience verify (plus, for app-url, the sender-binding email claim), never + * a bypass. Only the key source changes; production keeps the live + * remote-JWKS verifier untouched. + * - `COMIS_GOOGLECHAT_TEST_ISSUER` = an optional issuer override for a + * fully-synthetic emulator key set. Defaults to the audience shape's Google + * issuer when unset. + * + * This is never a production knob: the vars are documented test-only. Env is read + * through the injected getter, never the ambient process environment. + * + * @module + */ + +import { createGoogleChatInboundVerifier } from "@comis/channels"; +import type { ComisLogger } from "@comis/core"; +import type { Result } from "@comis/shared"; + +/** Reads an environment variable (injected so the resolver stays pure + testable). */ +export type EnvGetter = (key: string) => string | undefined; + +/** The env var that points the inbound verifier at a local test JWKS (off by default). */ +export const GOOGLECHAT_TEST_JWKS_ENV = "COMIS_GOOGLECHAT_TEST_JWKS"; +/** The env var that overrides the expected issuer for a synthetic local key set. */ +export const GOOGLECHAT_TEST_ISSUER_ENV = "COMIS_GOOGLECHAT_TEST_ISSUER"; + +/** + * Resolve the inbound-event JWT verifier for the ingress, closed over the + * configured `audienceType` + `audience`. + * + * Default (env unset): the production remote-JWKS verifier + * {@link createGoogleChatInboundVerifier}. When `COMIS_GOOGLECHAT_TEST_JWKS` + * names a readable JWKS file, a LOCAL-JWKS verifier against that key set instead + * (still a full signature/issuer/audience verify). The returned closure has the + * ingress's expected `(authHeader) => Promise>` shape. + */ +export function resolveTestGoogleChatVerifier( + cfg: { audienceType: "project-number" | "app-url"; audience: string }, + getEnv: EnvGetter, + deps?: { + readonly readFileImpl?: (path: string) => string; + readonly logger?: ComisLogger; + }, +): (authHeader: string | undefined) => Promise> { + const jwksPath = getEnv(GOOGLECHAT_TEST_JWKS_ENV); + if (jwksPath === undefined || jwksPath.length === 0) { + // Production/default path: verify against the live Google JWK set for the + // configured audience shape. + return createGoogleChatInboundVerifier({ + audienceType: cfg.audienceType, + audience: cfg.audience, + ...(deps?.logger ? { logger: deps.logger } : {}), + }); + } + // A named JWKS file selects the offline local-JWKS verifier; that branch swaps + // only the key source (never the verify) and emits a content-free activation + // WARN. Until it is wired the default verifier keeps the resolver total. + return createGoogleChatInboundVerifier({ + audienceType: cfg.audienceType, + audience: cfg.audience, + ...(deps?.logger ? { logger: deps.logger } : {}), + }); +} From adcd307f2cf4fce35244bd9eca6b118fc2b48f38 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 10:11:19 +0300 Subject: [PATCH 129/200] test(237-05): local-JWKS seam does a full offline verify + content-free WARN - app-url: valid token ok, wrong-audience err (a real verify, not a bypass) - project-number: valid token ok, wrong-audience err offline - COMIS_GOOGLECHAT_TEST_ISSUER threaded into the local verifier - Activation WARN fires once, content-free (no token, no JWKS bytes) - Fails RED: the env-set branch still falls through to the default verifier --- .../src/wiring/googlechat-test-seams.test.ts | 167 +++++++++++++++++- 1 file changed, 165 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/wiring/googlechat-test-seams.test.ts b/packages/daemon/src/wiring/googlechat-test-seams.test.ts index 5e1f78de6..4ad2b76da 100644 --- a/packages/daemon/src/wiring/googlechat-test-seams.test.ts +++ b/packages/daemon/src/wiring/googlechat-test-seams.test.ts @@ -9,16 +9,28 @@ * swaps in a LOCAL-JWKS verifier that STILL fully verifies (signature + issuer + * audience, plus the app-url sender-binding email claim) — it only changes the * key source, never relaxes a control, so it is never an auth bypass. Env is read - * through the injected getter (never `process.env`); the file is read through an - * injected `readFileImpl`. + * through the injected getter (never the ambient process environment); the file + * is read through an injected `readFileImpl`. * * @module */ +import { generateKeyPairSync, createSign } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; import type { ComisLogger } from "@comis/core"; import { resolveTestGoogleChatVerifier } from "./googlechat-test-seams.js"; +/** The issuer of a project-number Chat-system event token. */ +const CHAT_SYSTEM_ISSUER = "chat@system.gserviceaccount.com"; +/** Google's OIDC issuer for an app-url ID token. */ +const GOOGLE_OIDC_ISSUER = "https://accounts.google.com"; +/** The sender-binding email an app-url token must carry to prove the Chat system. */ +const CHAT_SYSTEM_EMAIL = "chat@system.gserviceaccount.com"; + +function b64url(input: string | Buffer): string { + return Buffer.from(input).toString("base64url"); +} + /** A ComisLogger whose warn spy records every argument for content-free asserts. */ function makeLoggerSpy(): { logger: ComisLogger; @@ -42,6 +54,40 @@ function makeLoggerSpy(): { return { logger, warn, serialized }; } +/** + * Generate an RS256 keypair with node:crypto (no jose dep in the daemon package), + * serialize its public JWKS, and return a minter that signs an arbitrary claim + * set. The token is exactly what jose's jwtVerify (RS256) accepts, so the + * @comis/channels local-JWKS verifier verifies it offline. + */ +function makeJwks(): { + jwksJson: string; + jwkModulus: string; + mint: (claims: Record) => string; +} { + const { privateKey, publicKey } = generateKeyPairSync("rsa", { + modulusLength: 2048, + }); + const jwk = publicKey.export({ format: "jwk" }) as Record; + jwk.kid = "gc-seam-1"; + jwk.alg = "RS256"; + jwk.use = "sig"; + const jwksJson = JSON.stringify({ keys: [jwk] }); + const mint = (claims: Record): string => { + const now = Math.floor(Date.now() / 1000); + const header = b64url( + JSON.stringify({ alg: "RS256", typ: "JWT", kid: "gc-seam-1" }), + ); + const payload = b64url( + JSON.stringify({ iat: now, exp: now + 300, ...claims }), + ); + const signingInput = `${header}.${payload}`; + const sig = createSign("RSA-SHA256").update(signingInput).sign(privateKey); + return `${signingInput}.${b64url(sig)}`; + }; + return { jwksJson, jwkModulus: String(jwk.n), mint }; +} + describe("resolveTestGoogleChatVerifier — default (production remote-JWKS) path", () => { it("env unset ⇒ returns the production verifier; reads no file, logs no WARN (project-number)", async () => { const readFileImpl = vi.fn((_p: string): string => { @@ -92,3 +138,120 @@ describe("resolveTestGoogleChatVerifier — default (production remote-JWKS) pat expect((await verify(undefined)).ok).toBe(false); }); }); + +describe("resolveTestGoogleChatVerifier — local-JWKS seam path (COMIS_GOOGLECHAT_TEST_JWKS set)", () => { + it("app-url: FULL offline verify — valid token ok, wrong-audience err (not a bypass) + content-free WARN", async () => { + const { jwksJson, jwkModulus, mint } = makeJwks(); + const readFileImpl = vi.fn((_p: string): string => jwksJson); + const { logger, warn, serialized } = makeLoggerSpy(); + const getEnv = (k: string): string | undefined => + k === "COMIS_GOOGLECHAT_TEST_JWKS" ? "/seam/app-url.jwks.json" : undefined; + + const verify = resolveTestGoogleChatVerifier( + { audienceType: "app-url", audience: "https://example.com/app/" }, + getEnv, + { readFileImpl, logger }, + ); + + // Seam activation is synchronous at resolve time, before any verify — a + // deterministic, network-free assertion. On the default path neither fires. + expect(readFileImpl).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledTimes(1); + + // A correctly-bound token (right key, issuer, audience, sender email) is + // ACCEPTED — the local key set verifies offline. + const good = mint({ + iss: GOOGLE_OIDC_ISSUER, + aud: "https://example.com/app/", + email: CHAT_SYSTEM_EMAIL, + email_verified: true, + }); + expect((await verify(`Bearer ${good}`)).ok).toBe(true); + + // A wrong-audience token is REJECTED — a real verify, never accept-all. + const wrongAudience = mint({ + iss: GOOGLE_OIDC_ISSUER, + aud: "https://attacker.example/app/", + email: CHAT_SYSTEM_EMAIL, + email_verified: true, + }); + expect((await verify(`Bearer ${wrongAudience}`)).ok).toBe(false); + + // The local verifier carries no logger, so the activation WARN is the only + // one — rejections stay opaque at this layer. + expect(warn).toHaveBeenCalledTimes(1); + const warnArg = warn.mock.calls[0]?.[0] as Record; + expect(warnArg).toMatchObject({ + channelType: "googlechat", + errorKind: "config", + }); + expect(String(warnArg.hint)).toContain("COMIS_GOOGLECHAT_TEST_JWKS"); + // Content-free: neither a token nor any JWKS key material reaches the WARN. + const dump = serialized(); + expect(dump).not.toContain(good); + expect(dump).not.toContain(jwkModulus); + }); + + it("project-number: FULL offline verify — valid token ok, wrong-audience err", async () => { + const { jwksJson, mint } = makeJwks(); + const readFileImpl = vi.fn((_p: string): string => jwksJson); + const { logger, warn } = makeLoggerSpy(); + const getEnv = (k: string): string | undefined => + k === "COMIS_GOOGLECHAT_TEST_JWKS" ? "/seam/pn.jwks.json" : undefined; + + const verify = resolveTestGoogleChatVerifier( + { audienceType: "project-number", audience: "1234567890" }, + getEnv, + { readFileImpl, logger }, + ); + + expect(readFileImpl).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledTimes(1); + + const good = mint({ iss: CHAT_SYSTEM_ISSUER, aud: "1234567890" }); + expect((await verify(`Bearer ${good}`)).ok).toBe(true); + + const wrongAudience = mint({ iss: CHAT_SYSTEM_ISSUER, aud: "9999999999" }); + expect((await verify(`Bearer ${wrongAudience}`)).ok).toBe(false); + }); + + it("threads COMIS_GOOGLECHAT_TEST_ISSUER into the local verifier (synthetic app-url issuer)", async () => { + const { jwksJson, mint } = makeJwks(); + const readFileImpl = vi.fn((_p: string): string => jwksJson); + const { logger } = makeLoggerSpy(); + const emulatorIssuer = "https://emulator.test/oidc"; + const getEnv = (k: string): string | undefined => + k === "COMIS_GOOGLECHAT_TEST_JWKS" + ? "/seam/iss.jwks.json" + : k === "COMIS_GOOGLECHAT_TEST_ISSUER" + ? emulatorIssuer + : undefined; + + const verify = resolveTestGoogleChatVerifier( + { audienceType: "app-url", audience: "https://example.com/app/" }, + getEnv, + { readFileImpl, logger }, + ); + + expect(readFileImpl).toHaveBeenCalledTimes(1); + + // A token from the synthetic emulator issuer verifies under the override... + const emulatorToken = mint({ + iss: emulatorIssuer, + aud: "https://example.com/app/", + email: CHAT_SYSTEM_EMAIL, + email_verified: true, + }); + expect((await verify(`Bearer ${emulatorToken}`)).ok).toBe(true); + + // ...while a token from the default Google issuer is now REJECTED — the + // override replaced the expected issuer, proving it was threaded through. + const googleToken = mint({ + iss: GOOGLE_OIDC_ISSUER, + aud: "https://example.com/app/", + email: CHAT_SYSTEM_EMAIL, + email_verified: true, + }); + expect((await verify(`Bearer ${googleToken}`)).ok).toBe(false); + }); +}); From bb080932e5e35042cb5462cdc4a6c9561faa3908 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 10:14:52 +0300 Subject: [PATCH 130/200] feat(237-05): local-JWKS seam does a full offline verify + activation WARN MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - COMIS_GOOGLECHAT_TEST_JWKS set ⇒ read the JWKS via the injected readFileImpl and swap to createLocalGoogleChatInboundVerifier — a full offline verify (signature + issuer + audience + app-url email binding), never a bypass - COMIS_GOOGLECHAT_TEST_ISSUER threads an optional issuer override - Emit a content-free activation WARN (channelType/hint/errorKind only) - Shrink the now-consumed inbound-verifier factories out of the ahead-of-consumer public-api-policy baseline (the seam is their live cross-package consumer) --- .../src/wiring/googlechat-test-seams.ts | 32 +++++++++++++++---- test/support/public-api-policy.ts | 17 +++++----- 2 files changed, 35 insertions(+), 14 deletions(-) diff --git a/packages/daemon/src/wiring/googlechat-test-seams.ts b/packages/daemon/src/wiring/googlechat-test-seams.ts index 680368a23..aa7d733c2 100644 --- a/packages/daemon/src/wiring/googlechat-test-seams.ts +++ b/packages/daemon/src/wiring/googlechat-test-seams.ts @@ -26,7 +26,11 @@ * @module */ -import { createGoogleChatInboundVerifier } from "@comis/channels"; +import { readFileSync } from "node:fs"; +import { + createGoogleChatInboundVerifier, + createLocalGoogleChatInboundVerifier, +} from "@comis/channels"; import type { ComisLogger } from "@comis/core"; import type { Result } from "@comis/shared"; @@ -66,12 +70,28 @@ export function resolveTestGoogleChatVerifier( ...(deps?.logger ? { logger: deps.logger } : {}), }); } - // A named JWKS file selects the offline local-JWKS verifier; that branch swaps - // only the key source (never the verify) and emits a content-free activation - // WARN. Until it is wired the default verifier keeps the resolver total. - return createGoogleChatInboundVerifier({ + // Test-only offline path: verify against a local JWKS the emulator wrote. This + // swaps only the key source — a FULL signature + issuer + audience verify (plus + // the app-url sender-binding email claim) still runs, so it is never a bypass. + const readFileImpl = + // eslint-disable-next-line security/detect-non-literal-fs-filename -- jwksPath is an operator-set test-only env var, not user input + deps?.readFileImpl ?? ((path: string) => readFileSync(path, "utf8")); + const jwks = JSON.parse(readFileImpl(jwksPath)) as Parameters< + typeof createLocalGoogleChatInboundVerifier + >[0]; + const issuer = getEnv(GOOGLECHAT_TEST_ISSUER_ENV); + const verifier = createLocalGoogleChatInboundVerifier(jwks, { audienceType: cfg.audienceType, audience: cfg.audience, - ...(deps?.logger ? { logger: deps.logger } : {}), + ...(issuer !== undefined && issuer.length > 0 ? { issuer } : {}), }); + deps?.logger?.warn( + { + channelType: "googlechat" as const, + hint: "Unset COMIS_GOOGLECHAT_TEST_JWKS in production — the Google Chat ingress is verifying inbound tokens against a LOCAL test JWKS, not Google's signing keys", + errorKind: "config" as const, + }, + "Google Chat ingress using a LOCAL test JWKS (test-only seam)", + ); + return verifier; } diff --git a/test/support/public-api-policy.ts b/test/support/public-api-policy.ts index f021ef1ca..aab79a07d 100644 --- a/test/support/public-api-policy.ts +++ b/test/support/public-api-policy.ts @@ -636,14 +636,15 @@ export const PUBLIC_API_POLICY: ReadonlyMap> = "GoogleChatTokenProvider", "GoogleChatScope", "classifyGoogleChatError", - // Google Chat INBOUND verify closures, surfaced AHEAD of their consumer. - // createGoogleChatInboundVerifier (dual-audience remote-JWKS) + its - // local-JWKS offline twin createLocalGoogleChatInboundVerifier + the shared - // GoogleChatInboundVerifierOpts shape are consumed by the daemon test-seam - // that binds the gateway ingress's validateInboundJwt in a later wave. - // Shrink each once that cross-package consumer lands. - "createGoogleChatInboundVerifier", - "createLocalGoogleChatInboundVerifier", + // Google Chat INBOUND verify OPTIONS shape, surfaced AHEAD of a + // cross-package consumer. GoogleChatInboundVerifierOpts types the + // createGoogleChatInboundVerifier factory argument. The daemon test-seam + // (googlechat-test-seams.ts) that binds the gateway ingress's + // validateInboundJwt now name-imports the two verifier factories + // (createGoogleChatInboundVerifier + createLocalGoogleChatInboundVerifier), + // so those have a live consumer and are no longer listed here — but the + // seam takes its own cfg shape rather than this opts type. Shrink once a + // cross-package consumer names it. "GoogleChatInboundVerifierOpts", "createIMessageAdapter", "IMessageAdapterDeps", From a2d0e0b4d3810a3825e78224cd3758d4190ad55c Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 10:35:21 +0300 Subject: [PATCH 131/200] test(237-06): googlechat webhook branch builds the ingress + drives handleChatEvent + emits auth-reject signal - Extend the @comis/gateway mock with createGoogleChatIngress + the channels inbound-verify factories the daemon seam resolves at build time - Add handleChatEvent to the adapter mock + an eventBus spy to the container - Assert: webhook builds the ingress with the injected verify/driver/onAuthRejected, the driver reaches the real handleChatEvent, onAuthRejected bridges to the content-free channel:ingress_auth_rejected signal; pubsub/disabled build none --- .../wiring/setup-channels-adapters.test.ts | 114 ++++++++++++++++-- 1 file changed, 105 insertions(+), 9 deletions(-) diff --git a/packages/daemon/src/wiring/setup-channels-adapters.test.ts b/packages/daemon/src/wiring/setup-channels-adapters.test.ts index 50b9dc7e3..9a1be0543 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.test.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.test.ts @@ -40,11 +40,16 @@ const mockMsTeamsPlugin = { // caller-backed wiring can be asserted by identity. const mockMsTeamsIngress = { __msteamsIngress: true }; -// Google Chat is a pull-driven channel: the adapter opens the Pub/Sub pull loop -// on start(), so there is no gateway ingress to wire (unlike Teams). The plugin -// carries only the base send surface + lifecycle. -const mockGoogleChatAdapter = { sendMessage: vi.fn(), start: vi.fn(), stop: vi.fn() }; +// Google Chat is a dual-transport channel. In pubsub mode the adapter opens the +// Pub/Sub pull loop on start() (no gateway route); in webhook mode inbound arrives +// over the gateway ingress, which drives the adapter's ONE normalizer +// (handleChatEvent). The adapter mock therefore carries handleChatEvent so the +// injected webhook driver reaches the real adapter. +const mockGoogleChatAdapter = { sendMessage: vi.fn(), start: vi.fn(), stop: vi.fn(), handleChatEvent: vi.fn(() => Promise.resolve()) }; const mockGoogleChatPlugin = { adapter: mockGoogleChatAdapter, channelType: "googlechat" }; +// A sentinel the mocked googlechat ingress factory returns, so the webhook-branch +// wiring can be asserted by identity (mirrors mockMsTeamsIngress). +const mockGoogleChatIngress = { __googlechatIngress: true }; vi.mock("@comis/channels", () => ({ createTelegramPlugin: vi.fn(() => mockTelegramPlugin), @@ -73,6 +78,11 @@ vi.mock("@comis/channels", () => ({ createGoogleChatPlugin: vi.fn(() => mockGoogleChatPlugin), // Synchronous, transport-free credential guard — returns a Result directly. validateGoogleChatCredentials: vi.fn(() => ({ ok: true, value: undefined })), + // The inbound-verify factories the daemon test-seam resolves at build time + // (default path → the remote-JWKS verifier). Each returns a stub verify closure + // so the webhook wiring builds without standing up jose/remote key sets. + createGoogleChatInboundVerifier: vi.fn(() => vi.fn(async () => ({ ok: true, value: undefined }))), + createLocalGoogleChatInboundVerifier: vi.fn(() => vi.fn(async () => ({ ok: true, value: undefined }))), })); // The Teams ingress sub-app is built in @comis/gateway; the registration block @@ -80,6 +90,7 @@ vi.mock("@comis/channels", () => ({ // standing up a real Hono app. vi.mock("@comis/gateway", () => ({ createMsTeamsIngress: vi.fn(() => mockMsTeamsIngress), + createGoogleChatIngress: vi.fn(() => mockGoogleChatIngress), })); import { bootstrapAdapters } from "./setup-channels-adapters.js"; @@ -107,7 +118,7 @@ import { createGoogleChatPlugin, validateGoogleChatCredentials, } from "@comis/channels"; -import { createMsTeamsIngress } from "@comis/gateway"; +import { createMsTeamsIngress, createGoogleChatIngress } from "@comis/gateway"; // --------------------------------------------------------------------------- // Helpers @@ -138,6 +149,9 @@ function makeContainer(channelOverrides: Record = {}, secretMap: Re throw new Error("not found"); }), }, + // The webhook-branch onAuthRejected bridge emits a content-free fleet signal + // onto the eventBus; a spy lets the bridge be asserted by identity. + eventBus: { emit: vi.fn() }, } as unknown as AppContainer; } @@ -734,10 +748,12 @@ describe("bootstrapAdapters", () => { }); // ------------------------------------------------------------------------- - // Google Chat — a pull-driven channel. The registration block resolves the - // service-account key as config-SecretRef-or-GOOGLECHAT_SA_KEY, validates it - // with the subscription, and registers the adapter/plugin. There is no - // gateway ingress this transport (the adapter opens the Pub/Sub pull loop). + // Google Chat — a dual-transport channel. The registration block resolves the + // service-account key as config-SecretRef-or-GOOGLECHAT_SA_KEY, validates it, + // and registers the adapter/plugin. In pubsub mode the adapter opens the + // Pub/Sub pull loop (no route); in webhook mode this block ALSO builds the + // gateway ingress from the real adapter's handleChatEvent driver + the + // audience-bound inbound verifier + the content-free auth-reject bridge. // The credential-fail WARN carries only errorKind + hint — never the key. // ------------------------------------------------------------------------- @@ -820,5 +836,85 @@ describe("bootstrapAdapters", () => { expect(result.adaptersByType.has("googlechat")).toBe(false); expect(createGoogleChatPlugin).not.toHaveBeenCalled(); + // No route is built for a disabled channel. + expect(result.googlechatIngress).toBeUndefined(); + expect(createGoogleChatIngress).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // Google Chat webhook mode — the registration block is ALSO the production + // CALLER that builds the mounted ingress from the real adapter's + // handleChatEvent driver + the audience-bound inbound verifier + the + // content-free auth-reject bridge. Pubsub mode builds no route. + // ------------------------------------------------------------------------- + + it("builds the googlechat webhook ingress from the real adapter + injected verifier when enabled in webhook mode", async () => { + // Webhook mode needs no subscriptionName (inbound arrives over the ingress, + // not a pull loop) — a blank subscription must still register + build the ingress. + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const container = makeContainer({ + googlechat: { enabled: true, serviceAccountKey: saKey, mode: "webhook", audienceType: "project-number", audience: "123456789" }, + }); + const result = await bootstrapAdapters({ container, channelsLogger }); + + expect(result.adaptersByType.get("googlechat")).toBe(mockGoogleChatAdapter); + // The ingress the gateway phase will mount is the one built here. + expect(result.googlechatIngress).toBe(mockGoogleChatIngress); + expect(createGoogleChatIngress).toHaveBeenCalledWith( + expect.objectContaining({ + validateInboundJwt: expect.any(Function), + handleWebhookEvents: expect.any(Function), + onAuthRejected: expect.any(Function), + logger: channelsLogger, + }), + ); + }); + + it("drives the real adapter handleChatEvent from the injected handleWebhookEvents (fire-and-forget)", async () => { + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const container = makeContainer({ + googlechat: { enabled: true, serviceAccountKey: saKey, mode: "webhook", audienceType: "project-number", audience: "123456789" }, + }); + await bootstrapAdapters({ container, channelsLogger }); + + // The injected dispatch closure reaches the REAL adapter's one normalizer, so + // a mounted route can drive the inbound pipeline end-to-end. + const ingressDeps = vi.mocked(createGoogleChatIngress).mock.calls[0]![0] as unknown as { + handleWebhookEvents: (events: unknown[]) => void; + }; + ingressDeps.handleWebhookEvents([{ some: "event" }]); + expect(mockGoogleChatAdapter.handleChatEvent).toHaveBeenCalledWith({ some: "event" }); + }); + + it("bridges onAuthRejected onto the content-free channel:ingress_auth_rejected eventBus signal", async () => { + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const container = makeContainer({ + googlechat: { enabled: true, serviceAccountKey: saKey, mode: "webhook", audienceType: "app-url", audience: "https://example.com/hook" }, + }); + await bootstrapAdapters({ container, channelsLogger }); + + const ingressDeps = vi.mocked(createGoogleChatIngress).mock.calls[0]![0] as unknown as { + onAuthRejected: (reason: string) => void; + }; + ingressDeps.onAuthRejected("invalid_token"); + // Content-free by construction: the channel label + closed reason class + + // timestamp only — never the token/header/body. + expect(container.eventBus.emit).toHaveBeenCalledWith("channel:ingress_auth_rejected", { + channelType: "googlechat", + reason: "invalid_token", + timestamp: expect.any(Number), + }); + }); + + it("builds no googlechat ingress in pubsub mode (default) — the pull loop opens the transport, no route", async () => { + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const container = makeContainer({ + googlechat: { enabled: true, serviceAccountKey: saKey, subscriptionName: "projects/p/subscriptions/s", mode: "pubsub" }, + }); + const result = await bootstrapAdapters({ container, channelsLogger }); + + expect(result.adaptersByType.get("googlechat")).toBe(mockGoogleChatAdapter); + expect(result.googlechatIngress).toBeUndefined(); + expect(createGoogleChatIngress).not.toHaveBeenCalled(); }); }); From 9912da6809b11b64d1f7fe7f776880126dc65869 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 10:38:02 +0300 Subject: [PATCH 132/200] feat(237-06): build googlechat webhook ingress with injected verify + handleChatEvent driver + auth-reject bridge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Webhook mode builds createGoogleChatIngress from the real adapter's handleChatEvent (fire-and-forget) + resolveTestGoogleChatVerifier (audience closed over, fail-closed if blank) + a content-free onAuthRejected → channel:ingress_auth_rejected bridge; pubsub mode builds no route - Relax the registration gate so webhook mode needs no subscriptionName (inbound arrives over the ingress), matching the adapter's per-mode precondition - Thread googlechatIngress out of bootstrapAdapters --- .../src/wiring/setup-channels-adapters.ts | 80 ++++++++++++++++--- 1 file changed, 70 insertions(+), 10 deletions(-) diff --git a/packages/daemon/src/wiring/setup-channels-adapters.ts b/packages/daemon/src/wiring/setup-channels-adapters.ts index 3d3cc5818..d911f7cfc 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.ts @@ -39,13 +39,15 @@ import { type MsTeamsAdapterHandle, type MsTeamsPluginHandle, type GoogleChatPluginHandle, + type GoogleChatAdapterHandle, type TeamsActivity, } from "@comis/channels"; -import { createMsTeamsIngress } from "@comis/gateway"; +import { createMsTeamsIngress, createGoogleChatIngress } from "@comis/gateway"; import { resolveTestActivityValidator, resolveTestConnectorFetch, } from "./msteams-test-seams.js"; +import { resolveTestGoogleChatVerifier } from "./googlechat-test-seams.js"; import os from "node:os"; import { safePath, systemNowMs } from "@comis/core"; @@ -78,6 +80,13 @@ export interface AdapterBootstrapResult { * inbound resolver over the service-account chat.bot token provider). * Undefined when the channel is disabled or its credentials are invalid. */ googlechatPlugin?: GoogleChatPluginHandle; + /** Google Chat inbound ingress sub-app — built here when the channel is + * enabled in webhook mode with valid credentials, from the real adapter's + * handleChatEvent driver + the audience-bound inbound verifier. The + * composition root threads it to the gateway so the `/channels/googlechat` + * route mounts only when a caller-backed ingress exists. Undefined in pubsub + * mode (the pull loop opens the transport) or when the channel is disabled. */ + googlechatIngress?: import("hono").Hono; } // --------------------------------------------------------------------------- @@ -118,6 +127,7 @@ export async function bootstrapAdapters(deps: { let msTeamsIngress: import("hono").Hono | undefined; let msTeamsPlugin: MsTeamsPluginHandle | undefined; let googlechatPlugin: GoogleChatPluginHandle | undefined; + let googlechatIngress: import("hono").Hono | undefined; // Helper: attempt to get a secret, return undefined if not found const getSecret = (name: string): string | undefined => { @@ -506,25 +516,36 @@ export async function bootstrapAdapters(deps: { } } - // Google Chat — a pull-driven channel: inbound arrives over a Cloud Pub/Sub - // pull loop the adapter opens on start(), so there is no gateway ingress to - // build here (unlike Teams — that's the webhook transport). On a valid config - // it registers the adapter/plugin and stops. The service-account key resolves - // as the config SecretRef (already resolved to a string upstream) OR the - // service-account-key env fallback, and is never placed in a log. + // Google Chat — a dual-transport channel. In pubsub mode inbound arrives over a + // Cloud Pub/Sub pull loop the adapter opens on start() (no gateway route). In + // webhook mode inbound instead arrives over the net-new gateway ingress: this + // block is that ingress's production CALLER — on valid creds it registers the + // adapter/plugin, then wires the REAL adapter's handleChatEvent driver + the + // audience-bound inbound verifier + the content-free auth-reject bridge into + // createGoogleChatIngress and exposes the sub-app for the gateway phase to mount + // at /channels/googlechat. A mounted route MUST reach the real adapter — there + // is no factory without a caller. The service-account key resolves as the config + // SecretRef (already resolved to a string upstream) OR the service-account-key + // env fallback, and is never placed in a log. if (channelConfig.googlechat.enabled) { const key = (channelConfig.googlechat.serviceAccountKey as string | undefined) || getSecret("GOOGLECHAT_SA_KEY"); const subscriptionName = channelConfig.googlechat.subscriptionName; + // A pull-loop subscription is required to receive inbound in pubsub mode; + // webhook mode receives inbound over the ingress instead, so it needs none + // (the adapter's start() applies the same per-mode precondition). + const needsSubscription = channelConfig.googlechat.mode !== "webhook"; const validation = validateGoogleChatCredentials({ serviceAccountKey: key, subscriptionName, allowFrom: channelConfig.googlechat.allowFrom, logger: channelsLogger, }); - if (validation.ok && key && subscriptionName) { + if (validation.ok && key && (subscriptionName || !needsSubscription)) { const plugin = createGoogleChatPlugin({ serviceAccountKey: key, - subscriptionName, + // Webhook mode carries no subscription (empty placeholder); its start() + // never opens the pull loop, so the value is unused there. + subscriptionName: subscriptionName ?? "", allowFrom: channelConfig.googlechat.allowFrom, allowMode: channelConfig.googlechat.allowMode, logger: channelsLogger, @@ -537,6 +558,45 @@ export async function bootstrapAdapters(deps: { // tgPlugin/msTeamsPlugin capture). Kept as a GoogleChatPluginHandle — a // down-cast to ChannelPluginPort would lose createResolver. googlechatPlugin = plugin as GoogleChatPluginHandle; + // Webhook mode: build the inbound ingress from the REAL adapter's inbound + // driver + the audience-bound verifier. Pubsub mode has no route. + if (channelConfig.googlechat.mode === "webhook") { + // OFF-BY-DEFAULT live-test seam (see googlechat-test-seams.ts): with + // COMIS_GOOGLECHAT_TEST_JWKS unset — the production case — the verifier is + // the live remote-JWKS one for the configured audience shape; set, it + // verifies against a local test JWKS (a full verify, never a bypass). Reads + // via the injected EnvPort (the sanctioned env boundary — never direct + // process.env). The audience is closed over here, fail-closed if blank. + const getEnv = (name: string): string | undefined => env?.get(name); + const gcAdapter = plugin.adapter as GoogleChatAdapterHandle; + googlechatIngress = createGoogleChatIngress({ + validateInboundJwt: resolveTestGoogleChatVerifier( + { + audienceType: channelConfig.googlechat.audienceType, + audience: channelConfig.googlechat.audience ?? "", + }, + getEnv, + { logger: channelsLogger }, + ), + // Fire-and-forget: googlechat's one normalizer is async, so the ingress + // does not block its fast-ack on it (per-event failure is the adapter's + // own concern). + handleWebhookEvents: (events) => { + for (const e of events) void gcAdapter.handleChatEvent(e); + }, + // Bridge the ingress auth-gate rejections onto the daemon eventBus as a + // content-free health_signal, so a forged/expired/wrong-audience/missing- + // token flood is COUNTED by `comis fleet` instead of raw-log-only. Carries + // the channel label + closed reason class only — never the token/header/body. + onAuthRejected: (reason) => + container.eventBus.emit("channel:ingress_auth_rejected", { + channelType: "googlechat", + reason, + timestamp: systemNowMs(), + }), + logger: channelsLogger, + }); + } channelsLogger.info({ channelType: "googlechat", mode: channelConfig.googlechat.mode }, "Channel adapter initialized"); } else { channelsLogger.warn({ hint: "Set channels.googlechat.serviceAccountKey (SecretRef) or GOOGLECHAT_SA_KEY, and channels.googlechat.subscriptionName", errorKind: "auth" as const }, "Google Chat credential validation failed"); @@ -550,5 +610,5 @@ export async function bootstrapAdapters(deps: { } } // end if (channelConfig) - return { adaptersByType, tgPlugin, linePlugin, channelPlugins, msTeamsIngress, msTeamsPlugin, googlechatPlugin }; + return { adaptersByType, tgPlugin, linePlugin, channelPlugins, msTeamsIngress, msTeamsPlugin, googlechatPlugin, googlechatIngress }; } From 0b1e90495e36d06c5c9113ac406c0362cb33ac58 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 10:38:49 +0300 Subject: [PATCH 133/200] test(237-06): setupChannels forwards the googlechat webhook ingress from bootstrapAdapters - Assert the ingress bootstrapAdapters builds in webhook mode is forwarded in the setupChannels result so the composition root can mount /channels/googlechat --- .../setup-channels-registry.test.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/daemon/src/wiring/setup-channels/setup-channels-registry.test.ts b/packages/daemon/src/wiring/setup-channels/setup-channels-registry.test.ts index cebe804bc..7d3df010d 100644 --- a/packages/daemon/src/wiring/setup-channels/setup-channels-registry.test.ts +++ b/packages/daemon/src/wiring/setup-channels/setup-channels-registry.test.ts @@ -1251,4 +1251,28 @@ describe("setupChannels", () => { expect(cmDeps.interactiveCallbackRouter).toBeUndefined(); }); }); + + // -- googlechat webhook ingress thread-out -- + // + // The gateway phase mounts /channels/googlechat only when bootstrapAdapters + // built a caller-backed ingress (webhook mode). setupChannels must FORWARD that + // ingress in its result so the composition root can thread it into the gateway + // deps — mirroring msTeamsIngress. A missing thread silently severs the mount. + describe("googlechat webhook ingress thread-out", () => { + it("forwards the googlechat ingress built by bootstrapAdapters into the setupChannels result", async () => { + const googlechatIngress = { __googlechatIngress: true }; + vi.mocked(bootstrapAdapters).mockResolvedValueOnce({ + adaptersByType: mockAdaptersByType, + tgPlugin: undefined, + linePlugin: undefined, + channelPlugins: new Map(), + googlechatIngress, + } as any); + const { container } = makeContainer(); + const deps = makeDeps({ container }); + const result = await setupChannels(deps); + + expect(result.googlechatIngress).toBe(googlechatIngress); + }); + }); }); From 83be257a5b6b9823fb2c5d3de1f672e14b55ffe6 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 10:40:00 +0300 Subject: [PATCH 134/200] feat(237-06): thread googlechatIngress out of setupChannels alongside msTeamsIngress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add googlechatIngress to ChannelsResult, destructure it from bootstrapAdapters, and forward it in the result so the composition root can mount /channels/googlechat - Pure additive glue: no behavior change to any other channel; cycles:refs stays clean (the daemon owns the channels↔gateway injection, the gateway gains no dep) --- .../src/wiring/setup-channels/setup-channels-registry.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/daemon/src/wiring/setup-channels/setup-channels-registry.ts b/packages/daemon/src/wiring/setup-channels/setup-channels-registry.ts index 0b1be2aa6..677e81706 100644 --- a/packages/daemon/src/wiring/setup-channels/setup-channels-registry.ts +++ b/packages/daemon/src/wiring/setup-channels/setup-channels-registry.ts @@ -63,6 +63,12 @@ export interface ChannelsResult { * threads it into the gateway deps so `/channels/msteams` mounts only when a * caller-backed ingress exists. Undefined when the channel is disabled. */ msTeamsIngress?: import("hono").Hono; + /** Google Chat inbound ingress sub-app, built by the adapter bootstrap when + * the channel is enabled in webhook mode with valid credentials. The + * composition root threads it into the gateway deps so `/channels/googlechat` + * mounts only when a caller-backed ingress exists. Undefined in pubsub mode + * (the pull loop opens the transport) or when the channel is disabled. */ + googlechatIngress?: import("hono").Hono; /** The command queue instance for parent session TTL extension during graph execution. */ commandQueue?: CommandQueue; /** DeliveryService constructed once at the daemon composition root. Threaded @@ -373,7 +379,7 @@ export async function setupChannels(deps: ChannelsDeps): Promise // + the daemon TimerPort are injected into createMsTeamsPlugin here (both are // optional @comis/core-port seams on the adapter): capture + proactive recovery // + the typing keepalive. - const { adaptersByType, tgPlugin, linePlugin, channelPlugins, msTeamsIngress, msTeamsPlugin, googlechatPlugin } = await bootstrapAdapters({ + const { adaptersByType, tgPlugin, linePlugin, channelPlugins, msTeamsIngress, msTeamsPlugin, googlechatPlugin, googlechatIngress } = await bootstrapAdapters({ container, channelsLogger, msTeamsConversationStore: deps.msTeamsConversationStore, @@ -504,6 +510,7 @@ export async function setupChannels(deps: ChannelsDeps): Promise lifecycleReactors, channelPlugins, msTeamsIngress, + googlechatIngress, commandQueue, deliveryService, }; From f64b0bd56d6b96b91950f98474560f7276a786f0 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 10:45:30 +0300 Subject: [PATCH 135/200] chore(237-06): shrink createGoogleChatIngress out of the ahead-of-consumer export baseline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - The daemon composition root now consumes createGoogleChatIngress (webhook ingress build), so it is no longer an orphan — remove it from the @comis/gateway public-export-consumers allowlist per the shrink-only ratchet - Keep GoogleChatIngressDeps (still inline-constructed by the daemon, no import), mirroring the MsTeamsIngressDeps parity entry --- test/support/public-api-policy.ts | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/test/support/public-api-policy.ts b/test/support/public-api-policy.ts index aab79a07d..3cc0ea2ec 100644 --- a/test/support/public-api-policy.ts +++ b/test/support/public-api-policy.ts @@ -2619,12 +2619,10 @@ export const PUBLIC_API_POLICY: ReadonlyMap> = // MsTeamsIngressDeps is the factory's deps shape, which the daemon // constructs inline (like ApprovalTokenDeps) — tracked here for parity. "MsTeamsIngressDeps", - // Google Chat inbound ingress, surfaced AHEAD of its consumer. Unlike the - // shipped msteams factory, createGoogleChatIngress has no cross-package - // importer yet — the daemon composition root (setup-channels-adapters.ts) - // builds it in a later wave, so both the factory and its inline-constructed - // deps shape are tracked here. Shrink both once that consumer lands. - "createGoogleChatIngress", + // Google Chat inbound ingress. createGoogleChatIngress is consumed by the + // daemon composition root (setup-channels-adapters.ts builds the ingress in + // webhook mode); GoogleChatIngressDeps is the factory's deps shape, which the + // daemon constructs inline (like MsTeamsIngressDeps) — tracked here for parity. "GoogleChatIngressDeps", ])], // @comis/infra: baseline orphans + transient orphans. From 19500b15b52df555be3ff40e2674cc78c531cf0e Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 11:01:14 +0300 Subject: [PATCH 136/200] test(237-07): add failing test for /channels/googlechat mount - Mount cases mirror the msteams pair: /channels/googlechat mounts when a built ingress is threaded into the gateway route deps (presence = mount signal), and no route when absent. - Fails on pre-patch: mountGatewayRoutes has no googlechat mount block yet. --- .../src/wiring/setup-gateway-routes.test.ts | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/packages/daemon/src/wiring/setup-gateway-routes.test.ts b/packages/daemon/src/wiring/setup-gateway-routes.test.ts index 7eb058b8c..6f2933258 100644 --- a/packages/daemon/src/wiring/setup-gateway-routes.test.ts +++ b/packages/daemon/src/wiring/setup-gateway-routes.test.ts @@ -331,6 +331,36 @@ describe("mountGatewayRoutes", () => { ); }); + // ----------------------------------------------------------------------- + // Google Chat inbound ingress mount (same presence-gated contract). The + // route exists ONLY when the composition root threaded a built ingress + // sub-app (channel enabled + creds valid); absent otherwise. Presence of + // the threaded ingress IS the mount signal — a caller-less dead route can + // never ship. + // ----------------------------------------------------------------------- + + it("mounts /channels/googlechat when the threaded ingress is present (enabled)", () => { + const deps = createMockDeps({ googlechatIngress: new Hono() }); + + mountGatewayRoutes(deps); + + expect(deps.gatewayHandle.app.route).toHaveBeenCalledWith( + "/channels/googlechat", + expect.any(Hono), + ); + }); + + it("does NOT mount /channels/googlechat when the ingress is absent (disabled)", () => { + const deps = createMockDeps(); + + mountGatewayRoutes(deps); + + expect(deps.gatewayHandle.app.route).not.toHaveBeenCalledWith( + "/channels/googlechat", + expect.anything(), + ); + }); + // ----------------------------------------------------------------------- // OpenAI routes // ----------------------------------------------------------------------- From 079cad0d95ba16022d61ca562e1c9a62874965a0 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 11:02:51 +0300 Subject: [PATCH 137/200] feat(237-07): mount /channels/googlechat when the ingress is threaded MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add googlechatIngress? to GatewayRouteDeps + destructure it. - Add a presence-gated mount block mirroring the msteams one: mounts gatewayHandle.app.route("/channels/googlechat", googlechatIngress) only when a built ingress is threaded in; absent ⇒ no route. - The msteams mount is unchanged. --- .../daemon/src/wiring/setup-gateway-routes.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/packages/daemon/src/wiring/setup-gateway-routes.ts b/packages/daemon/src/wiring/setup-gateway-routes.ts index df9a1694e..a0fc2e973 100644 --- a/packages/daemon/src/wiring/setup-gateway-routes.ts +++ b/packages/daemon/src/wiring/setup-gateway-routes.ts @@ -120,6 +120,11 @@ export interface GatewayRouteDeps { * caller-backed ingress), the `/channels/msteams` route is mounted; absent * ⇒ no route exists. Presence is the mount signal. */ msTeamsIngress?: import("hono").Hono; + /** Google Chat inbound ingress sub-app. Present only when the channel is + * enabled in webhook mode with validated credentials (the composition root + * built a caller-backed ingress); the `/channels/googlechat` route is + * mounted only then. Presence is the mount signal. */ + googlechatIngress?: import("hono").Hono; /** Deterministic unattended honest-fail backstop (webhook-claude-cli-tdd-20260701, * `WEBHOOK-CLAUDE-AGENT-DRIVE-RELIABILITY`): after an unattended (webhook) agent turn, reap the * LIVE terminal drives the turn created but NEVER tasked (no `send_text`) — the model @@ -157,6 +162,7 @@ export function mountGatewayRoutes(deps: GatewayRouteDeps): void { defaultWorkspaceDir, interactiveCallbackWiring, msTeamsIngress, + googlechatIngress, reapNeverTaskedDrives, } = deps; @@ -201,6 +207,22 @@ export function mountGatewayRoutes(deps: GatewayRouteDeps): void { ); } + // ------------------------------------------------------------------------- + // Google Chat inbound ingress + // ------------------------------------------------------------------------- + // The sibling of the Microsoft Teams ingress: a Hono sub-app that verifies + // the inbound Bearer JWT before parsing the body, then fast-acks. Mounted + // ONLY when the composition root threaded a built ingress here (the channel + // is enabled in webhook mode and its credentials validated) — presence is + // the mount signal, so a pubsub-mode or disabled channel produces no route. + if (googlechatIngress !== undefined) { + gatewayHandle.app.route("/channels/googlechat", googlechatIngress); + gatewayLogger.debug( + { submodule: "googlechat-ingress" }, + "Google Chat ingress mounted at /channels/googlechat/*", + ); + } + // ------------------------------------------------------------------------- // Webhook mapping sub-app // ------------------------------------------------------------------------- From a4ed506adbcd9fede4d22c3fce0504af582bd9f0 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 11:05:13 +0300 Subject: [PATCH 138/200] test(237-07): add failing forwarding test for googlechatIngress thread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Runs the real setupGateway wrapper (heavy collaborators mocked) with a googlechatIngress sentinel and asserts the mocked mountGatewayRoutes received it — proving the wrapper→mount pass-through hop. - Fails on pre-patch: the wrapper does not forward googlechatIngress yet. --- .../setup-gateway-routes.test.ts | 65 ++++++++++++++++++- 1 file changed, 64 insertions(+), 1 deletion(-) diff --git a/packages/daemon/src/wiring/setup-gateway/setup-gateway-routes.test.ts b/packages/daemon/src/wiring/setup-gateway/setup-gateway-routes.test.ts index c825ed92a..29e705d97 100644 --- a/packages/daemon/src/wiring/setup-gateway/setup-gateway-routes.test.ts +++ b/packages/daemon/src/wiring/setup-gateway/setup-gateway-routes.test.ts @@ -11,8 +11,28 @@ * @module */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, vi } from "vitest"; + +// Mock the heavy collaborators so `setupGateway` reaches its `mountGatewayRoutes` +// call without a full gateway-server harness. `mountGatewayRoutes` is a spy, so +// the forwarding test asserts the wrapper THREADS `googlechatIngress` into it +// (the new hop); the real mount behavior is covered by the sibling +// `../setup-gateway-routes.test.ts`. +vi.mock("../setup-gateway-routes.js", () => ({ mountGatewayRoutes: vi.fn() })); +vi.mock("@comis/gateway", () => ({ + createTokenStore: vi.fn(() => ({})), + WsConnectionManager: vi.fn(), + createGatewayServer: vi.fn(), +})); +vi.mock("./setup-gateway-admin.js", () => ({ buildGreetingGenerator: vi.fn(() => ({})) })); +vi.mock("./setup-gateway-rpc.js", () => ({ + buildRpcAdapterDeps: vi.fn(() => ({})), + buildDynamicRouterAndRegister: vi.fn(() => ({ server: {} })), +})); +vi.mock("../../api/mcp-server-handlers.js", () => ({ buildMcpServerForClient: vi.fn() })); + import { setupGateway, type GatewayDeps, type GatewayResult } from "./setup-gateway-routes.js"; +import { mountGatewayRoutes } from "../setup-gateway-routes.js"; describe("setup-gateway-routes", () => { it("setupGateway: exported as a callable function", () => { @@ -60,4 +80,47 @@ describe("setup-gateway-routes", () => { }; expect(Object.keys(witness).length).toBe(4); }); + + it("threads googlechatIngress from setupGateway into mountGatewayRoutes", async () => { + const googlechatIngress = { __googlechatIngress: true }; + const gatewayHandle = { + app: { route: vi.fn(), use: vi.fn() }, + start: vi.fn().mockResolvedValue(undefined), + }; + const logger = { info: vi.fn(), debug: vi.fn(), warn: vi.fn(), error: vi.fn() }; + const deps = { + container: { config: {}, eventBus: {} }, + gwConfig: { enabled: true, web: { enabled: false }, host: "127.0.0.1", port: 0 }, + webhooksConfig: undefined, + agents: {}, + defaultAgentId: "default", + configPaths: [], + defaultConfigPaths: [], + gatewayLogger: logger, + memoryAdapter: {}, + memoryApi: {}, + cachedPort: {}, + sessionStore: {}, + getExecutor: vi.fn(), + assembleToolsForAgent: vi.fn(), + preprocessMessageText: vi.fn(), + rpcCall: vi.fn(), + costTrackers: new Map(), + workspaceDirs: new Map(), + _createGatewayServer: vi.fn(() => gatewayHandle), + instanceId: "test-instance", + startupStartMs: Date.now(), + resolvedTokens: [], + daemonVersion: "0.0.0-test", + googlechatIngress, + } as unknown as GatewayDeps; + + await setupGateway(deps); + + // The wrapper must pass the threaded ingress straight through to the mount + // impl; without the pass-through the value never reaches app.route. + expect(mountGatewayRoutes).toHaveBeenCalledWith( + expect.objectContaining({ googlechatIngress }), + ); + }); }); From a3bc7c7c3170e4855c399cb1ae165b616aef1b6c Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 11:08:58 +0300 Subject: [PATCH 139/200] feat(237-07): thread googlechatIngress end-to-end from setupChannels through the daemon into the gateway mount --- packages/daemon/src/daemon-types.ts | 4 ++++ packages/daemon/src/daemon.ts | 5 ++++- .../daemon/src/wiring/setup-gateway/setup-gateway-routes.ts | 6 ++++++ 3 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/daemon/src/daemon-types.ts b/packages/daemon/src/daemon-types.ts index 471e807d7..1270c402f 100644 --- a/packages/daemon/src/daemon-types.ts +++ b/packages/daemon/src/daemon-types.ts @@ -584,6 +584,10 @@ export interface BootContext { * `/channels/msteams` route mounts when the channel is enabled. Optional: * undefined when the channel is disabled (no route). */ msTeamsIngress?: Awaited>["msTeamsIngress"]; + /** Google Chat inbound ingress sub-app — threaded to bootGateway so the + * `/channels/googlechat` route mounts when the channel is enabled in webhook + * mode. Optional: undefined otherwise (no route). */ + googlechatIngress?: Awaited>["googlechatIngress"]; commandQueue?: Awaited>["commandQueue"]; deliveryService?: Awaited>["deliveryService"]; inboundMessageIdResolver?: InboundMessageIdResolver; diff --git a/packages/daemon/src/daemon.ts b/packages/daemon/src/daemon.ts index 0dcba4727..aa4733575 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -2117,7 +2117,7 @@ async function bootChannels(boot: BootContext): Promise { // pass accessor closures for sessionTracker / inboundMessageIdResolver // (const `{current?:T}` container pattern; populated after setupChannels // returns by mutating the .current field). - const { adaptersByType, channelManager, resolveAttachment, lifecycleReactors, channelPlugins, msTeamsIngress, commandQueue, deliveryService } = await setupChannels( + const { adaptersByType, channelManager, resolveAttachment, lifecycleReactors, channelPlugins, msTeamsIngress, googlechatIngress, commandQueue, deliveryService } = await setupChannels( buildChannelManagerDeps({ agents: handle, msTeamsConversationStore, assembleToolsForAgent, @@ -2332,6 +2332,7 @@ async function bootChannels(boot: BootContext): Promise { Object.assign(boot, { adaptersByType, channelManager, resolveAttachment, lifecycleReactors, channelPlugins, msTeamsIngress, + googlechatIngress, commandQueue, deliveryService, inboundMessageIdResolver, channelHealthMonitor, stopChannelHealthMonitor, stopChannelLivenessMonitor, notificationContext, bgCompletionRunnerContext, terminalWakeContext, @@ -2393,6 +2394,7 @@ async function bootGateway( suspendedAgents, gatewaySendRef, interactiveCallbackWiring, msTeamsIngress, + googlechatIngress, obsStore, // backs the obs_explain assembler closure (diagnostics rollup) dataDir: bootDataDir, // absolute fallback data dir (always abs; ~/.comis or $COMIS_DATA_DIR) } = channels; @@ -2476,6 +2478,7 @@ async function bootGateway( instanceId, startupStartMs, interactiveCallbackWiring, msTeamsIngress, + googlechatIngress, obsExplainForMcpClient, obsFleetHealthForMcpClient, }); diff --git a/packages/daemon/src/wiring/setup-gateway/setup-gateway-routes.ts b/packages/daemon/src/wiring/setup-gateway/setup-gateway-routes.ts index aec1a9bb0..857e3551f 100644 --- a/packages/daemon/src/wiring/setup-gateway/setup-gateway-routes.ts +++ b/packages/daemon/src/wiring/setup-gateway/setup-gateway-routes.ts @@ -205,6 +205,11 @@ export interface GatewayDeps { * `mountGatewayRoutes` so the `/channels/msteams` route mounts only when * present; absent ⇒ no route. */ msTeamsIngress?: import("hono").Hono; + /** Google Chat inbound ingress sub-app, built by the channel bootstrap in + * webhook mode with valid credentials. Passed through to + * `mountGatewayRoutes` so the `/channels/googlechat` route mounts only when + * present; absent ⇒ no route. */ + googlechatIngress?: import("hono").Hono; } /** All services produced by the gateway setup. */ @@ -422,6 +427,7 @@ export async function setupGateway(deps: GatewayDeps): Promise { defaultWorkspaceDir: workspaceDirs.get(defaultAgentId), interactiveCallbackWiring: deps.interactiveCallbackWiring, msTeamsIngress: deps.msTeamsIngress, + googlechatIngress: deps.googlechatIngress, }); await gatewayHandle.start(); From ac2d345ac2651a77b6104480d2dc374c4ef9ab03 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 11:14:50 +0300 Subject: [PATCH 140/200] docs(237-08): document Google Chat webhook mode as shipped + the COMIS_GOOGLECHAT_TEST_* verify seams - config-yaml googlechat block: drop the "not yet available"/"arrives in a later release" caveats; document mode: webhook as an opt-in gateway ingress at /channels/googlechat (dual-audience Bearer-JWT verify + liveness window) and add a webhook yaml variant alongside the pubsub example - environment-variables: add COMIS_GOOGLECHAT_TEST_JWKS + COMIS_GOOGLECHAT_TEST_ISSUER (local-JWKS test verify, never a bypass; no connector-redirect analog) --- docs/reference/config-yaml.mdx | 23 +++++++--- docs/reference/environment-variables.mdx | 53 ++++++++++++++++++++++++ 2 files changed, 71 insertions(+), 5 deletions(-) diff --git a/docs/reference/config-yaml.mdx b/docs/reference/config-yaml.mdx index c8b0257d5..b396c88a5 100644 --- a/docs/reference/config-yaml.mdx +++ b/docs/reference/config-yaml.mdx @@ -1142,19 +1142,19 @@ Teams authenticates with an app registration rather than a single bot token. Inb **Google Chat (channels.googlechat)** -Google Chat authenticates with a service-account key (no bot token). The default `pubsub` transport pulls inbound events from a Pub/Sub subscription — no public IP or gateway required (`mode: webhook` support arrives in a later release). +Google Chat authenticates with a service-account key (no bot token). The default `pubsub` transport pulls inbound events from a Pub/Sub subscription — no public IP or gateway required. Set `mode: webhook` for an opt-in transport where Google delivers inbound events as authenticated HTTPS POSTs to `/channels/googlechat` on the gateway; every request is Bearer-JWT-verified before processing, so webhook mode also requires the gateway to be enabled (`gateway.enabled: true`) and a public URL. While `enabled` is `false`, or in `pubsub` mode, no inbound route is mounted. | Key | Type | Default | Description | |-----|------|---------|-------------| | `enabled` | `boolean` | `false` | Whether this channel is active (opt-in) | -| `mode` | `enum` | `"pubsub"` | Inbound transport: `pubsub` (default, no public IP) or `webhook` (not yet available) | +| `mode` | `enum` | `"pubsub"` | Inbound transport: `pubsub` (default, no public IP) or `webhook` (a gateway ingress mounted at `/channels/googlechat`; needs a public URL + `gateway.enabled: true`) | | `serviceAccountKey` | `string \| SecretRef` | _(unset)_ | Service-account key JSON for the Chat app. A secret — supply a `SecretRef` or set the `GOOGLECHAT_SA_KEY` environment variable | | `subscriptionName` | `string` | _(unset)_ | Pub/Sub pull subscription `projects/{project}/subscriptions/{sub}`; required in `pubsub` mode | -| `audienceType` | `enum` | `"project-number"` | Webhook-mode Bearer-JWT audience type (webhook mode not yet available) | -| `audience` | `string` | _(unset)_ | Webhook-mode audience value (webhook mode not yet available) | +| `audienceType` | `enum` | `"project-number"` | Webhook-mode Bearer-JWT audience type: `project-number` (the token's `aud` is your Google Cloud project number, issued by `chat@system.gserviceaccount.com`) or `app-url` (the `aud` is your endpoint URL, a Google OIDC ID token bound to the Chat sender) | +| `audience` | `string` | _(unset)_ | Webhook-mode audience value — the project number (`audienceType: project-number`) or the endpoint URL (`audienceType: app-url`); required in `webhook` mode | | `allowFrom` | `string[]` | `[]` | Allowed sender IDs — each a `users/{id}` or `spaces/{id}`; prefer the immutable `users/{id}` over a mutable email display id | | `allowMode` | `enum` | `"allowlist"` | Sender gate: `allowlist` (default, blocks all unless listed) or `open` | -| `missedInboundThresholdMs` | `number` | `21600000` (6h) | Webhook-mode liveness window before a missed-inbound alert (webhook mode not yet available). Must be a positive integer | +| `missedInboundThresholdMs` | `number` | `21600000` (6h) | Webhook-mode silence window before a missed-inbound alert fires. Webhook channels are exempt from the health monitor's stale-reap, so a dead ingress reports healthy indefinitely; a dedicated liveness timer compares the last inbound timestamp to this threshold and, on breach, emits a `channel:inbound_silent` event + a WARN that surface as a `comis fleet` health signal (`health_signal:channel_ingress_silent`). Floored at 1 minute. Must be a positive integer | | `mediaProcessing` | `MediaProcessing` | _(all true)_ | Per-channel media processing overrides | ```yaml @@ -1187,6 +1187,19 @@ channels: allowFrom: ["users/1234567890"] ``` +Google Chat in `mode: webhook` (opt-in — needs `gateway.enabled` and a public HTTPS URL; inbound is Bearer-JWT-verified at `/channels/googlechat` before processing, so no Pub/Sub subscription is required): + +```yaml +channels: + googlechat: + enabled: true + mode: webhook + serviceAccountKey: "${GOOGLECHAT_SA_KEY}" + audienceType: project-number + audience: "1234567890" # your Google Cloud project number + allowFrom: ["users/1234567890"] +``` + See the [Channels](/channels/index) section for platform-specific setup guides. diff --git a/docs/reference/environment-variables.mdx b/docs/reference/environment-variables.mdx index 87676be7f..ed4faea2e 100644 --- a/docs/reference/environment-variables.mdx +++ b/docs/reference/environment-variables.mdx @@ -305,6 +305,59 @@ Source: `packages/daemon/src/wiring/msteams-test-seams.ts` export COMIS_MSTEAMS_TEST_CONNECTOR=http://127.0.0.1:53999 ``` +### Google Chat live-test seams (test-only) + + +These variables exist ONLY to point the Google Chat webhook ingress at a local +loopback emulator for live testing. They are **unset in production** — with them +unset the daemon verifies inbound events against Google's live signing keys, +byte-identically to a normal Google Chat webhook deployment. **Never set them on +a production daemon.** They do not relax a security control: the seam swaps in a +full local-JWKS signature/issuer/audience verify, never a bypass. Google Chat has +no outbound connector-redirect seam — its egress base URLs (`tokenUrl`, +`chatBaseUrl`, `pubsubBaseUrl`) are already injectable via config, so there is no +`COMIS_GOOGLECHAT_TEST_CONNECTOR`. + + +#### `COMIS_GOOGLECHAT_TEST_JWKS` + +| Property | Value | +|----------|-------| +| **Used by** | Daemon (googlechat webhook ingress) | +| **Format** | Absolute path to a public JWKS JSON file | +| **Default** | (unset) — Google's live remote JWKS (the Chat-system keys for a `project-number` audience, or Google's OIDC certs for an `app-url` audience) | + +When set, the Google Chat ingress verifies inbound event tokens against the local +JWKS at this path (the emulator's public signing key) instead of Google's remote +keys. A full RS256 signature + issuer + audience verify — plus the `app-url` +sender-binding email claim — not a bypass. Activating it emits a content-free WARN +so a stray production setting is loud in the logs. + +Source: `packages/daemon/src/wiring/googlechat-test-seams.ts` + +```bash +export COMIS_GOOGLECHAT_TEST_JWKS=/tmp/comis-googlechat-jwks.json +``` + +#### `COMIS_GOOGLECHAT_TEST_ISSUER` + +| Property | Value | +|----------|-------| +| **Used by** | Daemon (googlechat webhook ingress) | +| **Format** | Expected token `iss` claim | +| **Default** | (unset) — the Google issuer for the configured `audienceType` | + +Optional. Only meaningful alongside `COMIS_GOOGLECHAT_TEST_JWKS`: overrides the +expected issuer when the emulator mints tokens from a fully-synthetic key set +rather than impersonating Google's issuer. Unset, the verifier still requires the +real Google issuer for the configured audience shape. + +Source: `packages/daemon/src/wiring/googlechat-test-seams.ts` + +```bash +export COMIS_GOOGLECHAT_TEST_ISSUER=https://emulator.test/issuer +``` + ## Secret Variables ### `SECRETS_MASTER_KEY` From c22a9780d9a144f93fa9804437d5b2f3029da864 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 11:15:43 +0300 Subject: [PATCH 141/200] docs(237-08): add the Google Chat webhook public-endpoint note to defense-in-depth - opt-in mode: webhook mounts /channels/googlechat, rides the gateway TLS, and Bearer-JWT-verifies every inbound before processing; stale-reap-exempt so the missed-inbound alert catches a dead ingress - links to the config reference (no /channels/googlechat runbook link yet) --- docs/security/defense-in-depth.mdx | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/docs/security/defense-in-depth.mdx b/docs/security/defense-in-depth.mdx index 5898ec63c..c4c913e47 100644 --- a/docs/security/defense-in-depth.mdx +++ b/docs/security/defense-in-depth.mdx @@ -117,6 +117,33 @@ the input checkpoint. [Microsoft Teams setup guide](/channels/msteams) for the full exposure and liveness walkthrough. + + + **What it does:** Google Chat runs a no-public-IP Cloud Pub/Sub pull + transport by default (`mode: pubsub`). In the opt-in `mode: webhook`, Google + instead POSTs events to `/channels/googlechat` on your gateway. That route + mounts **only** when the channel is enabled in webhook mode + (`channels.googlechat.enabled: true` with `mode: webhook`; off by default), + rides the gateway's existing TLS surface, and every inbound request is + Bearer-JWT-verified against Google's signing keys (per the configured + `audienceType`) **before** it reaches your agent. + + **Why it matters:** A public endpoint is an exposed surface. Keeping webhook + mode opt-in, verifying every inbound request before processing, and + terminating TLS at the gateway (or a reverse proxy / Tailscale Funnel in + front of it) keeps that surface closed by default and authenticated when + open. A webhook channel is also exempt from the health monitor's automatic + stale-reap, so a silently broken ingress would otherwise report healthy + forever -- a bot that quietly stops receiving messages with no error. + + **Do I need to configure it?** Only if you choose webhook mode. Expose the + gateway over HTTPS (a reverse proxy or Tailscale Funnel is recommended), + keep `channels.googlechat.enabled` off until the endpoint is wired, and rely + on the missed-inbound alert (`missedInboundThresholdMs`, default 6 hours) to + catch a dead ingress -- it surfaces as a `comis fleet` health signal. See the + [configuration reference](/reference/config-yaml) for the + `channels.googlechat` keys. + ### When a Message Arrives From 13a2c3bc71bbc2adfb3eecb3130d916da60c7b75 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 11:16:46 +0300 Subject: [PATCH 142/200] docs(237-08): drop the stale "transport not wired yet" comment on the googlechat webhook schema fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Webhook mode is wired end-to-end; the audienceType/audience field comments now read "webhook mode". Comment-only — no schema or behavior change. --- packages/core/src/config/schema-channel.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/core/src/config/schema-channel.ts b/packages/core/src/config/schema-channel.ts index b0e29364f..f99f7af04 100644 --- a/packages/core/src/config/schema-channel.ts +++ b/packages/core/src/config/schema-channel.ts @@ -235,8 +235,8 @@ export const GoogleChatChannelEntrySchema = z.strictObject({ mode: z.enum(["pubsub", "webhook"]).default("pubsub"), serviceAccountKey: SecretRefOrStringSchema.optional(), subscriptionName: z.string().optional(), // pubsub: projects/X/subscriptions/Y - audienceType: z.enum(["project-number", "app-url"]).default("project-number"), // webhook mode (transport not wired yet) - audience: z.string().optional(), // webhook mode (transport not wired yet) + audienceType: z.enum(["project-number", "app-url"]).default("project-number"), // webhook mode + audience: z.string().optional(), // webhook mode allowFrom: z.array(z.string()).default([]), // users/{id} or spaces/{id} allowMode: z.enum(["allowlist", "open"]).default("allowlist"), missedInboundThresholdMs: z.number().int().min(60_000).default(21_600_000), // webhook-mode liveness window; floor 1 min From ce297b77df49b8d3ce939171f43e2712b2ee4548 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 11:55:30 +0300 Subject: [PATCH 143/200] test(237): failing tests for mode-aware googlechat credential validation Webhook mode receives inbound over the gateway ingress, so it needs no Pub/Sub subscriptionName; it instead needs the audience the inbound Bearer-JWT verifier binds to. Add validator unit tests (webhook+audience, no subscription -> ok; webhook, blank audience -> err naming audience) and daemon registration tests that run the REAL validator (documented webhook config registers + mounts the ingress; a webhook config missing audience fails fast with a config-errorKind WARN naming channels.googlechat.audience). --- .../googlechat/credential-validator.test.ts | 53 +++++++++++++++++++ .../wiring/setup-channels-adapters.test.ts | 49 +++++++++++++++++ 2 files changed, 102 insertions(+) diff --git a/packages/channels/src/googlechat/credential-validator.test.ts b/packages/channels/src/googlechat/credential-validator.test.ts index 88ba0b5ea..2448b4b6f 100644 --- a/packages/channels/src/googlechat/credential-validator.test.ts +++ b/packages/channels/src/googlechat/credential-validator.test.ts @@ -73,6 +73,59 @@ describe("validateGoogleChatCredentials", () => { if (!result.ok) expect(result.error.message).toContain("subscriptionName"); }); + // Webhook mode receives inbound over the gateway ingress (no Pub/Sub pull + // loop), so it requires no subscriptionName; it instead requires the audience + // the inbound Bearer-JWT verifier binds to. + it("returns ok in webhook mode with an audience and no subscriptionName", () => { + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + mode: "webhook", + audience: "1234567890", + }); + expect(result.ok).toBe(true); + }); + + it("returns err naming audience in webhook mode when the audience is blank", () => { + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + mode: "webhook", + audience: "", + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.message).toContain("audience"); + }); + + it("treats a whitespace-only audience as blank in webhook mode", () => { + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + mode: "webhook", + audience: " ", + }); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error.message).toContain("audience"); + }); + + it("does not require a subscriptionName in webhook mode even when it is blank", () => { + // A webhook config carries no subscriptionName by design; a blank one must + // NOT fail validation the way it does in pubsub mode. + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + mode: "webhook", + audience: "https://example.com/hook", + subscriptionName: "", + }); + expect(result.ok).toBe(true); + }); + + it("does not require an audience in pubsub mode (subscriptionName is the pubsub precondition)", () => { + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + mode: "pubsub", + subscriptionName: VALID_SUBSCRIPTION, + }); + expect(result.ok).toBe(true); + }); + it("returns err for a serviceAccountKey that is not valid JSON, without echoing the raw value", () => { const malformed = `not-json ${PRIVATE_KEY_SENTINEL}`; const result = validateGoogleChatCredentials({ diff --git a/packages/daemon/src/wiring/setup-channels-adapters.test.ts b/packages/daemon/src/wiring/setup-channels-adapters.test.ts index 9a1be0543..a9578c8f7 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.test.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.test.ts @@ -917,4 +917,53 @@ describe("bootstrapAdapters", () => { expect(result.googlechatIngress).toBeUndefined(); expect(createGoogleChatIngress).not.toHaveBeenCalled(); }); + + // ------------------------------------------------------------------------- + // Google Chat webhook mode — REAL credential validator. The suite otherwise + // stubs the validator to always pass, which masks whether the mode gate and + // the setup block agree. These two exercise the ACTUAL validator so a + // documented webhook config (no subscriptionName) registers, and a webhook + // config missing its audience fails fast at registration. + // ------------------------------------------------------------------------- + + it("registers a documented webhook config (real validator, no subscriptionName) and mounts the ingress", async () => { + const actual = await vi.importActual("@comis/channels"); + vi.mocked(validateGoogleChatCredentials).mockImplementationOnce((opts) => + actual.validateGoogleChatCredentials(opts), + ); + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const container = makeContainer({ + googlechat: { enabled: true, serviceAccountKey: saKey, mode: "webhook", audienceType: "project-number", audience: "1234567890" }, + }); + const result = await bootstrapAdapters({ container, channelsLogger }); + + expect(result.adaptersByType.get("googlechat")).toBe(mockGoogleChatAdapter); + expect(result.googlechatIngress).toBe(mockGoogleChatIngress); + expect(createGoogleChatIngress).toHaveBeenCalledTimes(1); + }); + + it("refuses to register a webhook config missing audience (real validator) and WARNs config naming channels.googlechat.audience", async () => { + const actual = await vi.importActual("@comis/channels"); + vi.mocked(validateGoogleChatCredentials).mockImplementationOnce((opts) => + actual.validateGoogleChatCredentials(opts), + ); + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const container = makeContainer({ + googlechat: { enabled: true, serviceAccountKey: saKey, mode: "webhook", audienceType: "project-number" }, // no audience + }); + const result = await bootstrapAdapters({ container, channelsLogger }); + + expect(result.adaptersByType.has("googlechat")).toBe(false); + expect(result.googlechatIngress).toBeUndefined(); + expect(createGoogleChatIngress).not.toHaveBeenCalled(); + // An unset audience is a config error (name the exact knob), not the + // per-request invalid_token flood the fleet lens reads as a forged-webhook attack. + expect(channelsLogger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + errorKind: "config", + hint: expect.stringContaining("channels.googlechat.audience"), + }), + expect.stringContaining("Google Chat credential validation failed"), + ); + }); }); From a1db0924b3c39e1c49d305a94f2b80dcd5664f57 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 11:57:42 +0300 Subject: [PATCH 144/200] fix(237): make googlechat credential validator mode-aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The validator rejected a blank subscriptionName unconditionally, so a documented webhook config (mode: webhook, audience set, no subscriptionName) failed validation and never registered — the ingress never mounted and Google's inbound POSTs hit a 404. Gate the subscription check on the mode: pubsub requires subscriptionName (unchanged); webhook requires audience and no subscription. An absent mode is treated as pubsub (backward compatible). Thread mode + audience from the setup call site, and make the registration failure WARN mode-aware: a webhook config with a blank audience now fails fast at boot with a config-errorKind WARN naming channels.googlechat.audience, rather than registering and rejecting every inbound request as an invalid_token flood that reads as a forged-webhook attack in the fleet lens. --- .../src/googlechat/credential-validator.ts | 60 ++++++++++++++----- .../src/wiring/setup-channels-adapters.ts | 19 +++++- 2 files changed, 63 insertions(+), 16 deletions(-) diff --git a/packages/channels/src/googlechat/credential-validator.ts b/packages/channels/src/googlechat/credential-validator.ts index c600a0a47..cb0f192b8 100644 --- a/packages/channels/src/googlechat/credential-validator.ts +++ b/packages/channels/src/googlechat/credential-validator.ts @@ -1,16 +1,18 @@ // SPDX-License-Identifier: Apache-2.0 /** * Google Chat Credential Validator: a fail-fast guard that the service-account - * key and Pub/Sub subscription required to register the adapter are present and - * well-formed. + * key and the per-mode inbound precondition required to register the adapter are + * present and well-formed. * - * Synchronous, transport-free, and secret-safe. It confirms the subscription is - * set and the key parses into a service-account key JSON carrying the two fields - * the outbound JWT mint needs (`private_key` and `client_email`), naming any - * missing field in the error — never the secret value, and never the raw key - * text on a parse failure. Minting a live token and reaching the subscription - * are separate operational probes and are intentionally out of scope here; this - * is parse-only. + * Synchronous, transport-free, and secret-safe. It confirms the per-mode inbound + * precondition — pubsub mode needs the Pub/Sub subscription; webhook mode needs + * the inbound-JWT audience, receiving inbound over the gateway ingress rather + * than a pull loop — and that the key parses into a service-account key JSON + * carrying the two fields the outbound JWT mint needs (`private_key` and + * `client_email`), naming any missing field in the error — never the secret + * value, and never the raw key text on a parse failure. Minting a live token and + * reaching the subscription are separate operational probes and are + * intentionally out of scope here; this is parse-only. * * It also lints the sender allowlist: an email display id is mutable and * spoofable, so an email-shaped `allowFrom` entry surfaces an advisory WARN @@ -33,6 +35,17 @@ export interface GoogleChatValidateOpts { serviceAccountKey?: string; /** The Pub/Sub pull subscription resource name (required in pubsub mode). */ subscriptionName?: string; + /** + * Inbound transport mode. Selects the per-mode precondition: `pubsub` (the + * default when absent) requires {@link subscriptionName}; `webhook` requires + * {@link audience} and needs no subscription. + */ + mode?: "pubsub" | "webhook"; + /** + * The inbound Bearer-JWT audience the webhook verifier binds to (the project + * number or the endpoint URL). Required in webhook mode; ignored in pubsub mode. + */ + audience?: string; /** The configured sender allowlist, linted for mutable email-shaped ids. */ allowFrom?: string[]; /** @@ -58,15 +71,19 @@ function isEmailShaped(entry: string): boolean { } /** - * Verify the Google Chat service-account key and subscription required to - * register the adapter are present and well-formed, parse-only. + * Verify the Google Chat service-account key and the per-mode inbound + * precondition required to register the adapter are present and well-formed, + * parse-only. * * @param opts.serviceAccountKey - The service-account key JSON string - * @param opts.subscriptionName - The Pub/Sub pull subscription resource name + * @param opts.subscriptionName - The Pub/Sub pull subscription resource name (pubsub mode) + * @param opts.mode - Inbound transport mode; absent is treated as pubsub + * @param opts.audience - The inbound Bearer-JWT audience (webhook mode) * @param opts.allowFrom - The sender allowlist (linted, never gated here) * @param opts.logger - Optional logger for the advisory allowlist lint - * @returns ok when the key parses with the required fields and the subscription - * is set; err naming the first missing or malformed field, never its value + * @returns ok when the key parses with the required fields and the per-mode + * precondition is met (pubsub → subscriptionName, webhook → audience); err + * naming the first missing or malformed field, never its value */ export function validateGoogleChatCredentials( opts: GoogleChatValidateOpts, @@ -76,7 +93,20 @@ export function validateGoogleChatCredentials( new Error("Google Chat credentials invalid: serviceAccountKey must not be empty"), ); } - if (isBlank(opts.subscriptionName)) { + // Per-mode inbound precondition. Webhook mode receives inbound over the gateway + // ingress (no pull loop), so it needs no subscription; it instead needs the + // audience the inbound Bearer-JWT verifier binds to. Failing fast here means an + // unset audience is a boot config error, never a per-request auth-reject flood + // at the ingress. An absent mode is treated as pubsub (backward compatible). + if (opts.mode === "webhook") { + if (isBlank(opts.audience)) { + return err( + new Error( + "Google Chat credentials invalid: audience must not be empty (webhook mode)", + ), + ); + } + } else if (isBlank(opts.subscriptionName)) { return err( new Error( "Google Chat credentials invalid: subscriptionName must not be empty (pubsub mode)", diff --git a/packages/daemon/src/wiring/setup-channels-adapters.ts b/packages/daemon/src/wiring/setup-channels-adapters.ts index d911f7cfc..0f7ca14b3 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.ts @@ -537,6 +537,11 @@ export async function bootstrapAdapters(deps: { const validation = validateGoogleChatCredentials({ serviceAccountKey: key, subscriptionName, + // Thread the mode + audience so the validator enforces the per-mode inbound + // precondition: pubsub needs subscriptionName, webhook needs audience. Absent + // this, a documented webhook config (no subscriptionName) would be rejected. + mode: channelConfig.googlechat.mode, + audience: channelConfig.googlechat.audience, allowFrom: channelConfig.googlechat.allowFrom, logger: channelsLogger, }); @@ -599,7 +604,19 @@ export async function bootstrapAdapters(deps: { } channelsLogger.info({ channelType: "googlechat", mode: channelConfig.googlechat.mode }, "Channel adapter initialized"); } else { - channelsLogger.warn({ hint: "Set channels.googlechat.serviceAccountKey (SecretRef) or GOOGLECHAT_SA_KEY, and channels.googlechat.subscriptionName", errorKind: "auth" as const }, "Google Chat credential validation failed"); + // Mode-aware failure hint. In webhook mode a blank audience is the common + // misconfiguration: name that exact knob with a config errorKind so the + // fleet lens reads it as an operator config gap, not the per-request + // invalid_token flood that signals a forged-webhook attack. Every other + // failure (missing/malformed key, or a pubsub config with no subscription) + // stays an auth-class WARN naming the credential knobs. + const gc = channelConfig.googlechat; + const audienceBlank = !gc.audience || gc.audience.trim() === ""; + if (gc.mode === "webhook" && audienceBlank) { + channelsLogger.warn({ hint: "Set channels.googlechat.audience — required in webhook mode (the project number for audienceType project-number, or the endpoint URL for app-url); an unset audience rejects every inbound request", errorKind: "config" as const }, "Google Chat credential validation failed"); + } else { + channelsLogger.warn({ hint: "Set channels.googlechat.serviceAccountKey (SecretRef) or GOOGLECHAT_SA_KEY, and channels.googlechat.subscriptionName (pubsub mode) or channels.googlechat.audience (webhook mode)", errorKind: "auth" as const }, "Google Chat credential validation failed"); + } } } From d4ad75a8671213eb6ca912d9ca6ffced63ccf66e Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 11:58:40 +0300 Subject: [PATCH 145/200] test(237): failing test for contained googlechat webhook dispatch A webhook handler rejection currently escapes the fire-and-forget void dispatch as an unhandled rejection, which the daemon's global safety net re-logs as a second generic internal error (double-counting in the fleet errorKind tally). Assert the dispatch contains the rejection in exactly one debug log with no unhandled rejection. --- .../wiring/setup-channels-adapters.test.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/daemon/src/wiring/setup-channels-adapters.test.ts b/packages/daemon/src/wiring/setup-channels-adapters.test.ts index a9578c8f7..545c6755d 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.test.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.test.ts @@ -886,6 +886,35 @@ describe("bootstrapAdapters", () => { expect(mockGoogleChatAdapter.handleChatEvent).toHaveBeenCalledWith({ some: "event" }); }); + it("contains a rejecting webhook handler in a single debug log with no unhandled rejection", async () => { + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const container = makeContainer({ + googlechat: { enabled: true, serviceAccountKey: saKey, mode: "webhook", audienceType: "project-number", audience: "123456789" }, + }); + await bootstrapAdapters({ container, channelsLogger }); + + const ingressDeps = vi.mocked(createGoogleChatIngress).mock.calls[0]![0] as unknown as { + handleWebhookEvents: (events: unknown[]) => void; + }; + // handleChatEvent rejects by design (its Pub/Sub pull-loop skip-ack signal), + // which is meaningless in webhook mode — the dispatch must contain the + // rejection, not leak an unhandled rejection that the daemon's global safety + // net re-logs as a second, generic internal error (double-counting in fleet). + mockGoogleChatAdapter.handleChatEvent.mockImplementationOnce(() => + Promise.reject(new Error("handler boom")), + ); + ingressDeps.handleWebhookEvents([{ some: "event" }]); + + // Exactly one contained log line (debug); the rejection is caught, so no + // unhandled rejection escapes to the global handler. + await vi.waitFor(() => + expect(channelsLogger.debug).toHaveBeenCalledWith( + expect.stringContaining("googlechat-webhook-dispatch"), + ), + ); + expect(channelsLogger.debug).toHaveBeenCalledTimes(1); + }); + it("bridges onAuthRejected onto the content-free channel:ingress_auth_rejected eventBus signal", async () => { const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; const container = makeContainer({ From f90dcbb5c79557d33c04b18c286f70c47d254d3b Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 11:59:19 +0300 Subject: [PATCH 146/200] fix(237): contain googlechat webhook dispatch rejections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit handleChatEvent rejects by design (its Pub/Sub pull-loop skip-ack signal), which is meaningless in webhook mode: the ingress already fast-acked and there is no redelivery. The fire-and-forget void discarded the rejected promise, so every webhook handler failure surfaced as an unhandled rejection that the daemon's global safety net re-logged as a second generic internal error — double-counting in the fleet errorKind tally. Wrap each dispatch in suppressError so the rejection is contained at debug level (the specific failure is already logged in fanOutMessage), mirroring the msteams sibling's per-event containment. --- .../src/wiring/setup-channels-adapters.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/packages/daemon/src/wiring/setup-channels-adapters.ts b/packages/daemon/src/wiring/setup-channels-adapters.ts index 0f7ca14b3..3f27b1347 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.ts @@ -50,6 +50,7 @@ import { import { resolveTestGoogleChatVerifier } from "./googlechat-test-seams.js"; import os from "node:os"; import { safePath, systemNowMs } from "@comis/core"; +import { suppressError } from "@comis/shared"; // --------------------------------------------------------------------------- // Result type @@ -584,10 +585,20 @@ export async function bootstrapAdapters(deps: { { logger: channelsLogger }, ), // Fire-and-forget: googlechat's one normalizer is async, so the ingress - // does not block its fast-ack on it (per-event failure is the adapter's - // own concern). + // does not block its fast-ack on it. handleChatEvent rejects by design + // (its Pub/Sub pull-loop skip-ack signal), which is meaningless in + // webhook mode — the ingress already fast-acked and there is no + // redelivery. fanOutMessage has already logged the specific failure, so + // suppressError contains the rejection at a debug level: no unhandled + // rejection, and no second generic internal error double-counting in fleet. handleWebhookEvents: (events) => { - for (const e of events) void gcAdapter.handleChatEvent(e); + for (const e of events) { + suppressError( + gcAdapter.handleChatEvent(e), + "googlechat-webhook-dispatch", + (msg) => channelsLogger.debug(msg), + ); + } }, // Bridge the ingress auth-gate rejections onto the daemon eventBus as a // content-free health_signal, so a forged/expired/wrong-audience/missing- From 87536eeddb546cf54e3eb92f9cd446af45badb03 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 12:00:23 +0300 Subject: [PATCH 147/200] test(237): failing tests for the googlechat test-seam JWKS read guard The test-seam JSON.parse(readFileSync(...)) throws synchronously on a missing/unreadable/malformed JWKS file. It runs inside bootstrapAdapters, which is unwrapped, so the throw fails daemon boot. Assert an unreadable or non-JSON file does not throw; the seam falls back to the production remote-JWKS verifier and logs a config-errorKind WARN naming the env var. --- .../src/wiring/googlechat-test-seams.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/packages/daemon/src/wiring/googlechat-test-seams.test.ts b/packages/daemon/src/wiring/googlechat-test-seams.test.ts index 4ad2b76da..0cf5c8aaf 100644 --- a/packages/daemon/src/wiring/googlechat-test-seams.test.ts +++ b/packages/daemon/src/wiring/googlechat-test-seams.test.ts @@ -255,3 +255,60 @@ describe("resolveTestGoogleChatVerifier — local-JWKS seam path (COMIS_GOOGLECH expect((await verify(`Bearer ${googleToken}`)).ok).toBe(false); }); }); + +describe("resolveTestGoogleChatVerifier — unreadable/malformed JWKS file (fail-closed to production, never crash boot)", () => { + it("does not throw when the JWKS file is unreadable; falls back to the production verifier + a config WARN naming the env var", async () => { + // readFileImpl throwing simulates ENOENT/EACCES — the seam must not let that + // propagate out of bootstrapAdapters and fail daemon boot. + const readFileImpl = vi.fn((_p: string): string => { + throw new Error("ENOENT: no such file or directory"); + }); + const { logger, warn, serialized } = makeLoggerSpy(); + const getEnv = (k: string): string | undefined => + k === "COMIS_GOOGLECHAT_TEST_JWKS" ? "/seam/missing.jwks.json" : undefined; + + let verify: ReturnType | undefined; + expect(() => { + verify = resolveTestGoogleChatVerifier( + { audienceType: "project-number", audience: "1234567890" }, + getEnv, + { readFileImpl, logger }, + ); + }).not.toThrow(); + + // A usable verifier is still returned — the production remote-JWKS one. + expect(typeof verify).toBe("function"); + expect((await verify!(undefined)).ok).toBe(false); + + // A single config-errorKind WARN names the env var and the fallback. + expect(warn).toHaveBeenCalledTimes(1); + const warnArg = warn.mock.calls[0]?.[0] as Record; + expect(warnArg).toMatchObject({ channelType: "googlechat", errorKind: "config" }); + expect(String(warnArg.hint)).toContain("COMIS_GOOGLECHAT_TEST_JWKS"); + // Content-free: the read path is not echoed into the WARN structured fields. + expect(serialized()).not.toContain("/seam/missing.jwks.json"); + }); + + it("does not throw when the JWKS file is not valid JSON; falls back to the production verifier + a config WARN", async () => { + const readFileImpl = vi.fn((_p: string): string => "not-json{{{"); + const { logger, warn } = makeLoggerSpy(); + const getEnv = (k: string): string | undefined => + k === "COMIS_GOOGLECHAT_TEST_JWKS" ? "/seam/garbage.jwks.json" : undefined; + + let verify: ReturnType | undefined; + expect(() => { + verify = resolveTestGoogleChatVerifier( + { audienceType: "app-url", audience: "https://example.com/app/" }, + getEnv, + { readFileImpl, logger }, + ); + }).not.toThrow(); + + expect(typeof verify).toBe("function"); + expect((await verify!(undefined)).ok).toBe(false); + expect(warn).toHaveBeenCalledTimes(1); + const warnArg = warn.mock.calls[0]?.[0] as Record; + expect(warnArg).toMatchObject({ channelType: "googlechat", errorKind: "config" }); + expect(String(warnArg.hint)).toContain("COMIS_GOOGLECHAT_TEST_JWKS"); + }); +}); From 424e6bc5cd6d9f5cef03ccf66a91ec0a42b25bb3 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 12:01:24 +0300 Subject: [PATCH 148/200] fix(237): guard the googlechat test-seam JWKS read against a bad file JSON.parse(readFileSync(...)) threw synchronously on a missing/unreadable/ malformed JWKS file, and the resolver runs inside the unwrapped bootstrapAdapters, so the throw failed daemon boot. Wrap the read + parse: on failure, warn (config errorKind) naming COMIS_GOOGLECHAT_TEST_JWKS and fall closed to the production remote-JWKS verifier. The seam is off-by-default and test-only, so a bad file must degrade to production, never crash boot. --- .../src/wiring/googlechat-test-seams.ts | 30 +++++++++++++++++-- 1 file changed, 27 insertions(+), 3 deletions(-) diff --git a/packages/daemon/src/wiring/googlechat-test-seams.ts b/packages/daemon/src/wiring/googlechat-test-seams.ts index aa7d733c2..0784a2ac5 100644 --- a/packages/daemon/src/wiring/googlechat-test-seams.ts +++ b/packages/daemon/src/wiring/googlechat-test-seams.ts @@ -76,9 +76,33 @@ export function resolveTestGoogleChatVerifier( const readFileImpl = // eslint-disable-next-line security/detect-non-literal-fs-filename -- jwksPath is an operator-set test-only env var, not user input deps?.readFileImpl ?? ((path: string) => readFileSync(path, "utf8")); - const jwks = JSON.parse(readFileImpl(jwksPath)) as Parameters< - typeof createLocalGoogleChatInboundVerifier - >[0]; + // The read + parse can throw (ENOENT/EACCES from the read, SyntaxError from a + // malformed file). This resolver runs inside the unwrapped bootstrapAdapters, + // so an uncaught throw would fail daemon boot. Contain it: warn (config) naming + // the env var and fall closed to the production remote-JWKS verifier. The seam + // is off-by-default and test-only, so a bad file must degrade to production, not + // crash — the very misconfiguration the activation WARN below warns against. + let jwks: Parameters[0]; + try { + jwks = JSON.parse(readFileImpl(jwksPath)) as Parameters< + typeof createLocalGoogleChatInboundVerifier + >[0]; + } catch (readErr) { + deps?.logger?.warn( + { + channelType: "googlechat" as const, + err: readErr, + hint: "COMIS_GOOGLECHAT_TEST_JWKS is set but its JWKS file could not be read or parsed — falling back to the production remote-JWKS verifier; unset it in production", + errorKind: "config" as const, + }, + "Google Chat test JWKS unreadable; falling back to the production remote-JWKS verifier", + ); + return createGoogleChatInboundVerifier({ + audienceType: cfg.audienceType, + audience: cfg.audience, + ...(deps?.logger ? { logger: deps.logger } : {}), + }); + } const issuer = getEnv(GOOGLECHAT_TEST_ISSUER_ENV); const verifier = createLocalGoogleChatInboundVerifier(jwks, { audienceType: cfg.audienceType, From 03e1e041a6541dfd547f4c8afeefd7e6b148b9fc Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 12:03:15 +0300 Subject: [PATCH 149/200] refactor(237): derive the liveness webhook-channel set from a single typed source The monitor hand-listed { msteams, googlechat } for the arm decision and the poll cadence, and separately cast channelType to that union in the per-channel threshold lookup. A future third webhook channel would be watched (it reaches checkOnce via adaptersByType) yet silently omitted from both the arm decision and the tightest-window computation. Consolidate to one WEBHOOK_CHANNEL_TYPES tuple + a typed, total webhookChannelConfig lookup so the cast and the duplicated literal are gone; msteams + googlechat behavior is unchanged. --- .../wiring/setup-channel-liveness-monitor.ts | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/packages/daemon/src/wiring/setup-channel-liveness-monitor.ts b/packages/daemon/src/wiring/setup-channel-liveness-monitor.ts index d0f3b8398..159b2b646 100644 --- a/packages/daemon/src/wiring/setup-channel-liveness-monitor.ts +++ b/packages/daemon/src/wiring/setup-channel-liveness-monitor.ts @@ -37,6 +37,20 @@ const MAX_LIVENESS_CHECK_INTERVAL_MS = 900_000; // 15 minutes */ const DEFAULT_MISSED_INBOUND_MS = 21_600_000; // 6 hours +/** + * The channel types that can run in webhook mode. Single source of truth for the + * arm decision, the poll cadence, and the per-channel threshold lookup — so a + * future third webhook channel is added here once, never hand-synced across + * duplicated literal lists plus a `channelType as ...` cast. + */ +const WEBHOOK_CHANNEL_TYPES = ["msteams", "googlechat"] as const; +type WebhookChannelType = (typeof WEBHOOK_CHANNEL_TYPES)[number]; + +/** Narrow an arbitrary channelType to a known webhook channel (no cast). */ +function isWebhookChannelType(channelType: string): channelType is WebhookChannelType { + return (WEBHOOK_CHANNEL_TYPES as readonly string[]).includes(channelType); +} + /** The handle returned to the composition root; `stop()` cancels the timer. */ export interface ChannelLivenessMonitor { stop(): void; @@ -57,14 +71,18 @@ export function setupChannelLivenessMonitor(deps: { const { adaptersByType, daemonLogger, container, timer } = deps; const now = deps.now ?? systemNowMs; + // Total, typed lookup of a webhook channel's liveness config by channelType. + // Returns undefined for any non-webhook (or unknown) type — no cast; a future + // third webhook channel is picked up here via WEBHOOK_CHANNEL_TYPES. + const webhookChannelConfig = ( + channelType: string, + ): { enabled?: boolean; missedInboundThresholdMs?: number } | undefined => + isWebhookChannelType(channelType) ? container.config.channels?.[channelType] : undefined; + // Arm when ANY webhook-capable channel is enabled. Each such channel carries // its own missed-inbound window at the same config path, resolved per adapter // below; a disabled channel contributes nothing to watch. - const webhookConfigs = { - msteams: container.config.channels?.msteams, - googlechat: container.config.channels?.googlechat, - }; - if (!Object.values(webhookConfigs).some((c) => c?.enabled)) { + if (!WEBHOOK_CHANNEL_TYPES.some((t) => webhookChannelConfig(t)?.enabled)) { return { monitor: undefined, stop: undefined }; } @@ -106,10 +124,10 @@ export function setupChannelLivenessMonitor(deps: { if (status?.connectionMode !== "webhook") continue; // Resolve the window from this adapter's own channel config, so each - // webhook channel alerts on its own threshold. + // webhook channel alerts on its own threshold (typed, total — no cast). const thresholdMs = - container.config.channels?.[channelType as "msteams" | "googlechat"] - ?.missedInboundThresholdMs ?? DEFAULT_MISSED_INBOUND_MS; + webhookChannelConfig(channelType)?.missedInboundThresholdMs ?? + DEFAULT_MISSED_INBOUND_MS; const lastInboundAt = status.lastInboundAt ?? null; const baselineMs = lastInboundAt ?? daemonStartMs; @@ -145,9 +163,9 @@ export function setupChannelLivenessMonitor(deps: { // Poll on the tightest enabled window (so the smallest threshold still // detects on time), capped so detection latency stays bounded. - const enabledThresholds = Object.values(webhookConfigs) - .filter((c) => c?.enabled) - .map((c) => c?.missedInboundThresholdMs ?? DEFAULT_MISSED_INBOUND_MS); + const enabledThresholds = WEBHOOK_CHANNEL_TYPES.filter( + (t) => webhookChannelConfig(t)?.enabled, + ).map((t) => webhookChannelConfig(t)?.missedInboundThresholdMs ?? DEFAULT_MISSED_INBOUND_MS); const checkIntervalMs = Math.min(...enabledThresholds, MAX_LIVENESS_CHECK_INTERVAL_MS); const handle = timer.setInterval(checkOnce, checkIntervalMs); handle.unref(); From 5a4bac5b93f23ad366241616acddc7f74b38acb0 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 12:08:02 +0300 Subject: [PATCH 150/200] fix(237): state the absent-mode default without version framing Reword the mode-gate comment to state the behavior directly; drop the version-framing phrasing the no-backward-compat architecture gate rejects. --- packages/channels/src/googlechat/credential-validator.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/channels/src/googlechat/credential-validator.ts b/packages/channels/src/googlechat/credential-validator.ts index cb0f192b8..40f901fec 100644 --- a/packages/channels/src/googlechat/credential-validator.ts +++ b/packages/channels/src/googlechat/credential-validator.ts @@ -97,7 +97,7 @@ export function validateGoogleChatCredentials( // ingress (no pull loop), so it needs no subscription; it instead needs the // audience the inbound Bearer-JWT verifier binds to. Failing fast here means an // unset audience is a boot config error, never a per-request auth-reject flood - // at the ingress. An absent mode is treated as pubsub (backward compatible). + // at the ingress. An absent mode is treated as pubsub. if (opts.mode === "webhook") { if (isBlank(opts.audience)) { return err( From 546206eaabcd051cf812fd32dc804a602dbf84fd Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 13:59:34 +0300 Subject: [PATCH 151/200] docs(238-01): add the Google Chat channel runbook - Author docs/channels/googlechat.mdx on the msteams runbook skeleton: prerequisites, public-endpoint (webhook only), setup, per-mode auth, configuration + sender-allowlist email caveat, media, liveness, live-smoke checklist, local-emulator seams, capability matrix, platform limits, troubleshooting, later additions. - pubsub-default framing (no public IP); webhook opt-in at /channels/googlechat. - Local-emulator section names the inbound JWKS/ISSUER verify seams and the outbound COMIS_GOOGLECHAT_TEST_API egress redirect. - Every credential example uses a ${GOOGLECHAT_SA_KEY} ref; no key bytes. --- docs/channels/googlechat.mdx | 454 +++++++++++++++++++++++++++++++++++ 1 file changed, 454 insertions(+) create mode 100644 docs/channels/googlechat.mdx diff --git a/docs/channels/googlechat.mdx b/docs/channels/googlechat.mdx new file mode 100644 index 000000000..777c8a962 --- /dev/null +++ b/docs/channels/googlechat.mdx @@ -0,0 +1,454 @@ +--- +title: "Google Chat" +description: "Connect Comis to Google Chat with step-by-step setup" +icon: "google" +--- + +import Prereqs from '/snippets/prereqs.mdx'; +import SecretWarning from '/snippets/secret-warning.mdx'; +import NonTechnicalNote from '/snippets/non-technical-note.mdx'; + +Connect your Comis agent to Google Chat -- spaces, direct messages, and space +threads, with Cards v2 buttons, edit/delete, and inbound media. Google Chat is +the one channel that needs **no public IP by default**: the `pubsub` transport +pulls inbound events from a Cloud Pub/Sub subscription, so your Comis daemon +reaches out to Google rather than the other way around. An opt-in `webhook` mode +is available when you would rather Google push events to a public HTTPS endpoint. +This page walks you through creating the Chat app, wiring the transport, and +configuring authentication. + + + +## Prerequisites + + + +- A Google Cloud project with the **Google Chat API** and the **Cloud Pub/Sub + API** enabled +- A **service account** in that project, with its key JSON downloaded -- this is + the only credential Comis needs; there is no bot token +- For the default `pubsub` transport: a Pub/Sub **topic** and a **pull + subscription** (see [Setup](#setup)) +- For the opt-in `webhook` transport only: a public HTTPS URL that reaches your + Comis gateway (see [Public endpoint](#public-endpoint)) + +## Public endpoint + + +This section applies **only** to the opt-in `mode: webhook`. In the default +`mode: pubsub`, Comis pulls events from a Pub/Sub subscription and needs **no +public endpoint at all** -- skip ahead to [Setup](#setup). + + +In `webhook` mode, Google delivers each event as an authenticated HTTPS POST to +`/channels/googlechat` on your gateway, so the gateway must be reachable from the +internet at `https://your-host/channels/googlechat`. The route rides your +gateway's existing host, port, and TLS surface (`gateway.host` / `gateway.port`) +and is mounted **only** when `channels.googlechat.enabled` is `true` **and** +`mode` is `webhook`; in `pubsub` mode, or while the channel is disabled, no +inbound route exists at all. Every inbound request is Bearer-JWT-verified before +it reaches your agent, so an unauthenticated POST is rejected regardless of how +you expose the endpoint. + +You have several ways to make the gateway publicly reachable. They are listed +most-hardened first; **all** of them see the same inbound token verification, so +the choice is about network exposure, not authentication. + + + + Terminate TLS at a reverse proxy (Nginx, Caddy) in front of the gateway and + forward `/channels/googlechat` to it. This gives you full control over + certificates, headers, and rate limiting. See + [Reverse proxy](/operations/reverse-proxy) for a worked Nginx and Caddy + configuration. + + + + [Tailscale Funnel](https://tailscale.com/kb/1223/funnel) publishes a local + port to the public internet over HTTPS with an auto-provisioned certificate + and a stable `*.ts.net` hostname -- without forwarding a port on your router + or firewall. + + ```bash + tailscale funnel 4766 + ``` + + Your endpoint becomes + `https://your-machine.your-tailnet.ts.net/channels/googlechat`. Funnel + handles the certificate; Comis still verifies every inbound request. + + + + For local testing, a tunnel such as [ngrok](https://ngrok.com) gives you a + temporary public HTTPS URL that forwards to your gateway port. Use the tunnel + URL as the endpoint while you iterate; move to a reverse proxy or Funnel + before you run in production. + + + +## Setup + +Google Chat needs a Google Cloud project with a service account (the app's +credential) and a Chat app configuration that selects a transport. The default +`pubsub` transport also needs a Pub/Sub topic and a pull subscription. + + + + In the [Google Cloud console](https://console.cloud.google.com), create (or + pick) a project and enable the **Google Chat API** and the **Cloud Pub/Sub + API**. Note the project's **number** -- you will need it for `webhook` mode's + `project-number` audience. + + + + Create a service account in the project and download its **key JSON**. This + single credential lets Comis mint the tokens it needs to call the Chat API; + there is no separate bot token. Keep the key file safe -- it is a secret. + + + + + + On the Chat API **Configuration** page, set the app name and avatar, then + pick how inbound events are delivered: + + + + 1. Create a Pub/Sub **topic** and a **pull subscription** on it + (`projects/{project}/subscriptions/{sub}`). + 2. Grant the **Chat service account** + (`chat@system.gserviceaccount.com`) the **Pub/Sub Publisher** role on + the topic, so Google can publish inbound events to it. + 3. Grant **your** service account the `roles/pubsub.subscriber` role on + the subscription, so Comis can pull events. + 4. In the Chat app's connection settings, select **Cloud Pub/Sub** and + point it at your topic. + + No public IP or gateway is required -- Comis pulls from the subscription. + + + + 1. Expose your gateway over HTTPS (see + [Public endpoint](#public-endpoint)). + 2. In the Chat app's connection settings, select **HTTP endpoint URL** + and set it to `https://your-host/channels/googlechat`. + 3. Note whether Google mints the token with your **project number** or + your **endpoint URL** as the audience -- this maps to `audienceType` + in [Authentication](#authentication). + + + + + + Add the Google Chat channel to your Comis configuration file + (`~/.comis/config.yaml`). The default `pubsub` transport: + + ```yaml + channels: + googlechat: + enabled: true + serviceAccountKey: "${GOOGLECHAT_SA_KEY}" + subscriptionName: "projects/my-project/subscriptions/comis-chat" + allowFrom: ["users/1234567890"] + ``` + + Set the service-account key in your `~/.comis/.env` file (the full key JSON + on one line): + + ```bash + GOOGLECHAT_SA_KEY={"type":"service_account", ... } + ``` + + + + + + Restart the Comis daemon to pick up the new configuration: + + ```bash + comis daemon stop && comis daemon start + ``` + + Check that the Google Chat probes pass: + + ```bash + comis doctor + ``` + + Then message the bot from a Google Chat space or direct message and confirm + it replies. + + + +## Authentication + +Google Chat authenticates with a **service-account key** -- the same credential +in both transports. The key is the only secret; keep it in `~/.comis/.env` as +`GOOGLECHAT_SA_KEY` or supply a `SecretRef`. What differs between the modes is how +**inbound** events are trusted. + + + + Comis pulls events from your Pub/Sub subscription, so inbound trust comes + from the subscription's IAM -- there is no public endpoint and no inbound + token verification to configure. + + ```yaml + channels: + googlechat: + enabled: true + serviceAccountKey: "${GOOGLECHAT_SA_KEY}" + subscriptionName: "projects/my-project/subscriptions/comis-chat" + ``` + + + + Google POSTs events to your endpoint with a Bearer JWT, and Comis verifies + every one before processing. Set `audienceType` to match how Google mints + the token: + + - **`project-number`** -- the token's `aud` is your Google Cloud project + number, issued by `chat@system.gserviceaccount.com`. Set `audience` to the + project number. + - **`app-url`** -- the token's `aud` is your endpoint URL, a Google OIDC ID + token bound to the Chat sender. Set `audience` to the endpoint URL. + + ```yaml + channels: + googlechat: + enabled: true + mode: webhook + serviceAccountKey: "${GOOGLECHAT_SA_KEY}" + audienceType: project-number + audience: "1234567890" # your Google Cloud project number + ``` + + Webhook mode also needs `gateway.enabled: true` and a public URL. + + + +## Configuration + +The full option table -- all fields, types, and defaults -- lives in the +[Configuration reference](/reference/config-yaml#channels). The service-account +key environment variable is documented in +[Environment variables](/reference/environment-variables#googlechat_sa_key). This +section covers the one option that carries a security consequence worth spelling +out. + +### Sender allowlist and the email caveat + +`allowFrom` restricts who can talk to the bot. When it is non-empty (and +`allowMode` is the default `allowlist`), only listed senders reach the agent. +Each entry is a Google Chat resource id -- a `users/{id}` for one person or a +`spaces/{id}` for a whole space. + + +Prefer the **immutable** `users/{id}` form. A human-readable, email-shaped id is +mutable and spoofable -- a display value can change or be impersonated, so +allowlisting one is weaker than pinning the stable numeric `users/{id}`. Approval +authority on Cards v2 approval cards is derived from the verified sender of the +click, so a loose allowlist entry effectively widens who can press **Approve**. +Comis emits a boot-time WARN when it sees an email-shaped `allowFrom` entry. + + +## Media + +Inbound attachments (images, documents, voice, video) are downloaded +**exclusively** through the Chat API's `attachmentDataRef.resourceName` via +`media.download`, host-pinned to `chat.googleapis.com`, and MIME-checked before +the media pipeline (transcription, vision, document extraction) sees them. + +A file shared through the Drive picker arrives **without** a `resourceName` and +degrades with an honest WARN: downloading Drive-hosted content needs a user-OAuth +credential plus the Drive scope, which the service-account app does not have. + + +Google Chat media is **inbound only**. Uploading files or videos into a space is +a user-auth-only method and a later addition; today the agent replies with text +and Cards v2 messages. + + +## Liveness + +In `pubsub` mode the pull loop self-heals: it retries with bounded, jittered +backoff and de-duplicates redelivered events, so a transient Pub/Sub error +recovers without operator action. + +In `webhook` mode the channel is exempt from the health monitor's stale-reap -- +because Google pushes events over a connection Comis cannot watch, a mis-wired or +silently broken endpoint would otherwise report healthy forever, and the only +symptom would be a bot that quietly stops receiving messages. Two guards close +that gap: + +- **`comis doctor`** runs the Google Chat (`googlechat-health`) probes: the + service-account key parses and resolves; the Pub/Sub subscription is reachable + (in `pubsub` mode -- a failure names the `roles/pubsub.subscriber` grant) or + the ingress endpoint is reachable (in `webhook` mode); an inbound event has + arrived recently; and `allowFrom` entries are immutable `users/{id}` ids rather + than email-shaped. The reachability and recent-inbound probes skip (rather than + fail) when the daemon is down. +- A **missed-inbound alert** compares the time since the last inbound event to + `missedInboundThresholdMs` (default **6 hours**). On breach it emits a + `channel:inbound_silent` event and a WARN that surfaces as a `comis fleet` + health signal. Lower the threshold for a busy space where a few hours of + silence is itself a red flag. + +## Live-smoke checklist + +The build gates cover the adapter, mapper, renderer, auth, Pub/Sub source, and +gateway ingress. The end-to-end sign-off needs a real Chat app and a Google Cloud +project, so run this operator checklist once against a live account: + +- [ ] Text round-trips in a space and in a direct message +- [ ] A threaded reply lands on the original message's thread; the bot edits and + deletes its own messages +- [ ] A Cards v2 card renders, and clicking **Approve** on an approval card is + authorized to the clicking user -- a non-allowlisted user cannot approve +- [ ] An inbound image and an inbound document each resolve through the media + pipeline +- [ ] Both `pubsub` and `webhook` modes start cleanly +- [ ] `comis doctor` shows the Google Chat (`googlechat-health`) probes green; in + webhook mode the missed-inbound alert fires after the configured silence + window + +## Local emulator (self-drive testing) + +The live-smoke checklist above needs a real Chat app. For an offline, no-Google +round trip -- and for the self-driving live-test rig -- an in-tree emulator plays +the Google side (a fake Chat REST API + Pub/Sub pull endpoint + a JWKS signer for +webhook tokens). It lives at `test/live/emulators/googlechat/` and is driven from +`test/live/self-driving/`. + +Google Chat has both inbound and outbound loopback bridges, and all are +**off-by-default environment variables set only on the test daemon** -- never in +production: + +| Env var | Bridges | +|---------|---------| +| [`COMIS_GOOGLECHAT_TEST_JWKS`](/reference/environment-variables#comis_googlechat_test_jwks) | The webhook ingress verifies inbound event tokens against the emulator's local JWKS (a full signature/issuer/audience verify -- not a bypass) instead of Google's remote keys. | +| [`COMIS_GOOGLECHAT_TEST_ISSUER`](/reference/environment-variables#comis_googlechat_test_issuer) | Optional issuer override for a fully-synthetic emulator key set. | +| [`COMIS_GOOGLECHAT_TEST_API`](/reference/environment-variables#comis_googlechat_test_api) | The daemon's outbound egress -- the Chat REST API, the Pub/Sub pull endpoint, and the OAuth token endpoint -- is redirected to the loopback emulator. | + + +With these variables unset, the daemon behaves identically to a normal Google +Chat deployment. Setting them on a production daemon would point inbound trust +and outbound delivery at a local process -- they are strictly for the live-test +rig. + + +Wiring (as the operator, on the test box): + +```bash +# 1. Start the emulator (prints the exact daemonEnv to set). +tsx test/live/bin/vps-emu-googlechat.ts + +# 2. Enable channels.googlechat in config.yaml (allowMode: open), then boot the +# daemon with the seams from the emulator's printed daemonEnv: +COMIS_GOOGLECHAT_TEST_JWKS=/tmp/comis-googlechat-jwks.json \ +COMIS_GOOGLECHAT_TEST_API=http://127.0.0.1:53998 \ + node packages/daemon/dist/daemon.js + +# 3. Drive an inbound turn (pubsub mode injects into the fake pull queue; webhook +# mode signs a Bearer and POSTs to the ingress), then poll the Chat-API oracle +# for the agent reply: +node test/live/self-driving/scripts/googlechat-drive.mjs --mode pubsub "spaces/AAAA" "hello google chat" +``` + +The whole wire stack is also proven offline (no daemon) by the round-trip +scenario at `test/live/scenarios/channels/googlechat-emulator.test.ts`, which +pushes a signed event through the real ingress and adapter into the emulator's +oracle. + +## What your agent can do + +Once connected to Google Chat, your agent can: + +- Send, edit, and delete messages in spaces and direct messages +- Reply in a space thread (threaded on the original message) +- Send Cards v2 messages with interactive buttons, including default-deny + approval cards for privileged actions +- Receive inbound images, documents, and other attachments (routed through the + media pipeline) +- Detect when it is `@mentioned` in a space + +Google Chat does **not** support the agent *sending* reactions, uploading files +or videos, fetching un-approved message history, showing a typing indicator, or +token-by-token streaming. Those methods are user-auth-only or admin-approval-only +on this platform -- see [Later additions](#later-additions). + +## Platform limits + +| Limit | Value | What Comis does about it | +|---|---|---| +| Message length | 4,000 characters (32,000-byte platform cap) | Splits long responses into multiple messages at paragraph boundaries | +| Send rate | 1 message/second per space | Paces chunked replies per space to stay within the limit | +| Outbound attachments | None (inbound media only) | The agent replies with text and Cards v2 messages; file upload is a later addition | +| Space activation | Unmentioned messages are not delivered in multi-person spaces | The app receives space messages only when `@mentioned` or slash-commanded; a `groupActivation: "always"` setting is inert here | + +## Troubleshooting + + + + **What happened:** Comis cannot pull from the Pub/Sub subscription, or Google + is not publishing to the topic. + + **How to fix it:** Grant your service account the `roles/pubsub.subscriber` + role on the subscription, and confirm `subscriptionName` is the full + `projects/{project}/subscriptions/{sub}` path. Verify the Chat app's Pub/Sub + connection publishes to the topic that subscription is attached to, and that + the Chat service account has the Publisher role on the topic. + + + + **What happened:** Bearer-JWT verification failed -- usually an `audienceType` + / `audience` mismatch. + + **How to fix it:** Match `audienceType` to how Google mints the token: + `project-number` (`aud` is your project number, issuer + `chat@system.gserviceaccount.com`) or `app-url` (`aud` is your endpoint URL). + Set `audience` to the matching value, and confirm `gateway.enabled` is `true` + and the public URL resolves to `/channels/googlechat`. + + + + **What happened:** The `serviceAccountKey` value is not the full + service-account key JSON. + + **How to fix it:** Supply the complete key JSON (it must contain the + `client_email` and the private-key material), not a file path or a partial + copy. If you use a `${GOOGLECHAT_SA_KEY}` reference, confirm the variable is + set in `~/.comis/.env`. Comis never prints the key -- the doctor probe reports + only whether it parsed. + + + +## Later additions + +The Google Chat channel focuses on chat, Cards v2, threaded replies, edit/delete, +and inbound media. These are documented as not-yet-shipped so you can plan around +them: + +- Uploading files and videos into a space (needs a user-OAuth credential -- the + service-account app cannot upload) +- Sending and receiving reactions (user-auth-only on this platform) +- Fetching un-approved message history (needs admin approval and the read-only + scope) +- A Google Workspace Events event source (the path to inbound reactions and + listening to every space message) +- Typing indicators and token-by-token streaming + + + + Compare all 11 supported platforms side by side. + + + Every `channels.googlechat` option, type, and default. + + + The `GOOGLECHAT_SA_KEY` secret and the Google Chat live-test seams. + + + Learn how to manage API keys and tokens securely. + + From 922364cd8386f3ba68539a7da4d043e0d370f12d Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 14:01:18 +0300 Subject: [PATCH 152/200] docs(238-01): reconcile the Google Chat reference docs + cross-link the runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - config-yaml: add a runbook cross-link beside the googlechat block (the block's keys already match GoogleChatChannelEntrySchema — no drift). - environment-variables: rewrite the stale live-test-seams disclaimer to describe the real outbound-egress seam; add a COMIS_GOOGLECHAT_TEST_API subsection (Chat/Pub-Sub/token egress redirect, off-by-default, WARN on activation); add a GOOGLECHAT_SA_KEY anchor; cross-link the runbook. - defense-in-depth: point the Google Chat Public Endpoint accordion at the runbook (parity with the Teams accordion). --- docs/reference/config-yaml.mdx | 2 + docs/reference/environment-variables.mdx | 48 ++++++++++++++++++------ docs/security/defense-in-depth.mdx | 4 +- 3 files changed, 40 insertions(+), 14 deletions(-) diff --git a/docs/reference/config-yaml.mdx b/docs/reference/config-yaml.mdx index b396c88a5..94bdaa5e3 100644 --- a/docs/reference/config-yaml.mdx +++ b/docs/reference/config-yaml.mdx @@ -1200,6 +1200,8 @@ channels: allowFrom: ["users/1234567890"] ``` +See the [Google Chat setup guide](/channels/googlechat) for the full runbook -- Pub/Sub vs. webhook setup, the sender-allowlist email caveat, media, liveness, and the live-smoke checklist. + See the [Channels](/channels/index) section for platform-specific setup guides. diff --git a/docs/reference/environment-variables.mdx b/docs/reference/environment-variables.mdx index ed4faea2e..c8c59dc98 100644 --- a/docs/reference/environment-variables.mdx +++ b/docs/reference/environment-variables.mdx @@ -308,15 +308,16 @@ export COMIS_MSTEAMS_TEST_CONNECTOR=http://127.0.0.1:53999 ### Google Chat live-test seams (test-only) -These variables exist ONLY to point the Google Chat webhook ingress at a local -loopback emulator for live testing. They are **unset in production** — with them -unset the daemon verifies inbound events against Google's live signing keys, -byte-identically to a normal Google Chat webhook deployment. **Never set them on -a production daemon.** They do not relax a security control: the seam swaps in a -full local-JWKS signature/issuer/audience verify, never a bypass. Google Chat has -no outbound connector-redirect seam — its egress base URLs (`tokenUrl`, -`chatBaseUrl`, `pubsubBaseUrl`) are already injectable via config, so there is no -`COMIS_GOOGLECHAT_TEST_CONNECTOR`. +These variables exist ONLY to point the Google Chat daemon at a local loopback +emulator for live testing (see the [Google Chat setup guide](/channels/googlechat)). +They are **unset in production** — with them unset the daemon verifies inbound +events against Google's live signing keys and sends over Google's real Chat, +Pub/Sub, and token endpoints, byte-identically to a normal Google Chat +deployment. **Never set them on a production daemon.** They do not relax a +security control: `COMIS_GOOGLECHAT_TEST_JWKS` swaps in a full local-JWKS +signature/issuer/audience verify (never a bypass), and `COMIS_GOOGLECHAT_TEST_API` +only redirects outbound egress to a loopback address. Activating either emits a +content-free WARN so a stray production setting is loud in the logs. #### `COMIS_GOOGLECHAT_TEST_JWKS` @@ -358,6 +359,26 @@ Source: `packages/daemon/src/wiring/googlechat-test-seams.ts` export COMIS_GOOGLECHAT_TEST_ISSUER=https://emulator.test/issuer ``` +#### `COMIS_GOOGLECHAT_TEST_API` + +| Property | Value | +|----------|-------| +| **Used by** | Daemon (googlechat outbound egress) | +| **Format** | Loopback base URL (e.g. `http://127.0.0.1:PORT`) | +| **Default** | (unset) — Google's real Chat, Pub/Sub, and OAuth token endpoints | + +When set, the daemon redirects its Google Chat outbound egress — the Chat REST +API, the Pub/Sub pull endpoint, and the OAuth token endpoint — to the loopback +emulator at this base URL, so a live-test rig can observe and answer the bot's +calls without reaching Google. Off by default and test-only; activating it emits +a content-free WARN, and it is never a production knob. + +Source: `packages/daemon/src/wiring/googlechat-test-seams.ts` + +```bash +export COMIS_GOOGLECHAT_TEST_API=http://127.0.0.1:53998 +``` + ## Secret Variables ### `SECRETS_MASTER_KEY` @@ -732,9 +753,12 @@ The bot app id (`channels.msteams.appId`) and directory/tenant id (`channels.mst ### Google Chat -| Variable | Purpose | -|----------|---------| -| `GOOGLECHAT_SA_KEY` | Service-account key JSON for the Chat app — the fallback source for `channels.googlechat.serviceAccountKey` (also settable inline in config or as a SecretRef). A secret; never commit it in plaintext config. | +#### `GOOGLECHAT_SA_KEY` + +Service-account key JSON for the Chat app — the fallback source for +`channels.googlechat.serviceAccountKey` (also settable inline in config or as a +SecretRef). A secret; never commit it in plaintext config. See the +[Google Chat setup guide](/channels/googlechat) for how to obtain it. ### IRC diff --git a/docs/security/defense-in-depth.mdx b/docs/security/defense-in-depth.mdx index c4c913e47..4653d2179 100644 --- a/docs/security/defense-in-depth.mdx +++ b/docs/security/defense-in-depth.mdx @@ -141,8 +141,8 @@ the input checkpoint. keep `channels.googlechat.enabled` off until the endpoint is wired, and rely on the missed-inbound alert (`missedInboundThresholdMs`, default 6 hours) to catch a dead ingress -- it surfaces as a `comis fleet` health signal. See the - [configuration reference](/reference/config-yaml) for the - `channels.googlechat` keys. + [Google Chat setup guide](/channels/googlechat) for the full exposure and + liveness walkthrough. From 305be3af9cbb6254b306ac9ac91abb43414864f8 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 14:14:14 +0300 Subject: [PATCH 153/200] docs(238-02): add Google Chat to channel index, media, faq, and packages enumerations - channels/index.mdx: Google Chat rows in the capability, features, and limits tables (inbound-only media, no reactions, space threads, Cards v2), a Google Workspace use-case bullet, and a CardGroup cross-link - media/index.mdx: per-channel media row (inbound images/voice/video/documents, no voice-out) and Cards v2 in the buttons list - operations/faq.mdx: supported-platform count 10->11 and a Google Chat bullet - developer-guide/packages.mdx: platform-adapter count 11->12 (includes Echo) with Google Chat added to the enumeration --- docs/channels/index.mdx | 14 +++++++++++++- docs/developer-guide/packages.mdx | 4 ++-- docs/media/index.mdx | 9 +++++---- docs/operations/faq.mdx | 3 ++- 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/channels/index.mdx b/docs/channels/index.mdx index 7008109a5..6b406f64b 100644 --- a/docs/channels/index.mdx +++ b/docs/channels/index.mdx @@ -1,6 +1,6 @@ --- title: "Channels" -description: "Connect Comis to Discord, Telegram, Slack, WhatsApp, Signal, iMessage, LINE, IRC, Email, and Microsoft Teams" +description: "Connect Comis to Discord, Telegram, Slack, WhatsApp, Signal, iMessage, LINE, IRC, Email, Microsoft Teams, and Google Chat" icon: "message-dots" --- @@ -25,6 +25,7 @@ Not sure which channel to connect first? Here's a quick comparison to help you c | IRC | Yes | No | No | No | None | No | No | IRC control codes (bold, color) | | Email | Yes | Yes (attachment) | Yes | No | Reply chain | No | No | HTML | | Microsoft Teams | Yes | Via attachment | Yes | Inbound only | Channel threads | Yes | Yes | Adaptive Cards | +| Google Chat | Yes | Inbound only | Inbound only | No | Space threads | Yes | Yes | Cards v2 | Discord and Telegram have the broadest feature support -- they're great choices if you're just getting started. @@ -72,6 +73,7 @@ for your use case, or to understand the differences when connecting multiple pla | [IRC](/channels/irc) | DM, channel | None | No | No | No / No | No | IRC control codes | | [Email](/channels/email) | DM | None | Reply chain (RFC 5322) | No | No / No | No | HTML | | [Microsoft Teams](/channels/msteams) | DM, group, channel, thread | Edit | Channel threads | Inbound | Yes / Yes | Yes | Adaptive Cards | +| [Google Chat](/channels/googlechat) | space, DM, thread | Edit | Space threads | No | Yes / Yes | Yes | Cards v2 | ### Limits @@ -87,6 +89,7 @@ for your use case, or to understand the differences when connecting multiple pla | [IRC](/channels/irc) | 512 characters | N/A | | [Email](/channels/email) | 100,000 characters | N/A | | [Microsoft Teams](/channels/msteams) | 28,000 characters | Inline images | +| [Google Chat](/channels/googlechat) | 4,000 characters | Inbound only | **Reading the table:** "Edit / Delete" shows whether Comis can edit its own messages @@ -164,6 +167,12 @@ Not sure which channel to start with? Here are some common scenarios: and approval workflows. Teams requires a public messaging endpoint -- the setup guide covers exposing it safely. +- **Google Workspace** -- Use [Google Chat](/channels/googlechat). Your agent + works in spaces, direct messages, and threads with Cards v2 buttons and + approval workflows. Google Chat needs no public IP by default -- the daemon + pulls events over Cloud Pub/Sub, with an opt-in webhook mode when you prefer a + public endpoint. + You can always add more channels later. Your agent's memory and personality carry across all connected platforms. @@ -206,6 +215,9 @@ If you have not installed Comis yet, start with the [Quickstart](/get-started/qu 1:1, group, and channel chats with Adaptive Cards, threads, and edit/delete. + + Spaces, DMs, threads, and Cards v2 over Pub/Sub or webhook. + How streaming, typing indicators, retry logic, and message pacing work. diff --git a/docs/developer-guide/packages.mdx b/docs/developer-guide/packages.mdx index 94f3f933f..6e210d19c 100644 --- a/docs/developer-guide/packages.mdx +++ b/docs/developer-guide/packages.mdx @@ -20,7 +20,7 @@ All packages use ES modules (`"type": "module"`), strict TypeScript with `compos | skills | `@comis/skills` | Skill manifest, prompt skills, MCP, built-in tools, media processing | `SkillRegistry`, media preprocessor, STT/TTS/vision adapters | | scheduler | `@comis/scheduler` | Cron jobs, heartbeat checks, task extraction | Cron engine, heartbeat monitor, task extractor | | agent | `@comis/agent` | Executor, budget, circuit breaker, RAG, sessions, context engine | `PiExecutor`, session manager, budget tracker | -| channels | `@comis/channels` | 11 platform adapters (Discord, Telegram, Slack, WhatsApp, Signal, iMessage, LINE, IRC, Echo, Email, Microsoft Teams) | Channel adapters, message mappers, media resolvers | +| channels | `@comis/channels` | 12 platform adapters (Discord, Telegram, Slack, WhatsApp, Signal, iMessage, LINE, IRC, Echo, Email, Microsoft Teams, Google Chat) | Channel adapters, message mappers, media resolvers | | cli | `@comis/cli` | Commander.js CLI, JSON-RPC client | CLI commands, RPC client | | daemon | `@comis/daemon` | Orchestrator, observability, graph coordinator | `DaemonInstance`, wiring, graph coordinator | | observability | `@comis/observability` | Diagnostics substrate: queued writer, payload bounding, sanitization, cache-trace runtime, EventBus bridge, cache-stats aggregation and RPC | Diagnostics runtime, cache-trace recorder, stats aggregator | @@ -36,7 +36,7 @@ All packages use ES modules (`"type": "module"`), strict TypeScript with `compos **`@comis/agent`** is the execution engine. It contains the `PiExecutor` that orchestrates LLM calls, tool execution, and response generation. It also manages sessions (conversation state), budgets (token/cost limits), circuit breakers (automatic failure recovery), and RAG (retrieval-augmented generation from memory). It also contains the context engine -- a 10-layer pipeline that manages token budgets and conversation length before each AI call. -**`@comis/channels`** contains the 11 platform adapters. Each adapter lives in its own directory (e.g., `src/telegram/`, `src/discord/`) with a standard file set: adapter, message mapper, media handler, credential validator, media resolver, voice sender, and plugin wrapper. +**`@comis/channels`** contains the 12 platform adapters. Each adapter lives in its own directory (e.g., `src/telegram/`, `src/discord/`) with a standard file set: adapter, message mapper, media handler, credential validator, media resolver, voice sender, and plugin wrapper. **`@comis/daemon`** is the top-level orchestrator. It wires all packages together using the composition root, manages the process lifecycle (startup, shutdown, signal handling), runs the graph coordinator for multi-agent pipelines, and provides observability metrics. This is the only package that depends on all other packages. diff --git a/docs/media/index.mdx b/docs/media/index.mdx index 4abd9fd41..9ad4436a0 100644 --- a/docs/media/index.mdx +++ b/docs/media/index.mdx @@ -125,6 +125,7 @@ channel can actually receive and send. | IRC | No | No | No | No | No | No | | Email | Yes (inline/attachment) | Yes (audio attachment) | Yes (audio attachment) | Yes (attachment) | Yes (full MIME) | No | | Microsoft Teams | Yes | Yes | No | Yes | Yes | No | +| Google Chat | Yes | Yes | No | Yes | Yes | No | The "voice out" column reflects whether the channel adapter can deliver an audio reply when [auto-TTS](/media/voice#auto-reply-modes) is enabled. The @@ -154,10 +155,10 @@ agent gets consistent results no matter where the message originated. Some media features are only available on certain platforms. For example, -rich messages with buttons currently render on Discord, Telegram, Slack, and -Microsoft Teams (as Adaptive Cards); LINE and WhatsApp button rendering is not -yet wired through `sendMessage`, and Signal, iMessage, IRC, and Email do not -support buttons. See each +rich messages with buttons currently render on Discord, Telegram, Slack, +Microsoft Teams (as Adaptive Cards), and Google Chat (as Cards v2); LINE and +WhatsApp button rendering is not yet wired through `sendMessage`, and Signal, +iMessage, IRC, and Email do not support buttons. See each capability page for platform-specific details. diff --git a/docs/operations/faq.mdx b/docs/operations/faq.mdx index 28d647dc5..9e0c8038f 100644 --- a/docs/operations/faq.mdx +++ b/docs/operations/faq.mdx @@ -72,7 +72,7 @@ Answers to the most common questions about running and using Comis. - Comis supports 10 chat platforms: + Comis supports 11 chat platforms: - **Telegram** — full support including groups, inline keyboards, and media - **Discord** — servers, DMs, threads, reactions, and embeds @@ -84,6 +84,7 @@ Answers to the most common questions about running and using Comis. - **IRC** — any IRC network - **Email** — via IMAP/SMTP (any email provider) - **Microsoft Teams** — 1:1 chats, group chats, team channels/threads, Adaptive Cards + - **Google Chat** — spaces, direct messages, threads, and Cards v2 (no public IP needed by default via Cloud Pub/Sub) See the [Channels](/channels) overview for a detailed comparison of features and limitations across platforms. From 17a6d5c27427f709220c5ef731f7a9ae47705de2 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 14:16:09 +0300 Subject: [PATCH 154/200] docs(238-02): cross-link Google Chat and bump channel-count card 10->11 in every channel page - Insert a Google Chat cross-link Card in each channel page's footer CardGroup - Bump the 'Compare all 10 supported platforms' All-Channels card to 11 - Covers telegram, discord, slack, whatsapp, signal, imessage, line, irc, email, delivery, and msteams (googlechat.mdx already said 11 from 238-01) --- docs/channels/delivery.mdx | 5 ++++- docs/channels/discord.mdx | 5 ++++- docs/channels/email.mdx | 5 ++++- docs/channels/imessage.mdx | 5 ++++- docs/channels/irc.mdx | 5 ++++- docs/channels/line.mdx | 5 ++++- docs/channels/msteams.mdx | 5 ++++- docs/channels/signal.mdx | 5 ++++- docs/channels/slack.mdx | 5 ++++- docs/channels/telegram.mdx | 5 ++++- docs/channels/whatsapp.mdx | 5 ++++- 11 files changed, 44 insertions(+), 11 deletions(-) diff --git a/docs/channels/delivery.mdx b/docs/channels/delivery.mdx index befe025f6..9c8ee02ec 100644 --- a/docs/channels/delivery.mdx +++ b/docs/channels/delivery.mdx @@ -684,8 +684,11 @@ Each channel can override any of the default settings through the `perChannel` section. Options not specified in `perChannel` fall back to the defaults above. + + Spaces, DMs, threads, and Cards v2 over Pub/Sub or webhook. + - Compare all 10 supported platforms side by side. + Compare all 11 supported platforms side by side. Hook registration and plugin development. diff --git a/docs/channels/discord.mdx b/docs/channels/discord.mdx index 0ded593e7..d6689483b 100644 --- a/docs/channels/discord.mdx +++ b/docs/channels/discord.mdx @@ -277,8 +277,11 @@ A start-to-finish run for a fresh server bot named **MyTeamBot**: Run one install for a whole team - private per person, isolated per agent. + + Spaces, DMs, threads, and Cards v2 over Pub/Sub or webhook. + - Compare all 10 supported platforms side by side. + Compare all 11 supported platforms side by side. Set up your agent's personality, tools, and behavior. diff --git a/docs/channels/email.mdx b/docs/channels/email.mdx index 635e0d5b7..df575e055 100644 --- a/docs/channels/email.mdx +++ b/docs/channels/email.mdx @@ -337,8 +337,11 @@ A start-to-finish run for a Gmail mailbox `bot@your-team.com`: Run one install for a whole team - private per person, isolated per agent. + + Spaces, DMs, threads, and Cards v2 over Pub/Sub or webhook. + - Compare all 10 supported platforms side by side. + Compare all 11 supported platforms side by side. Set up your agent's personality, tools, and behavior. diff --git a/docs/channels/imessage.mdx b/docs/channels/imessage.mdx index 0b6867cbd..e129d41fa 100644 --- a/docs/channels/imessage.mdx +++ b/docs/channels/imessage.mdx @@ -161,8 +161,11 @@ rich text formatting, or `@mentions`. Run one install for a whole team - private per person, isolated per agent. + + Spaces, DMs, threads, and Cards v2 over Pub/Sub or webhook. + - Compare all 10 supported platforms side by side. + Compare all 11 supported platforms side by side. Set up your agent's personality, tools, and behavior. diff --git a/docs/channels/irc.mdx b/docs/channels/irc.mdx index 9672b23c2..82e93ba25 100644 --- a/docs/channels/irc.mdx +++ b/docs/channels/irc.mdx @@ -172,8 +172,11 @@ or embeds, streaming (live-updating responses), or native polls. Run one install for a whole team - private per person, isolated per agent. + + Spaces, DMs, threads, and Cards v2 over Pub/Sub or webhook. + - Compare all 10 supported platforms side by side. + Compare all 11 supported platforms side by side. Set up your agent's personality, tools, and behavior. diff --git a/docs/channels/line.mdx b/docs/channels/line.mdx index 9f84e0e8b..ab60dbb32 100644 --- a/docs/channels/line.mdx +++ b/docs/channels/line.mdx @@ -202,8 +202,11 @@ turns; this consumes from your monthly push-message quota. Run one install for a whole team - private per person, isolated per agent. + + Spaces, DMs, threads, and Cards v2 over Pub/Sub or webhook. + - Compare all 10 supported platforms side by side. + Compare all 11 supported platforms side by side. Set up your agent's personality, tools, and behavior. diff --git a/docs/channels/msteams.mdx b/docs/channels/msteams.mdx index 497050bd4..6559f7b42 100644 --- a/docs/channels/msteams.mdx +++ b/docs/channels/msteams.mdx @@ -453,8 +453,11 @@ around them: Run one install for a whole team - private per person, isolated per agent. + + Spaces, DMs, threads, and Cards v2 over Pub/Sub or webhook. + - Compare all 10 supported platforms side by side. + Compare all 11 supported platforms side by side. Set up your agent's personality, tools, and behavior. diff --git a/docs/channels/signal.mdx b/docs/channels/signal.mdx index 422b65c6b..4a6f646d1 100644 --- a/docs/channels/signal.mdx +++ b/docs/channels/signal.mdx @@ -198,8 +198,11 @@ fetching message history, buttons, cards/embeds, native polls, or mentions. Run one install for a whole team - private per person, isolated per agent. + + Spaces, DMs, threads, and Cards v2 over Pub/Sub or webhook. + - Compare all 10 supported platforms side by side. + Compare all 11 supported platforms side by side. Set up your agent's personality, tools, and behavior. diff --git a/docs/channels/slack.mdx b/docs/channels/slack.mdx index 0f86a5016..134fc049d 100644 --- a/docs/channels/slack.mdx +++ b/docs/channels/slack.mdx @@ -325,8 +325,11 @@ This walks through Socket Mode -- the easiest way to get started. Run one install for a whole team - private per person, isolated per agent. + + Spaces, DMs, threads, and Cards v2 over Pub/Sub or webhook. + - Compare all 10 supported platforms side by side. + Compare all 11 supported platforms side by side. Set up your agent's personality, tools, and behavior. diff --git a/docs/channels/telegram.mdx b/docs/channels/telegram.mdx index 20f6bd59a..a19b114fd 100644 --- a/docs/channels/telegram.mdx +++ b/docs/channels/telegram.mdx @@ -763,8 +763,11 @@ Three possible outcomes tell you which layer is failing: How streaming, typing indicators, and retry logic work under the hood. + + Spaces, DMs, threads, and Cards v2 over Pub/Sub or webhook. + - Compare all 10 supported platforms side by side. + Compare all 11 supported platforms side by side. Set up your agent's personality, tools, and behavior. diff --git a/docs/channels/whatsapp.mdx b/docs/channels/whatsapp.mdx index f56f64dff..84550dcc7 100644 --- a/docs/channels/whatsapp.mdx +++ b/docs/channels/whatsapp.mdx @@ -184,8 +184,11 @@ individual users, or cards/embeds. Run one install for a whole team - private per person, isolated per agent. + + Spaces, DMs, threads, and Cards v2 over Pub/Sub or webhook. + - Compare all 10 supported platforms side by side. + Compare all 11 supported platforms side by side. Set up your agent's personality, tools, and behavior. From 92d3b4f8189dfab0ecbc00a22b8c04cad2560888 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 14:19:52 +0300 Subject: [PATCH 155/200] docs(238-02): bump user-facing channel-count prose 10->11 - agent-tools/index.mdx: 'all 10 connected channels' -> 11 - installation/install-linux.mdx: 'any of the 10 supported platforms' -> 11 - get-started/quickstart.mdx: 'any of the 10 supported platforms' -> 11 contributing.mdx 'all 10 real channel adapters' intentionally left unchanged: the CHAN-01 credential-validation breadth test (echo-golden.test.ts) enumerates 9 adapters and does not include Google Chat, so bumping it would invent coverage. --- docs/agent-tools/index.mdx | 2 +- docs/get-started/quickstart.mdx | 2 +- docs/installation/install-linux.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/agent-tools/index.mdx b/docs/agent-tools/index.mdx index b1a688901..dec942e10 100644 --- a/docs/agent-tools/index.mdx +++ b/docs/agent-tools/index.mdx @@ -211,7 +211,7 @@ The available actions vary by platform. See the [Platform Actions](/agent-tools/ ## Channel Messaging Capability Matrix -The `message` tool works across all 10 connected channels, but not every action is supported everywhere. The matrix below summarizes per-channel capabilities -- consult the [Channels Overview](/channels/index) for full per-channel detail. +The `message` tool works across all 11 connected channels, but not every action is supported everywhere. The matrix below summarizes per-channel capabilities -- consult the [Channels Overview](/channels/index) for full per-channel detail. | Channel | Send | Reply | React | Edit | Delete | Fetch History | Threads | Streaming | |---------|------|-------|-------|------|--------|--------------|---------|-----------| diff --git a/docs/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index 99309e0f7..0b1a97287 100644 --- a/docs/get-started/quickstart.mdx +++ b/docs/get-started/quickstart.mdx @@ -328,7 +328,7 @@ pick up the changes: `comis daemon stop` followed by `comis daemon start`. - Add Discord, Telegram, Slack, WhatsApp, or any of the 10 supported platforms. + Add Discord, Telegram, Slack, WhatsApp, or any of the 11 supported platforms. Understand how Comis processes messages and connects your agents. diff --git a/docs/installation/install-linux.mdx b/docs/installation/install-linux.mdx index 05c64ecf0..6c23f20a0 100644 --- a/docs/installation/install-linux.mdx +++ b/docs/installation/install-linux.mdx @@ -330,6 +330,6 @@ For the full walkthrough including multi-account profiles, see Run diagnostic checks to confirm everything is working. - Add Discord, Telegram, Slack, or any of the 10 supported platforms. + Add Discord, Telegram, Slack, or any of the 11 supported platforms. From 4ea6dd2aef47af6f9975c16ce08e7d8b86b3e83c Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 14:28:21 +0300 Subject: [PATCH 156/200] test(238-03): pin channel-count guard to 11 + require Google Chat - expect the headline channel count == 11 (was 10) - expect channelList to enumerate exactly 11 channels (was 10) - assert channelList includes "Google Chat" alongside "Microsoft Teams" --- test/architecture/channel-count-consistency.test.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/test/architecture/channel-count-consistency.test.ts b/test/architecture/channel-count-consistency.test.ts index c76869463..6299df5cc 100644 --- a/test/architecture/channel-count-consistency.test.ts +++ b/test/architecture/channel-count-consistency.test.ts @@ -51,12 +51,12 @@ describe("website channel count is internally consistent", () => { const channels = headlineChannelCount(FACTS_SRC); const list = channelList(FACTS_SRC); - it("states 10 channels in the headline count", () => { - expect(channels).toBe(10); + it("states 11 channels in the headline count", () => { + expect(channels).toBe(11); }); - it("enumerates exactly 10 channels in channelList", () => { - expect(list).toHaveLength(10); + it("enumerates exactly 11 channels in channelList", () => { + expect(list).toHaveLength(11); }); it("reconciles the headline count with the enumerated list", () => { @@ -67,6 +67,10 @@ describe("website channel count is internally consistent", () => { expect(list).toContain("Microsoft Teams"); }); + it("includes Google Chat in the channel list", () => { + expect(list).toContain("Google Chat"); + }); + it("keeps the doc-comment example number equal to the headline count", () => { expect(docCommentChannelCount(FACTS_SRC)).toBe(channels); }); From 9f57514bb4d25404c35738b24c04db9af9c3010e Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 14:28:44 +0300 Subject: [PATCH 157/200] feat(238-03): state 11 channels incl. Google Chat in facts.ts - headline channels count 10 -> 11 - append "Google Chat" to channelList - bump the doc-comment example to "11 channels" --- website/src/data/facts.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/website/src/data/facts.ts b/website/src/data/facts.ts index 42274f150..2eab00226 100644 --- a/website/src/data/facts.ts +++ b/website/src/data/facts.ts @@ -2,7 +2,7 @@ /** * The single source of truth for every counted, benchmarked, and cost fact the * website states. Import `FACTS` instead of hardcoding a number in a page or - * component - so "10 channels", "15 packages", "35 providers", or "~71%" can + * component - so "11 channels", "15 packages", "35 providers", or "~71%" can * never be stated two different ways across the site. * * Every value here is copied verbatim from the audited README and is treated @@ -23,10 +23,11 @@ export const FACTS = { // Counts. // Used by: homepage / Channels / compare pages (channels), Everything-in-the-box (packages). - channels: 10, + channels: 11, channelList: [ "Telegram", "Discord", "Slack", "WhatsApp", "Signal", "iMessage", "LINE", "IRC", "Email", "Microsoft Teams", + "Google Chat", ], packages: 15, nodeEngines: "22.19+", From 25f563d69bad08dd95dc8c0c5e3adf58c3dff992 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 14:51:04 +0300 Subject: [PATCH 158/200] test(238-04): add failing googlechat wizard enumeration + init-option guards - new wizard-googlechat-enumeration.test.ts: SUPPORTED_CHANNELS, CHANNEL_ENV_KEYS, credential types, and the channel-command channelTypes enumeration - init.test.ts: assert the four --googlechat-* option longs and the new option count - fails RED on pre-patch code (googlechat not wired into the wizard yet) --- packages/cli/src/commands/init.test.ts | 10 +++-- .../wizard-googlechat-enumeration.test.ts | 38 +++++++++++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) create mode 100644 packages/cli/src/wizard/wizard-googlechat-enumeration.test.ts diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 475af1ca0..3efc572f7 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -59,7 +59,7 @@ describe("registerInitCommand", () => { expect(optionLongs).toContain("--gateway-bind"); expect(optionLongs).toContain("--gateway-token"); - // Channels (11) + // Channels (15) expect(optionLongs).toContain("--channels"); expect(optionLongs).toContain("--telegram-token"); expect(optionLongs).toContain("--discord-token"); @@ -71,6 +71,10 @@ describe("registerInitCommand", () => { expect(optionLongs).toContain("--msteams-app-password"); expect(optionLongs).toContain("--msteams-tenant-id"); expect(optionLongs).toContain("--msteams-auth-mode"); + expect(optionLongs).toContain("--googlechat-sa-key"); + expect(optionLongs).toContain("--googlechat-subscription"); + expect(optionLongs).toContain("--googlechat-mode"); + expect(optionLongs).toContain("--googlechat-audience"); // Media generation + processing (8) expect(optionLongs).toContain("--image-provider"); @@ -99,11 +103,11 @@ describe("registerInitCommand", () => { expect(optionLongs).toContain("--reset-scope"); }); - it("has exactly 38 options", () => { + it("has exactly 42 options", () => { const program = new Command(); registerInitCommand(program); const initCmd = program.commands.find((c) => c.name() === "init")!; - expect(initCmd.options).toHaveLength(38); + expect(initCmd.options).toHaveLength(42); }); it("parses --channels as comma-separated list", () => { diff --git a/packages/cli/src/wizard/wizard-googlechat-enumeration.test.ts b/packages/cli/src/wizard/wizard-googlechat-enumeration.test.ts new file mode 100644 index 000000000..c1b1930e9 --- /dev/null +++ b/packages/cli/src/wizard/wizard-googlechat-enumeration.test.ts @@ -0,0 +1,38 @@ +// SPDX-License-Identifier: Apache-2.0 +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, it, expect } from "vitest"; +import { SUPPORTED_CHANNELS, CHANNEL_ENV_KEYS } from "./types.js"; +import { getChannelCredentialTypes } from "./validators/channel-creds.js"; + +const here = dirname(fileURLToPath(import.meta.url)); + +describe("Google Chat wizard enumeration", () => { + it("offers a Google Chat entry in SUPPORTED_CHANNELS so the init menu can show it", () => { + const types = SUPPORTED_CHANNELS.map((c) => c.type as string); + expect(types).toContain("googlechat"); + }); + + it("maps googlechat to the GOOGLECHAT_SA_KEY env key in CHANNEL_ENV_KEYS", () => { + expect(CHANNEL_ENV_KEYS["googlechat"]).toContain("GOOGLECHAT_SA_KEY"); + }); + + it("declares serviceAccountKey and subscriptionName as the googlechat credential types", () => { + const creds = getChannelCredentialTypes("googlechat"); + expect(creds.length).toBeGreaterThan(0); + expect(creds).toEqual( + expect.arrayContaining(["serviceAccountKey", "subscriptionName"]), + ); + }); + + it("includes googlechat in the channelTypes enumeration of the channel command", () => { + const src = readFileSync(resolve(here, "../commands/channel.ts"), "utf8"); + const match = src.match(/const channelTypes = \[([\s\S]*?)\] as const;/); + expect( + match, + "channelTypes array literal must be present in channel.ts", + ).not.toBeNull(); + expect(match?.[1] ?? "").toContain("googlechat"); + }); +}); From 6ec6e39c4562c9e4026d55d0c18800219e9dfa6c Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 14:54:44 +0300 Subject: [PATCH 159/200] feat(238-04): wire googlechat into wizard enumeration, credential validator, and init options - types.ts: googlechat in the ChannelConfig union + fields (serviceAccountKey, subscriptionName, mode, audienceType, audience); SUPPORTED_CHANNELS entry; CHANNEL_ENV_KEYS googlechat -> GOOGLECHAT_SA_KEY - channel-creds.ts: googlechat credential types + secret-safe validateGoogleChat (parses the SA key JSON, asserts client_email/private_key, never echoes the key) - init.ts: four --googlechat-* commander options - channel.ts: googlechat in the channel-command channelTypes enumeration - flips the enumeration/init-option guards + the validateGoogleChat tests green --- packages/cli/src/commands/channel.ts | 2 +- packages/cli/src/commands/init.ts | 4 ++ .../cli/src/wizard/steps/06-channels.test.ts | 5 +- packages/cli/src/wizard/types.ts | 12 +++- .../wizard/validators/channel-creds.test.ts | 62 +++++++++++++++++++ .../src/wizard/validators/channel-creds.ts | 53 ++++++++++++++++ 6 files changed, 134 insertions(+), 4 deletions(-) diff --git a/packages/cli/src/commands/channel.ts b/packages/cli/src/commands/channel.ts index 601180c2d..ac08ca596 100644 --- a/packages/cli/src/commands/channel.ts +++ b/packages/cli/src/commands/channel.ts @@ -152,7 +152,7 @@ function extractChannels( const channelTypes = [ "telegram", "discord", "slack", "whatsapp", "signal", - "imessage", "line", "irc", "email", "msteams", + "imessage", "line", "irc", "email", "msteams", "googlechat", ] as const; for (const type of channelTypes) { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 1412c9ae5..28a07a6ac 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -201,6 +201,10 @@ export function registerInitCommand(program: Command): void { .option("--msteams-app-password ", "Microsoft Teams app password (client secret)") .option("--msteams-tenant-id ", "Microsoft Teams directory (tenant) ID") .option("--msteams-auth-mode ", "Microsoft Teams auth mode: secret|certificate|managedIdentity") + .option("--googlechat-sa-key ", "Google Chat service-account key JSON (or a path to the key file)") + .option("--googlechat-subscription ", "Google Chat Pub/Sub subscription (pubsub mode): projects/P/subscriptions/S") + .option("--googlechat-mode ", "Google Chat inbound mode: pubsub|webhook") + .option("--googlechat-audience ", "Google Chat inbound JWT audience (webhook mode)") // Media generation .option("--image-provider ", "Image generation provider: auto|fal|openai|openai-codex|google|openrouter") .option("--image-api-key ", "Image provider API key (e.g. FAL_KEY; reuses --api-key for a matching main provider)") diff --git a/packages/cli/src/wizard/steps/06-channels.test.ts b/packages/cli/src/wizard/steps/06-channels.test.ts index 7d05fc3b2..1950378a0 100644 --- a/packages/cli/src/wizard/steps/06-channels.test.ts +++ b/packages/cli/src/wizard/steps/06-channels.test.ts @@ -433,7 +433,7 @@ describe("channelsStep", () => { expect(result.channels![0].allowFrom).toBeUndefined(); }); - it("multiselect offers all 8 supported channels", async () => { + it("multiselect offers all 9 supported channels", async () => { const prompter = createMockPrompter(); vi.mocked(prompter.multiselect).mockResolvedValueOnce([]); @@ -450,7 +450,7 @@ describe("channelsStep", () => { ).mock.calls[0][0] as { options: Array<{ value: string }>; }; - expect(multiselectCall.options).toHaveLength(8); + expect(multiselectCall.options).toHaveLength(9); const values = multiselectCall.options.map( (o: { value: string }) => o.value, @@ -463,5 +463,6 @@ describe("channelsStep", () => { expect(values).toContain("irc"); expect(values).toContain("line"); expect(values).toContain("msteams"); + expect(values).toContain("googlechat"); }); }); diff --git a/packages/cli/src/wizard/types.ts b/packages/cli/src/wizard/types.ts index e0e91a6b4..d1bf96442 100644 --- a/packages/cli/src/wizard/types.ts +++ b/packages/cli/src/wizard/types.ts @@ -77,7 +77,7 @@ export type WizardError = { /** Per-channel collected credentials. */ export type ChannelConfig = { - type: "telegram" | "discord" | "slack" | "whatsapp" | "signal" | "irc" | "line" | "msteams"; + type: "telegram" | "discord" | "slack" | "whatsapp" | "signal" | "irc" | "line" | "msteams" | "googlechat"; botToken?: string; apiKey?: string; appToken?: string; @@ -91,6 +91,14 @@ export type ChannelConfig = { appPassword?: string; tenantId?: string; authMode?: "secret" | "certificate" | "managedIdentity"; + // Google Chat: a service-account key JSON blob (the secret, persisted as a + // ${VAR} ref) plus the inbound transport selector and its per-mode field — + // subscriptionName (Pub/Sub pull) or audience (verified webhook). + serviceAccountKey?: string; + subscriptionName?: string; + mode?: "pubsub" | "webhook"; + audienceType?: "project-number" | "app-url"; + audience?: string; }; /** Per-tool-provider collected credentials. */ @@ -294,6 +302,7 @@ export const SUPPORTED_CHANNELS: readonly SupportedChannel[] = [ { type: "irc", label: "IRC", credentialHint: "No credentials needed" }, { type: "line", label: "LINE", credentialHint: "Channel token + secret required" }, { type: "msteams", label: "Microsoft Teams", credentialHint: "App ID + password + tenant ID" }, + { type: "googlechat", label: "Google Chat", credentialHint: "Service-account key + Pub/Sub subscription" }, ] as const; // ---------- Environment Key Maps ---------- @@ -532,4 +541,5 @@ export const CHANNEL_ENV_KEYS: Record = { whatsapp: ["WHATSAPP_ACCESS_TOKEN", "WHATSAPP_VERIFY_TOKEN"], line: ["LINE_CHANNEL_ACCESS_TOKEN", "LINE_CHANNEL_SECRET"], msteams: ["MSTEAMS_APP_PASSWORD"], + googlechat: ["GOOGLECHAT_SA_KEY"], }; diff --git a/packages/cli/src/wizard/validators/channel-creds.test.ts b/packages/cli/src/wizard/validators/channel-creds.test.ts index dd21f33a2..bc186cf1d 100644 --- a/packages/cli/src/wizard/validators/channel-creds.test.ts +++ b/packages/cli/src/wizard/validators/channel-creds.test.ts @@ -216,6 +216,68 @@ describe("validateChannelCredential", () => { }); }); + describe("googlechat", () => { + // A well-formed service-account key shape carrying only the two fields the + // outbound JWT mint needs. Placeholder values only -- never a real key. + const validSaKey = JSON.stringify({ + type: "service_account", + client_email: "bot@example-project.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nMIIexampleexample\n-----END PRIVATE KEY-----\n", + }); + + it("accepts a well-formed service-account key JSON with client_email and private_key", () => { + const result = validateChannelCredential("googlechat", "serviceAccountKey", validSaKey); + expect(result).toBeUndefined(); + }); + + it("rejects a service-account key that is not valid JSON", () => { + const result = validateChannelCredential("googlechat", "serviceAccountKey", "not-json-at-all"); + expect(result).toBeDefined(); + expect(result!.message).toContain("not valid JSON"); + }); + + it("rejects a service-account key JSON missing client_email or private_key", () => { + const result = validateChannelCredential( + "googlechat", + "serviceAccountKey", + JSON.stringify({ client_email: "bot@example-project.iam.gserviceaccount.com" }), + ); + expect(result).toBeDefined(); + expect(result!.message).toContain("client_email or private_key"); + }); + + it("never echoes the key material into the validation message (secret-safe)", () => { + // A structurally-valid JSON carrying a recognizable secret marker but + // missing client_email: the failure message must name the requirement, + // never the value -- no key material may leak through the failure path. + const secretMarker = "SUPER-SECRET-PRIVATE-KEY-MATERIAL-DO-NOT-LEAK"; + const result = validateChannelCredential( + "googlechat", + "serviceAccountKey", + JSON.stringify({ private_key: secretMarker }), + ); + expect(result).toBeDefined(); + expect(result!.message).not.toContain(secretMarker); + expect(result!.hint ?? "").not.toContain(secretMarker); + expect(result!.field ?? "").not.toContain(secretMarker); + }); + + it("rejects an empty service-account key", () => { + const result = validateChannelCredential("googlechat", "serviceAccountKey", ""); + expect(result).toBeDefined(); + expect(result!.message).toContain("required"); + }); + + it("accepts a non-empty subscriptionName (no format check beyond non-empty)", () => { + const result = validateChannelCredential( + "googlechat", + "subscriptionName", + "projects/p/subscriptions/s", + ); + expect(result).toBeUndefined(); + }); + }); + describe("channels with no credentials", () => { it("returns undefined for whatsapp", () => { expect( diff --git a/packages/cli/src/wizard/validators/channel-creds.ts b/packages/cli/src/wizard/validators/channel-creds.ts index cb10bc611..92afa60e3 100644 --- a/packages/cli/src/wizard/validators/channel-creds.ts +++ b/packages/cli/src/wizard/validators/channel-creds.ts @@ -25,6 +25,7 @@ const CHANNEL_CREDENTIAL_TYPES: Record = { slack: ["botToken", "appToken"], line: ["channelToken", "channelSecret"], msteams: ["appId", "appPassword", "tenantId"], + googlechat: ["serviceAccountKey", "subscriptionName"], whatsapp: [], signal: [], irc: [], @@ -188,6 +189,56 @@ function validateMsTeams( return undefined; } +// ---------- Google Chat ---------- + +/** + * Format-check a Google Chat credential. The service-account key is the only + * value with a format worth catching early: it must be a service-account key + * JSON carrying the two fields the outbound JWT mint needs (`client_email` and + * `private_key`). A parse failure or a missing field is turned into a message + * that names the requirement only -- the raw key text is NEVER placed in the + * message, so no key material leaks through the failure path. This is a + * format-only typo-catcher, not a security control: the daemon surfaces the real + * auth error at first use. subscriptionName/audience have no format to check + * here beyond the non-empty guard applied by the caller. + */ +function validateGoogleChat( + credentialType: string, + value: string, +): ValidationResult | undefined { + if (credentialType === "serviceAccountKey") { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch { + return { + message: "Invalid Google Chat service-account key: not valid JSON.", + hint: "Paste the downloaded service-account key JSON, or a path to the key file.", + field: "googlechatServiceAccountKey", + }; + } + if (typeof parsed !== "object" || parsed === null) { + return { + message: "Invalid Google Chat service-account key: expected a JSON object.", + hint: "The service-account key JSON must be an object with client_email and private_key.", + field: "googlechatServiceAccountKey", + }; + } + const key = parsed as { client_email?: unknown; private_key?: unknown }; + const hasClientEmail = typeof key.client_email === "string" && key.client_email.trim() !== ""; + const hasPrivateKey = typeof key.private_key === "string" && key.private_key.trim() !== ""; + if (!hasClientEmail || !hasPrivateKey) { + return { + message: "Invalid Google Chat service-account key: missing client_email or private_key.", + hint: "Use the full service-account key JSON downloaded from the Google Cloud console.", + field: "googlechatServiceAccountKey", + }; + } + } + + return undefined; +} + // ---------- Public API ---------- /** @@ -228,6 +279,8 @@ export function validateChannelCredential( return validateLine(credentialType, trimmed); case "msteams": return validateMsTeams(credentialType, trimmed); + case "googlechat": + return validateGoogleChat(credentialType, trimmed); case "whatsapp": case "signal": case "irc": From f0ed6e2e9c6ea6e658445f470004bac5e431c989 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 14:59:56 +0300 Subject: [PATCH 160/200] test(238-04): add failing googlechat interactive + write-path + non-interactive guards - 06-channels.test.ts: handleGoogleChat collects a pubsub (pasted SA key) and a webhook (SA key read from a file path) channel - 10-write-config.test.ts: the written block carries serviceAccountKey as a ${GOOGLECHAT_SA_KEY} ref (never the raw blob), validates under the shipped GoogleChatChannelEntrySchema (via ChannelConfigSchema), and the SA key is persisted via collectManagedSecrets so the ref is not dangling - non-interactive.test.ts: --googlechat-mode enum guard, per-mode required-field validation, and the googlechat config-build - non-interactive.ts: NonInteractiveOptions googlechat* fields (type-only, so the tests compile; the guard/build logic lands in the GREEN patch) - 11 tests fail RED on pre-patch code --- .../cli/src/wizard/non-interactive.test.ts | 109 ++++++++++++++++++ packages/cli/src/wizard/non-interactive.ts | 4 + .../cli/src/wizard/steps/06-channels.test.ts | 57 +++++++++ .../src/wizard/steps/10-write-config.test.ts | 107 ++++++++++++++++- 4 files changed, 276 insertions(+), 1 deletion(-) diff --git a/packages/cli/src/wizard/non-interactive.test.ts b/packages/cli/src/wizard/non-interactive.test.ts index a9f1d4055..cca0c8360 100644 --- a/packages/cli/src/wizard/non-interactive.test.ts +++ b/packages/cli/src/wizard/non-interactive.test.ts @@ -404,6 +404,78 @@ describe("validateNonInteractiveOptions", () => { } }); + it("rejects an unknown --googlechat-mode (closed transport vocabulary)", () => { + const opts = validOpts({ + googlechatMode: "grpc" as NonInteractiveOptions["googlechatMode"], + }); + expect(() => validateNonInteractiveOptions(opts)).toThrow(NonInteractiveError); + try { + validateNonInteractiveOptions(opts); + } catch (e) { + expect((e as NonInteractiveError).field).toBe("googlechatMode"); + expect((e as NonInteractiveError).message).toContain("pubsub"); + expect((e as NonInteractiveError).message).toContain("webhook"); + } + }); + + it("accepts each valid --googlechat-mode value", () => { + for (const mode of ["pubsub", "webhook"] as const) { + expect(() => + validateNonInteractiveOptions(validOpts({ googlechatMode: mode })), + ).not.toThrow(); + } + }); + + it("throws NonInteractiveError for missing googlechat sa key", () => { + const opts = validOpts({ channels: ["googlechat"] }); + expect(() => validateNonInteractiveOptions(opts)).toThrow(NonInteractiveError); + try { + validateNonInteractiveOptions(opts); + } catch (e) { + expect((e as NonInteractiveError).field).toBe("googlechatSaKey"); + } + }); + + it("throws NonInteractiveError for missing googlechat subscription in pubsub mode", () => { + const opts = validOpts({ + channels: ["googlechat"], + googlechatSaKey: "{}", + googlechatMode: "pubsub", + }); + try { + validateNonInteractiveOptions(opts); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(NonInteractiveError); + expect((e as NonInteractiveError).field).toBe("googlechatSubscription"); + } + }); + + it("throws NonInteractiveError for missing googlechat audience in webhook mode", () => { + const opts = validOpts({ + channels: ["googlechat"], + googlechatSaKey: "{}", + googlechatMode: "webhook", + }); + try { + validateNonInteractiveOptions(opts); + throw new Error("expected throw"); + } catch (e) { + expect(e).toBeInstanceOf(NonInteractiveError); + expect((e as NonInteractiveError).field).toBe("googlechatAudience"); + } + }); + + it("does NOT throw when all required googlechat pubsub flags are present", () => { + const opts = validOpts({ + channels: ["googlechat"], + googlechatSaKey: "{}", + googlechatSubscription: "projects/p/subscriptions/s", + googlechatMode: "pubsub", + }); + expect(() => validateNonInteractiveOptions(opts)).not.toThrow(); + }); + it("rejects an unknown --stt-provider / --tts-provider", () => { expect(() => validateNonInteractiveOptions(validOpts({ sttProvider: "assemblyai" })), @@ -591,6 +663,43 @@ describe("buildNonInteractiveState", () => { }); }); + it("builds a googlechat (pubsub) channel from the --googlechat-* opts", () => { + const state = buildNonInteractiveState( + validOpts({ + channels: ["googlechat"], + googlechatSaKey: '{"client_email":"bot@x.iam.gserviceaccount.com","private_key":"pk"}', + googlechatSubscription: "projects/p/subscriptions/s", + googlechatMode: "pubsub", + }), + ); + expect(state.channels).toHaveLength(1); + const gc = state.channels![0]; + expect(gc.type).toBe("googlechat"); + expect(gc.serviceAccountKey).toBe( + '{"client_email":"bot@x.iam.gserviceaccount.com","private_key":"pk"}', + ); + expect(gc.subscriptionName).toBe("projects/p/subscriptions/s"); + expect(gc.mode).toBe("pubsub"); + expect(gc.validated).toBe(false); + }); + + it("builds a googlechat (webhook) channel carrying the audience", () => { + const state = buildNonInteractiveState( + validOpts({ + channels: ["googlechat"], + googlechatSaKey: '{"client_email":"bot@x.iam.gserviceaccount.com","private_key":"pk"}', + googlechatAudience: "123456789012", + googlechatMode: "webhook", + }), + ); + expect(state.channels).toHaveLength(1); + const gc = state.channels![0]; + expect(gc.type).toBe("googlechat"); + expect(gc.mode).toBe("webhook"); + expect(gc.audience).toBe("123456789012"); + expect(gc.validated).toBe(false); + }); + it("builds tokenless channels (whatsapp, signal, irc) correctly", () => { const state = buildNonInteractiveState( validOpts({ channels: ["whatsapp", "signal", "irc"] }), diff --git a/packages/cli/src/wizard/non-interactive.ts b/packages/cli/src/wizard/non-interactive.ts index 9d28cb73c..1dc8fd109 100644 --- a/packages/cli/src/wizard/non-interactive.ts +++ b/packages/cli/src/wizard/non-interactive.ts @@ -86,6 +86,10 @@ export type NonInteractiveOptions = { msteamsAppPassword?: string; msteamsTenantId?: string; msteamsAuthMode?: "secret" | "certificate" | "managedIdentity"; + googlechatSaKey?: string; + googlechatSubscription?: string; + googlechatMode?: "pubsub" | "webhook"; + googlechatAudience?: string; // Media generation imageProvider?: string; imageApiKey?: string; diff --git a/packages/cli/src/wizard/steps/06-channels.test.ts b/packages/cli/src/wizard/steps/06-channels.test.ts index 1950378a0..897e2d241 100644 --- a/packages/cli/src/wizard/steps/06-channels.test.ts +++ b/packages/cli/src/wizard/steps/06-channels.test.ts @@ -9,6 +9,8 @@ * @module */ +import { mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import type { WizardPrompter, Spinner } from "../prompter.js"; import type { WizardState, ProviderConfig } from "../types.js"; @@ -465,4 +467,59 @@ describe("channelsStep", () => { expect(values).toContain("msteams"); expect(values).toContain("googlechat"); }); + + // ---------- Google Chat ---------- + + const GC_SA_KEY = JSON.stringify({ + client_email: "bot@example-project.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nMIIexample\n-----END PRIVATE KEY-----\n", + }); + + it("collects a googlechat (pubsub) channel from a pasted SA key and subscription", async () => { + const prompter = createMockPrompter({ confirm: false }); + vi.mocked(prompter.multiselect).mockResolvedValueOnce(["googlechat"]); + vi.mocked(prompter.text) + .mockResolvedValueOnce(GC_SA_KEY) // SA key (pasted JSON) + .mockResolvedValueOnce("projects/p/subscriptions/s"); // subscription + vi.mocked(prompter.select).mockResolvedValueOnce("pubsub"); + + const result = await channelsStep.execute({ ...INITIAL_STATE }, prompter); + + const gc = result.channels?.find((c) => c.type === "googlechat"); + expect(gc).toEqual({ + type: "googlechat", + serviceAccountKey: GC_SA_KEY, + mode: "pubsub", + subscriptionName: "projects/p/subscriptions/s", + validated: false, + }); + }); + + it("reads the SA key from a file path and collects a googlechat (webhook) channel", async () => { + const dir = mkdtempSync(`${tmpdir()}/comis-gc-`); + const keyPath = `${dir}/sa-key.json`; + writeFileSync(keyPath, GC_SA_KEY, "utf-8"); + try { + const prompter = createMockPrompter({ confirm: false }); + vi.mocked(prompter.multiselect).mockResolvedValueOnce(["googlechat"]); + vi.mocked(prompter.text) + .mockResolvedValueOnce(keyPath) // SA key given as a path to the file + .mockResolvedValueOnce("123456789012"); // audience + vi.mocked(prompter.select).mockResolvedValueOnce("webhook"); + + const result = await channelsStep.execute({ ...INITIAL_STATE }, prompter); + + const gc = result.channels?.find((c) => c.type === "googlechat"); + expect(gc).toEqual({ + type: "googlechat", + // The stored value is the FILE CONTENTS, not the path. + serviceAccountKey: GC_SA_KEY, + mode: "webhook", + audience: "123456789012", + validated: false, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/cli/src/wizard/steps/10-write-config.test.ts b/packages/cli/src/wizard/steps/10-write-config.test.ts index 42394f23b..69b2b026e 100644 --- a/packages/cli/src/wizard/steps/10-write-config.test.ts +++ b/packages/cli/src/wizard/steps/10-write-config.test.ts @@ -44,7 +44,7 @@ vi.mock("../../util/offline-secrets-store.js", () => ({ })); import { existsSync, mkdirSync, writeFileSync, renameSync } from "node:fs"; -import { loadEnvFile } from "@comis/core"; +import { loadEnvFile, ChannelConfigSchema } from "@comis/core"; import { offlineSecretSet } from "../../util/offline-secrets-store.js"; import type { WizardPrompter, WizardState, Spinner } from "../index.js"; import { writeConfigStep } from "./10-write-config.js"; @@ -829,4 +829,109 @@ describe("writeConfigStep", () => { ); }); }); + + // ---------- Google Chat: SecretRef blob + schema-valid block ---------- + + describe("google chat channel write", () => { + // The service-account key is a JSON blob (the secret) — it must land in the + // managed-secret store, and config.yaml must carry only the ${VAR} ref. mode + // and the per-mode field (subscriptionName for pubsub / audience for webhook) + // are non-secret config written inline. The emitted block must parse under + // the shipped GoogleChatChannelEntrySchema (via ChannelConfigSchema). + const SA_KEY_BLOB = JSON.stringify({ + type: "service_account", + client_email: "bot@example-project.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nMIIexample\n-----END PRIVATE KEY-----\n", + }); + + function googlechatState(mode: "pubsub" | "webhook"): WizardState { + return { + ...populatedState(), + channels: [ + mode === "pubsub" + ? { + type: "googlechat", + serviceAccountKey: SA_KEY_BLOB, + subscriptionName: "projects/p/subscriptions/s", + mode: "pubsub", + validated: false, + } + : { + type: "googlechat", + serviceAccountKey: SA_KEY_BLOB, + audience: "123456789012", + mode: "webhook", + validated: false, + }, + ], + }; + } + + function writtenChannels(): Record { + const writeCalls = vi.mocked(writeFileSync).mock.calls; + const configWriteCall = writeCalls.find( + ([path]) => typeof path === "string" && path.includes(".tmp"), + ); + expect(configWriteCall).toBeDefined(); + const rawConfig = configWriteCall![1] as string; + const configContent = JSON.parse(rawConfig) as { + channels: Record; + }; + return configContent.channels; + } + + it("writes channels.googlechat.serviceAccountKey as a ${GOOGLECHAT_SA_KEY} reference (never the raw blob) with mode/subscriptionName inline", async () => { + const prompter = createMockPrompter(); + await writeConfigStep.execute(googlechatState("pubsub"), prompter); + + const channels = writtenChannels(); + const gc = channels.googlechat as Record; + expect(gc.serviceAccountKey).toBe("${GOOGLECHAT_SA_KEY}"); + expect(gc.mode).toBe("pubsub"); + expect(gc.subscriptionName).toBe("projects/p/subscriptions/s"); + // The raw service-account key JSON must never appear in config.yaml. + const writeCalls = vi.mocked(writeFileSync).mock.calls; + const rawConfig = writeCalls.find( + ([path]) => typeof path === "string" && path.includes(".tmp"), + )![1] as string; + expect(rawConfig).not.toContain("BEGIN PRIVATE KEY"); + expect(rawConfig).not.toContain("example-project.iam.gserviceaccount.com"); + }); + + it("emits a googlechat (pubsub) block that validates against the shipped GoogleChatChannelEntrySchema", async () => { + const prompter = createMockPrompter(); + await writeConfigStep.execute(googlechatState("pubsub"), prompter); + + const gc = writtenChannels().googlechat; + const parsed = ChannelConfigSchema.safeParse({ googlechat: gc }); + expect(parsed.success).toBe(true); + }); + + it("emits a googlechat (webhook) block with audience inline that validates against the schema", async () => { + const prompter = createMockPrompter(); + await writeConfigStep.execute(googlechatState("webhook"), prompter); + + const gc = writtenChannels().googlechat as Record; + expect(gc.mode).toBe("webhook"); + expect(gc.audience).toBe("123456789012"); + expect(gc.serviceAccountKey).toBe("${GOOGLECHAT_SA_KEY}"); + const parsed = ChannelConfigSchema.safeParse({ googlechat: gc }); + expect(parsed.success).toBe(true); + }); + + it("registers the raw GOOGLECHAT_SA_KEY via collectManagedSecrets so the config reference is not dangling", async () => { + const prompter = createMockPrompter(); + await writeConfigStep.execute(googlechatState("pubsub"), prompter); + + const writeCalls = vi.mocked(writeFileSync).mock.calls; + const envWriteCall = writeCalls.find( + ([path]) => typeof path === "string" && path.includes(".env"), + ); + expect(envWriteCall).toBeDefined(); + const envContent = envWriteCall![1] as string; + // The managed-secret branch persists the raw blob, so the + // ${GOOGLECHAT_SA_KEY} written into config.yaml always resolves at boot. + expect(envContent).toContain(`GOOGLECHAT_SA_KEY=${SA_KEY_BLOB}`); + }); + }); }); From fa34e18d8dbf92f9cdd2dc0dae7988b32ca29f85 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 15:03:22 +0300 Subject: [PATCH 161/200] feat(238-04): implement googlechat interactive prompt, write-path, and non-interactive build - 06-channels.ts: handleGoogleChat prompts for the SA key (path-or-paste, read from a file when the input names one), the inbound mode (pubsub|webhook), and the per-mode field (subscriptionName / audience); readServiceAccountKey resolves a path to the file contents; dispatched from handleChannel - 10-write-config.ts: write block emits mode + per-mode field inline and serviceAccountKey as ${GOOGLECHAT_SA_KEY}; collectManagedSecrets persists the SA key blob under GOOGLECHAT_SA_KEY so the ref never dangles - non-interactive.ts: --googlechat-mode enum guard, per-mode required-field validation, and the googlechat config-build - init.ts: map the four --googlechat-* commander options into NonInteractiveOptions - flips the 11 Task-3 guards green; full @comis/cli suite green, cli tsc clean --- packages/cli/src/commands/init.ts | 8 ++ packages/cli/src/wizard/non-interactive.ts | 52 ++++++++++++ packages/cli/src/wizard/steps/06-channels.ts | 80 +++++++++++++++++++ .../cli/src/wizard/steps/10-write-config.ts | 23 ++++++ 4 files changed, 163 insertions(+) diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 28a07a6ac..f13237c77 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -123,6 +123,14 @@ function buildNonInteractiveOptionsFromCommander( | "certificate" | "managedIdentity" | undefined, + googlechatSaKey: options.googlechatSaKey as string | undefined, + googlechatSubscription: options.googlechatSubscription as string | undefined, + // Commander yields an arbitrary string; this assertion to the closed union is + // made sound by validateNonInteractiveOptions, which runs before any consumer + // and rejects a value outside pubsub|webhook (same cast-then-validate contract + // as --msteams-auth-mode / --storage above). + googlechatMode: options.googlechatMode as "pubsub" | "webhook" | undefined, + googlechatAudience: options.googlechatAudience as string | undefined, imageProvider: options.imageProvider as string | undefined, imageApiKey: options.imageApiKey as string | undefined, videoProvider: options.videoProvider as string | undefined, diff --git a/packages/cli/src/wizard/non-interactive.ts b/packages/cli/src/wizard/non-interactive.ts index 1dc8fd109..866605cad 100644 --- a/packages/cli/src/wizard/non-interactive.ts +++ b/packages/cli/src/wizard/non-interactive.ts @@ -298,6 +298,20 @@ export function validateNonInteractiveOptions( ); } + // --googlechat-mode, when provided, must be one of the closed inbound-transport + // vocabulary. Like the enums above, a typo would be written verbatim into + // config.yaml and only rejected by the daemon's GoogleChatChannelEntrySchema at + // boot. Reject early with a clear hint, mirroring the interactive select prompt. + if ( + opts.googlechatMode !== undefined && + !["pubsub", "webhook"].includes(opts.googlechatMode) + ) { + throw new NonInteractiveError( + "--googlechat-mode must be one of: pubsub, webhook", + "googlechatMode", + ); + } + // Validate channel credentials if (opts.channels && opts.channels.length > 0) { for (const channel of opts.channels) { @@ -366,6 +380,31 @@ export function validateNonInteractiveOptions( ); } break; + case "googlechat": { + if (!opts.googlechatSaKey) { + throw new NonInteractiveError( + "--googlechat-sa-key is required when googlechat channel is enabled", + "googlechatSaKey", + ); + } + // pubsub (the default when --googlechat-mode is absent) needs the + // Pub/Sub subscription; webhook needs the inbound JWT audience. + // Reject an incomplete block before it is written. + const mode = opts.googlechatMode ?? "pubsub"; + if (mode === "pubsub" && !opts.googlechatSubscription) { + throw new NonInteractiveError( + "--googlechat-subscription is required for googlechat pubsub mode", + "googlechatSubscription", + ); + } + if (mode === "webhook" && !opts.googlechatAudience) { + throw new NonInteractiveError( + "--googlechat-audience is required for googlechat webhook mode", + "googlechatAudience", + ); + } + break; + } // whatsapp, signal, irc do not require tokens at init time default: // Unknown channel -- allow for forward compatibility @@ -449,6 +488,19 @@ export function buildNonInteractiveState( validated: false, }); break; + case "googlechat": + // The SA key is taken verbatim (CI passes the JSON directly, e.g. + // --googlechat-sa-key "$(cat key.json)"); write-config swaps it for the + // ${GOOGLECHAT_SA_KEY} ref and persists the blob to the secret store. + channels.push({ + type: "googlechat", + serviceAccountKey: opts.googlechatSaKey, + subscriptionName: opts.googlechatSubscription, + audience: opts.googlechatAudience, + mode: opts.googlechatMode, + validated: false, + }); + break; case "whatsapp": channels.push({ type: "whatsapp", validated: false }); break; diff --git a/packages/cli/src/wizard/steps/06-channels.ts b/packages/cli/src/wizard/steps/06-channels.ts index d7d8b3b31..869fb5fac 100644 --- a/packages/cli/src/wizard/steps/06-channels.ts +++ b/packages/cli/src/wizard/steps/06-channels.ts @@ -13,6 +13,7 @@ * @module */ +import { existsSync, readFileSync } from "node:fs"; import type { WizardState, WizardStep, @@ -512,6 +513,83 @@ async function handleMsTeams( }; } +/** + * Resolve a service-account key from either a path to the JSON key file or the + * pasted JSON itself. When the input names an existing file it is read; + * otherwise it is treated as the JSON blob verbatim. The value is never logged. + */ +function readServiceAccountKey(input: string): string { + const trimmed = input.trim(); + if (trimmed.length > 0 && existsSync(trimmed)) { + return readFileSync(trimmed, "utf-8"); + } + return input; +} + +/** + * Collect Google Chat bot credentials. + * + * Google Chat authenticates with a service-account key (a JSON blob that mints a + * scoped JWT bearer), not a single bot token. The default inbound transport is a + * Pub/Sub pull loop (no public IP); an opt-in verified webhook is the + * alternative. The key is format-checked only (parseable JSON carrying + * client_email + private_key) and returned validated:false — the daemon surfaces + * an honest auth error at first use if it is wrong. The raw key is never echoed. + */ +async function handleGoogleChat( + prompter: WizardPrompter, +): Promise { + prompter.note(sectionSeparator("Google Chat Setup")); + prompter.note( + info("Create a Chat app + service account (Google Cloud console), download its key, and set up a Pub/Sub topic + subscription (pull mode)."), + ); + + const keyInput = await prompter.text({ + message: "Service-account key (path to the JSON key file, or paste the JSON)", + validate: (v: string) => { + if (typeof v !== "string") return undefined; + const result = validateChannelCredential( + "googlechat", + "serviceAccountKey", + readServiceAccountKey(v), + ); + return result?.message; + }, + }); + const serviceAccountKey = readServiceAccountKey(keyInput); + + const mode = await prompter.select<"pubsub" | "webhook">({ + message: "Inbound transport", + options: [ + { value: "pubsub", label: "Pub/Sub pull", hint: "No public IP (recommended)" }, + { value: "webhook", label: "Verified webhook", hint: "Inbound over the gateway ingress" }, + ], + initialValue: "pubsub", + }); + + if (mode === "webhook") { + const audience = await prompter.text({ + message: "Inbound JWT audience (project number or endpoint URL)", + validate: (v: string) => { + if (typeof v !== "string") return undefined; + const result = validateChannelCredential("googlechat", "audience", v); + return result?.message; + }, + }); + return { type: "googlechat", serviceAccountKey, mode, audience, validated: false }; + } + + const subscriptionName = await prompter.text({ + message: "Pub/Sub subscription (projects/P/subscriptions/S)", + validate: (v: string) => { + if (typeof v !== "string") return undefined; + const result = validateChannelCredential("googlechat", "subscriptionName", v); + return result?.message; + }, + }); + return { type: "googlechat", serviceAccountKey, mode, subscriptionName, validated: false }; +} + /** * WhatsApp: deferred configuration guidance. */ @@ -629,6 +707,8 @@ async function handleChannel( return { config: await handleLine(prompter) }; case "msteams": return { config: await handleMsTeams(prompter) }; + case "googlechat": + return { config: await handleGoogleChat(prompter) }; case "whatsapp": return { config: handleWhatsApp(prompter) }; case "signal": diff --git a/packages/cli/src/wizard/steps/10-write-config.ts b/packages/cli/src/wizard/steps/10-write-config.ts index dcde2bc6b..7c27a4c71 100644 --- a/packages/cli/src/wizard/steps/10-write-config.ts +++ b/packages/cli/src/wizard/steps/10-write-config.ts @@ -185,6 +185,21 @@ function buildConfigObject(state: WizardState): Record { if (ch.authMode) entry.authMode = ch.authMode; } + // Google Chat: the service-account key is a JSON blob (the secret) — only + // the ${GOOGLECHAT_SA_KEY} ref is written to config.yaml; the blob is + // persisted to the managed-secret store (the collectManagedSecrets branch + // below), never in config.yaml. mode and the per-mode field + // (subscriptionName for pubsub / audience+audienceType for webhook) are + // non-secret config written inline. The generic botToken fallback does NOT + // fit Google Chat (it has no botToken), so this explicit block is required. + if (ch.type === "googlechat") { + if (ch.mode) entry.mode = ch.mode; + if (ch.subscriptionName) entry.subscriptionName = ch.subscriptionName; + if (ch.audienceType) entry.audienceType = ch.audienceType; + if (ch.audience) entry.audience = ch.audience; + if (ch.serviceAccountKey) entry.serviceAccountKey = "${GOOGLECHAT_SA_KEY}"; + } + // Generic fallback for other channel types if (ch.botToken && !entry.botToken && !entry.accessToken && !entry.channelAccessToken) { entry.botToken = `\${${ch.type.toUpperCase()}_BOT_TOKEN}`; @@ -274,6 +289,14 @@ function collectManagedSecrets(state: WizardState): Map { const msteamsEnvKeys = CHANNEL_ENV_KEYS["msteams"]; if (msteamsEnvKeys?.[0]) managed.set(msteamsEnvKeys[0], ch.appPassword); } + // Google Chat's secret is the service-account key JSON blob (not + // botToken/apiKey/channelSecret), so it needs its own branch — the join + // that keeps the config's ${GOOGLECHAT_SA_KEY} reference from being a + // dangling, boot-fatal ref. + if (ch.serviceAccountKey && ch.type === "googlechat") { + const googlechatEnvKeys = CHANNEL_ENV_KEYS["googlechat"]; + if (googlechatEnvKeys?.[0]) managed.set(googlechatEnvKeys[0], ch.serviceAccountKey); + } if (ch.appToken) managed.set(`${ch.type.toUpperCase()}_APP_TOKEN`, ch.appToken); } } From 36e61efa2287fef0327f21b6dbb4b47c0d35f510 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 15:14:13 +0300 Subject: [PATCH 162/200] test(238-05): add failing WARN test for inert groupActivation on Google Chat - assert an "always" groupActivation emits exactly one content-free precondition WARN (channelType + hint, no secret, no config value beyond the mode literal) without failing validation - assert "mention-gated"/absent stay silent and no-logger never throws --- .../googlechat/credential-validator.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/channels/src/googlechat/credential-validator.test.ts b/packages/channels/src/googlechat/credential-validator.test.ts index 2448b4b6f..8a39bcfb7 100644 --- a/packages/channels/src/googlechat/credential-validator.test.ts +++ b/packages/channels/src/googlechat/credential-validator.test.ts @@ -238,4 +238,66 @@ describe("validateGoogleChatCredentials", () => { }); expect(result.ok).toBe(true); }); + + // Google Chat delivers a space MESSAGE event only when the app is @mentioned or + // slash-commanded, so an "always" group-activation mode never sees the + // unmentioned traffic it is meant to answer — it is inert on this channel. + it("emits exactly one content-free WARN when groupActivation is 'always' (inert on Google Chat), without failing validation", () => { + const logger = makeLogger(); + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + subscriptionName: VALID_SUBSCRIPTION, + groupActivation: "always", + logger, + }); + // Advisory only: an inert activation mode does NOT fail validation. + expect(result.ok).toBe(true); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + channelType: "googlechat", + errorKind: "precondition", + hint: expect.stringContaining("never delivers unmentioned"), + }), + expect.stringContaining("Inert groupActivation"), + ); + // Content-free: no secret and no config value beyond the mode literal reach + // the WARN (the SA key material and the subscription id never cross into it). + const warnDump = JSON.stringify( + (logger.warn as ReturnType).mock.calls, + ); + expect(warnDump).not.toContain(PRIVATE_KEY_SENTINEL); + expect(warnDump).not.toContain(VALID_SUBSCRIPTION); + }); + + it("does NOT WARN about groupActivation when it is 'mention-gated' (the mode Google Chat actually delivers)", () => { + const logger = makeLogger(); + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + subscriptionName: VALID_SUBSCRIPTION, + groupActivation: "mention-gated", + logger, + }); + expect(result.ok).toBe(true); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("does NOT WARN about groupActivation when it is absent", () => { + const logger = makeLogger(); + validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + subscriptionName: VALID_SUBSCRIPTION, + logger, + }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("does not throw when groupActivation is 'always' but no logger is injected", () => { + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + subscriptionName: VALID_SUBSCRIPTION, + groupActivation: "always", + }); + expect(result.ok).toBe(true); + }); }); From 7383af2965f489d1de225c971f170af78a511f51 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 15:15:15 +0300 Subject: [PATCH 163/200] feat(238-05): WARN on inert "always" groupActivation for Google Chat - add optional groupActivation to GoogleChatValidateOpts - emit a content-free advisory precondition WARN when it is "always": the platform delivers space messages only when mentioned, so "always" is inert here (mentions still activate) - advisory only; validation result unchanged (stays ok) --- .../src/googlechat/credential-validator.ts | 40 +++++++++++++++---- 1 file changed, 33 insertions(+), 7 deletions(-) diff --git a/packages/channels/src/googlechat/credential-validator.ts b/packages/channels/src/googlechat/credential-validator.ts index 40f901fec..eec54c88c 100644 --- a/packages/channels/src/googlechat/credential-validator.ts +++ b/packages/channels/src/googlechat/credential-validator.ts @@ -14,10 +14,11 @@ * reaching the subscription are separate operational probes and are * intentionally out of scope here; this is parse-only. * - * It also lints the sender allowlist: an email display id is mutable and - * spoofable, so an email-shaped `allowFrom` entry surfaces an advisory WARN - * steering the operator toward the immutable resource id. The lint never fails - * validation. + * It also carries two advisory config lints that never fail validation: an + * email-shaped `allowFrom` entry surfaces a WARN steering the operator toward the + * immutable resource id (an email display id is mutable and spoofable), and an + * `"always"` group-activation mode surfaces a WARN naming that Google Chat never + * delivers unmentioned space messages, so `"always"` is inert on this channel. * * @module */ @@ -49,8 +50,16 @@ export interface GoogleChatValidateOpts { /** The configured sender allowlist, linted for mutable email-shaped ids. */ allowFrom?: string[]; /** - * Optional logger. When present, an email-shaped `allowFrom` entry emits an - * advisory WARN; without it the lint is silent (validation is unaffected). + * The global group-activation mode. `"always"` is inert on Google Chat — the + * platform delivers a space message only when the app is mentioned or + * slash-commanded, so there is no unmentioned traffic for `"always"` to answer. + * When it is `"always"` an advisory WARN steers the operator to that reality. + */ + groupActivation?: string; + /** + * Optional logger. When present, an email-shaped `allowFrom` entry and an inert + * `groupActivation` emit advisory WARNs; without it the lints are silent + * (validation is unaffected either way). */ logger?: ComisLogger; } @@ -80,7 +89,9 @@ function isEmailShaped(entry: string): boolean { * @param opts.mode - Inbound transport mode; absent is treated as pubsub * @param opts.audience - The inbound Bearer-JWT audience (webhook mode) * @param opts.allowFrom - The sender allowlist (linted, never gated here) - * @param opts.logger - Optional logger for the advisory allowlist lint + * @param opts.groupActivation - The global group-activation mode; `"always"` is + * linted as inert on Google Chat (advisory, never gated here) + * @param opts.logger - Optional logger for the advisory allowlist + activation lints * @returns ok when the key parses with the required fields and the per-mode * precondition is met (pubsub → subscriptionName, webhook → audience); err * naming the first missing or malformed field, never its value @@ -168,5 +179,20 @@ export function validateGoogleChatCredentials( } } + // Advisory activation lint (does not fail validation): Google Chat delivers a + // space MESSAGE event only when the app is mentioned or slash-commanded, so an + // "always" activation mode never sees the unmentioned traffic it is meant to + // answer — it is inert here. Content-free: the WARN names only the mode literal. + if (opts.logger && opts.groupActivation === "always") { + opts.logger.warn( + { + channelType: "googlechat" as const, + hint: 'Google Chat never delivers unmentioned space messages, so groupActivation "always" is inert here — mentions still activate', + errorKind: "precondition" as const, + }, + "Inert groupActivation for Google Chat", + ); + } + return ok(undefined); } From a945e501e34b32f2ed6eb18a99b1a03574e79a10 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 15:16:06 +0300 Subject: [PATCH 164/200] test(238-05): assert the daemon threads groupActivation into the validator - extend makeContainer with a config-extras arg for autoReplyEngine - assert validateGoogleChatCredentials receives groupActivation:"always" at boot so the inert-mode WARN can fire, and that the actual mode ("mention-gated") is threaded verbatim (not hardcoded) --- .../wiring/setup-channels-adapters.test.ts | 38 ++++++++++++++++++- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/packages/daemon/src/wiring/setup-channels-adapters.test.ts b/packages/daemon/src/wiring/setup-channels-adapters.test.ts index 545c6755d..fd1e5343c 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.test.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.test.ts @@ -140,9 +140,13 @@ function makeChannelConfig(overrides: Record = {}) { }; } -function makeContainer(channelOverrides: Record = {}, secretMap: Record = {}) { +function makeContainer( + channelOverrides: Record = {}, + secretMap: Record = {}, + configExtras: Record = {}, +) { return { - config: { channels: makeChannelConfig(channelOverrides) }, + config: { channels: makeChannelConfig(channelOverrides), ...configExtras }, secretManager: { get: vi.fn((name: string) => { if (name in secretMap) return secretMap[name]; @@ -783,6 +787,36 @@ describe("bootstrapAdapters", () => { ); }); + it("threads autoReplyEngine.groupActivation into the boot-time validator so the inert-'always' WARN can fire", async () => { + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const container = makeContainer( + { googlechat: { enabled: true, serviceAccountKey: saKey, subscriptionName: "projects/p/subscriptions/s" } }, + {}, + { autoReplyEngine: { groupActivation: "always" } }, + ); + await bootstrapAdapters({ container, channelsLogger }); + + // The validator owns the content-free WARN; the daemon's only job is to hand + // it the global activation mode so an "always" config surfaces the lint at boot. + expect(validateGoogleChatCredentials).toHaveBeenCalledWith( + expect.objectContaining({ groupActivation: "always" }), + ); + }); + + it("threads the actual groupActivation (not a hardcoded 'always') — 'mention-gated' reaches the validator verbatim", async () => { + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const container = makeContainer( + { googlechat: { enabled: true, serviceAccountKey: saKey, subscriptionName: "projects/p/subscriptions/s" } }, + {}, + { autoReplyEngine: { groupActivation: "mention-gated" } }, + ); + await bootstrapAdapters({ container, channelsLogger }); + + expect(validateGoogleChatCredentials).toHaveBeenCalledWith( + expect.objectContaining({ groupActivation: "mention-gated" }), + ); + }); + it("resolves the key from GOOGLECHAT_SA_KEY when config serviceAccountKey is absent", async () => { const container = makeContainer( { googlechat: { enabled: true, subscriptionName: "projects/p/subscriptions/s" } }, From 1196eb56a55148b21b7d4ae3ea6db796ec1fa0c7 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 15:16:53 +0300 Subject: [PATCH 165/200] feat(238-05): thread global groupActivation into the googlechat validator - pass container.config.autoReplyEngine?.groupActivation into the boot-time validateGoogleChatCredentials opts so an "always" mode surfaces the content-free inert-activation WARN at daemon boot - no other channel registration touched --- packages/daemon/src/wiring/setup-channels-adapters.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/daemon/src/wiring/setup-channels-adapters.ts b/packages/daemon/src/wiring/setup-channels-adapters.ts index 3f27b1347..24e59dbd0 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.ts @@ -544,6 +544,10 @@ export async function bootstrapAdapters(deps: { mode: channelConfig.googlechat.mode, audience: channelConfig.googlechat.audience, allowFrom: channelConfig.googlechat.allowFrom, + // The global group-activation mode. Google Chat delivers a space message + // only when the app is mentioned, so an "always" mode is inert here — the + // validator emits a content-free advisory WARN when it sees "always". + groupActivation: container.config.autoReplyEngine?.groupActivation, logger: channelsLogger, }); if (validation.ok && key && (subscriptionName || !needsSubscription)) { From a50fb21af2ee97fd0719ba0e2e8ebb367cf61daa Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 15:19:40 +0300 Subject: [PATCH 166/200] test(238-05): add failing tests for the off-by-default egress seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - googlechat-test-seams.test.ts: resolveTestGoogleChatEgress — env-unset ⇒ undefined + no WARN; env-set ⇒ the three derived base URLs (chat/pubsub/token) + exactly one content-free WARN naming the env var; trailing-slash normalized; injected getter only (never process.env); malformed URL fails closed to production - setup-channels-adapters.test.ts: the daemon threads the redirect into createGoogleChatPlugin base URLs for both pubsub and webhook modes, and leaves production endpoints untouched when the env is unset --- .../src/wiring/googlechat-test-seams.test.ts | 98 ++++++++++++++++++- .../wiring/setup-channels-adapters.test.ts | 60 ++++++++++++ 2 files changed, 157 insertions(+), 1 deletion(-) diff --git a/packages/daemon/src/wiring/googlechat-test-seams.test.ts b/packages/daemon/src/wiring/googlechat-test-seams.test.ts index 0cf5c8aaf..4d3c95849 100644 --- a/packages/daemon/src/wiring/googlechat-test-seams.test.ts +++ b/packages/daemon/src/wiring/googlechat-test-seams.test.ts @@ -18,7 +18,11 @@ import { generateKeyPairSync, createSign } from "node:crypto"; import { describe, expect, it, vi } from "vitest"; import type { ComisLogger } from "@comis/core"; -import { resolveTestGoogleChatVerifier } from "./googlechat-test-seams.js"; +import { + resolveTestGoogleChatVerifier, + resolveTestGoogleChatEgress, + GOOGLECHAT_TEST_API_ENV, +} from "./googlechat-test-seams.js"; /** The issuer of a project-number Chat-system event token. */ const CHAT_SYSTEM_ISSUER = "chat@system.gserviceaccount.com"; @@ -312,3 +316,95 @@ describe("resolveTestGoogleChatVerifier — unreadable/malformed JWKS file (fail expect(String(warnArg.hint)).toContain("COMIS_GOOGLECHAT_TEST_JWKS"); }); }); + +describe("resolveTestGoogleChatEgress — off-by-default outbound egress redirect (COMIS_GOOGLECHAT_TEST_API)", () => { + it("env unset ⇒ returns undefined and logs no WARN (production endpoints, byte-identical)", () => { + const { logger, warn } = makeLoggerSpy(); + const getEnv = (_k: string): string | undefined => undefined; + const egress = resolveTestGoogleChatEgress(getEnv, { logger }); + expect(egress).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); + + it("env empty-string ⇒ returns undefined and logs no WARN", () => { + const { logger, warn } = makeLoggerSpy(); + const getEnv = (k: string): string | undefined => + k === GOOGLECHAT_TEST_API_ENV ? "" : undefined; + const egress = resolveTestGoogleChatEgress(getEnv, { logger }); + expect(egress).toBeUndefined(); + expect(warn).not.toHaveBeenCalled(); + }); + + it("env set to a loopback base ⇒ returns the three derived base URLs + exactly one content-free WARN", () => { + const { logger, warn, serialized } = makeLoggerSpy(); + const getEnv = (k: string): string | undefined => + k === GOOGLECHAT_TEST_API_ENV ? "http://127.0.0.1:53998" : undefined; + + const egress = resolveTestGoogleChatEgress(getEnv, { logger }); + + // One loopback base fans out to the three outbound legs (Chat REST, Pub/Sub + // pull, token mint), each on a distinct path prefix so one emulator serves all + // three — the same layout the offline scenario injects. + expect(egress).toEqual({ + chatBaseUrl: "http://127.0.0.1:53998/chat/v1", + pubsubBaseUrl: "http://127.0.0.1:53998/pubsub/v1", + tokenUrl: "http://127.0.0.1:53998/token", + }); + // Exactly one activation WARN, content-free: it names the env var, never the + // resolved loopback value. + expect(warn).toHaveBeenCalledTimes(1); + const warnArg = warn.mock.calls[0]?.[0] as Record; + expect(warnArg).toMatchObject({ channelType: "googlechat", errorKind: "config" }); + expect(String(warnArg.hint)).toContain("COMIS_GOOGLECHAT_TEST_API"); + expect(serialized()).not.toContain("127.0.0.1"); + }); + + it("normalizes a trailing slash on the base before deriving the three suffixes", () => { + const { logger } = makeLoggerSpy(); + const getEnv = (k: string): string | undefined => + k === GOOGLECHAT_TEST_API_ENV ? "http://127.0.0.1:53998/" : undefined; + const egress = resolveTestGoogleChatEgress(getEnv, { logger }); + expect(egress).toEqual({ + chatBaseUrl: "http://127.0.0.1:53998/chat/v1", + pubsubBaseUrl: "http://127.0.0.1:53998/pubsub/v1", + tokenUrl: "http://127.0.0.1:53998/token", + }); + }); + + it("reads ONLY via the injected getter — never the ambient process environment", () => { + const seen: string[] = []; + const getEnv = (k: string): string | undefined => { + seen.push(k); + return k === GOOGLECHAT_TEST_API_ENV ? "http://127.0.0.1:41000" : undefined; + }; + const egress = resolveTestGoogleChatEgress(getEnv); + expect(egress).toBeDefined(); + // The only env key consulted is the documented seam var, through the injected + // getter (globals.test.ts separately proves no direct process.env in src). + expect(seen).toContain(GOOGLECHAT_TEST_API_ENV); + }); + + it("resolves with no deps bag at all (logger optional)", () => { + const getEnv = (k: string): string | undefined => + k === GOOGLECHAT_TEST_API_ENV ? "http://127.0.0.1:41000" : undefined; + expect(() => resolveTestGoogleChatEgress(getEnv)).not.toThrow(); + }); + + it("malformed base URL ⇒ fails closed to production (undefined) + a config WARN naming the env var, never a boot crash", () => { + // An uncaught throw here would run inside the unwrapped bootstrapAdapters and + // fail daemon boot; the seam is off-by-default and test-only, so a bad value + // must degrade to production, not crash — mirroring the JWKS seam's fail-closed. + const { logger, warn } = makeLoggerSpy(); + const getEnv = (k: string): string | undefined => + k === GOOGLECHAT_TEST_API_ENV ? "not-a-url" : undefined; + let egress: ReturnType | undefined; + expect(() => { + egress = resolveTestGoogleChatEgress(getEnv, { logger }); + }).not.toThrow(); + expect(egress).toBeUndefined(); + expect(warn).toHaveBeenCalledTimes(1); + const warnArg = warn.mock.calls[0]?.[0] as Record; + expect(warnArg).toMatchObject({ channelType: "googlechat", errorKind: "config" }); + expect(String(warnArg.hint)).toContain("COMIS_GOOGLECHAT_TEST_API"); + }); +}); diff --git a/packages/daemon/src/wiring/setup-channels-adapters.test.ts b/packages/daemon/src/wiring/setup-channels-adapters.test.ts index fd1e5343c..156549381 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.test.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.test.ts @@ -817,6 +817,66 @@ describe("bootstrapAdapters", () => { ); }); + it("threads the COMIS_GOOGLECHAT_TEST_API egress redirect into the plugin base URLs (pubsub mode)", async () => { + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const env = { + get: vi.fn((k: string) => + k === "COMIS_GOOGLECHAT_TEST_API" ? "http://127.0.0.1:53998" : undefined, + ), + } as unknown as EnvPort; + const container = makeContainer({ + googlechat: { enabled: true, serviceAccountKey: saKey, subscriptionName: "projects/p/subscriptions/s", mode: "pubsub" }, + }); + await bootstrapAdapters({ container, channelsLogger, env }); + + // Set ⇒ the adapter's Chat / Pub-Sub / token egress is redirected at the + // loopback emulator via the three base-URL overrides. + expect(createGoogleChatPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + chatBaseUrl: "http://127.0.0.1:53998/chat/v1", + pubsubBaseUrl: "http://127.0.0.1:53998/pubsub/v1", + tokenUrl: "http://127.0.0.1:53998/token", + }), + ); + }); + + it("threads the egress redirect into the plugin base URLs in webhook mode too (send + token mint still route to the emulator)", async () => { + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const env = { + get: vi.fn((k: string) => + k === "COMIS_GOOGLECHAT_TEST_API" ? "http://127.0.0.1:53998" : undefined, + ), + } as unknown as EnvPort; + const container = makeContainer({ + googlechat: { enabled: true, serviceAccountKey: saKey, mode: "webhook", audienceType: "project-number", audience: "1234567890" }, + }); + await bootstrapAdapters({ container, channelsLogger, env }); + + expect(createGoogleChatPlugin).toHaveBeenCalledWith( + expect.objectContaining({ + chatBaseUrl: "http://127.0.0.1:53998/chat/v1", + pubsubBaseUrl: "http://127.0.0.1:53998/pubsub/v1", + tokenUrl: "http://127.0.0.1:53998/token", + }), + ); + }); + + it("leaves the plugin on production endpoints when COMIS_GOOGLECHAT_TEST_API is unset (no base-URL override)", async () => { + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + const env = { get: vi.fn(() => undefined) } as unknown as EnvPort; + const container = makeContainer({ + googlechat: { enabled: true, serviceAccountKey: saKey, subscriptionName: "projects/p/subscriptions/s" }, + }); + await bootstrapAdapters({ container, channelsLogger, env }); + + // Unset ⇒ no override reaches the plugin; the adapter keeps Google's real + // Chat / Pub-Sub / token endpoints (byte-identical to production). + const deps = vi.mocked(createGoogleChatPlugin).mock.calls[0]![0] as Record; + expect(deps.chatBaseUrl).toBeUndefined(); + expect(deps.pubsubBaseUrl).toBeUndefined(); + expect(deps.tokenUrl).toBeUndefined(); + }); + it("resolves the key from GOOGLECHAT_SA_KEY when config serviceAccountKey is absent", async () => { const container = makeContainer( { googlechat: { enabled: true, subscriptionName: "projects/p/subscriptions/s" } }, From b147ec53dc311a26461fe6a21ea1bad07b221969 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 15:21:28 +0300 Subject: [PATCH 167/200] feat(238-05): add off-by-default Google Chat outbound egress seam MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - resolveTestGoogleChatEgress reads COMIS_GOOGLECHAT_TEST_API via the injected EnvGetter (never process.env); unset ⇒ undefined (production endpoints, byte-identical); set ⇒ the three derived base URLs (…/chat/v1, …/pubsub/v1, …/token) so one emulator serves all legs, plus one content-free activation WARN; malformed URL fails closed - thread it into createGoogleChatPlugin for both pubsub and webhook modes via a single resolved EnvPort accessor for the registration block --- .../src/wiring/googlechat-test-seams.ts | 62 +++++++++++++++++++ .../src/wiring/setup-channels-adapters.ts | 21 ++++++- 2 files changed, 80 insertions(+), 3 deletions(-) diff --git a/packages/daemon/src/wiring/googlechat-test-seams.ts b/packages/daemon/src/wiring/googlechat-test-seams.ts index 0784a2ac5..5f4cb45a2 100644 --- a/packages/daemon/src/wiring/googlechat-test-seams.ts +++ b/packages/daemon/src/wiring/googlechat-test-seams.ts @@ -19,6 +19,11 @@ * - `COMIS_GOOGLECHAT_TEST_ISSUER` = an optional issuer override for a * fully-synthetic emulator key set. Defaults to the audience shape's Google * issuer when unset. + * - `COMIS_GOOGLECHAT_TEST_API` = a loopback base (`http://127.0.0.1:PORT`). + * This redirects the adapter's OUTBOUND egress — the Chat REST API, the + * Pub/Sub pull endpoint, and the OAuth token mint — onto that one base under + * distinct path prefixes, so a single emulator answers all three. With it + * unset the adapter keeps Google's real endpoints (production, unchanged). * * This is never a production knob: the vars are documented test-only. Env is read * through the injected getter, never the ambient process environment. @@ -41,6 +46,8 @@ export type EnvGetter = (key: string) => string | undefined; export const GOOGLECHAT_TEST_JWKS_ENV = "COMIS_GOOGLECHAT_TEST_JWKS"; /** The env var that overrides the expected issuer for a synthetic local key set. */ export const GOOGLECHAT_TEST_ISSUER_ENV = "COMIS_GOOGLECHAT_TEST_ISSUER"; +/** The env var that redirects the daemon's Google Chat outbound egress to a loopback emulator (off by default). */ +export const GOOGLECHAT_TEST_API_ENV = "COMIS_GOOGLECHAT_TEST_API"; /** * Resolve the inbound-event JWT verifier for the ingress, closed over the @@ -119,3 +126,58 @@ export function resolveTestGoogleChatVerifier( ); return verifier; } + +/** + * Resolve the OFF-BY-DEFAULT outbound-egress base-URL overrides for the adapter. + * + * Default (env unset): `undefined` — the adapter keeps Google's real Chat REST, + * Pub/Sub, and OAuth token endpoints (production, byte-identical). When + * `COMIS_GOOGLECHAT_TEST_API` names a loopback base, the three outbound legs are + * redirected onto that one base under distinct path prefixes — `…/chat/v1` (the + * Chat REST API), `…/pubsub/v1` (the Pub/Sub pull endpoint), and `…/token` (the + * service-account token mint) — so a single emulator serves all three. Activation + * emits one content-free WARN naming the env var (never the loopback value). + * + * Env is read only through the injected getter, never the ambient process + * environment. A malformed base fails closed to production (returns `undefined`) + * with a config WARN rather than throwing out of the unwrapped bootstrapAdapters + * and failing daemon boot — the seam is off-by-default and test-only, so a bad + * value must degrade to production, not crash. + */ +export function resolveTestGoogleChatEgress( + getEnv: EnvGetter, + deps?: { readonly logger?: ComisLogger }, +): { chatBaseUrl: string; pubsubBaseUrl: string; tokenUrl: string } | undefined { + const loopback = getEnv(GOOGLECHAT_TEST_API_ENV); + if (loopback === undefined || loopback.length === 0) return undefined; + let base: string; + try { + // Validate + normalize the loopback base; strip any trailing slash so the + // derived path suffixes join cleanly. A non-URL value throws here and degrades + // to production below rather than crashing boot. + base = new URL(loopback).href.replace(/\/+$/, ""); + } catch { + deps?.logger?.warn( + { + channelType: "googlechat" as const, + hint: "COMIS_GOOGLECHAT_TEST_API is set but is not a valid URL — ignoring it and using the production Google Chat/Pub-Sub/token endpoints; unset it in production", + errorKind: "config" as const, + }, + "Google Chat test egress base URL invalid; using production endpoints", + ); + return undefined; + } + deps?.logger?.warn( + { + channelType: "googlechat" as const, + hint: "Unset COMIS_GOOGLECHAT_TEST_API in production — the Google Chat outbound Chat, Pub/Sub, and token egress is redirected to a local test endpoint, not Google", + errorKind: "config" as const, + }, + "Google Chat outbound egress redirected to a local test endpoint (test-only seam)", + ); + return { + chatBaseUrl: `${base}/chat/v1`, + pubsubBaseUrl: `${base}/pubsub/v1`, + tokenUrl: `${base}/token`, + }; +} diff --git a/packages/daemon/src/wiring/setup-channels-adapters.ts b/packages/daemon/src/wiring/setup-channels-adapters.ts index 24e59dbd0..ca49d94a3 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.ts @@ -47,7 +47,10 @@ import { resolveTestActivityValidator, resolveTestConnectorFetch, } from "./msteams-test-seams.js"; -import { resolveTestGoogleChatVerifier } from "./googlechat-test-seams.js"; +import { + resolveTestGoogleChatVerifier, + resolveTestGoogleChatEgress, +} from "./googlechat-test-seams.js"; import os from "node:os"; import { safePath, systemNowMs } from "@comis/core"; import { suppressError } from "@comis/shared"; @@ -551,6 +554,15 @@ export async function bootstrapAdapters(deps: { logger: channelsLogger, }); if (validation.ok && key && (subscriptionName || !needsSubscription)) { + // OFF-BY-DEFAULT outbound live-test seam (see googlechat-test-seams.ts): + // with COMIS_GOOGLECHAT_TEST_API unset — production — this is undefined and + // the adapter keeps Google's real Chat/Pub-Sub/token endpoints (byte-identical + // to today). Set to a loopback base, it redirects all three outbound legs to + // the emulator. Reads via the injected EnvPort (the sanctioned env boundary — + // never direct process.env). Runs for BOTH modes: pubsub and webhook sends + // both mint a token and call the Chat REST API. + const getEnv = (name: string): string | undefined => env?.get(name); + const egress = resolveTestGoogleChatEgress(getEnv, { logger: channelsLogger }); const plugin = createGoogleChatPlugin({ serviceAccountKey: key, // Webhook mode carries no subscription (empty placeholder); its start() @@ -560,6 +572,9 @@ export async function bootstrapAdapters(deps: { allowMode: channelConfig.googlechat.allowMode, logger: channelsLogger, mode: channelConfig.googlechat.mode, + // Off-by-default outbound egress redirect (undefined in production → the + // adapter keeps its real Google base URLs). + ...(egress ?? {}), }); adaptersByType.set("googlechat", plugin.adapter); channelPlugins.set("googlechat", plugin); @@ -576,8 +591,8 @@ export async function bootstrapAdapters(deps: { // the live remote-JWKS one for the configured audience shape; set, it // verifies against a local test JWKS (a full verify, never a bypass). Reads // via the injected EnvPort (the sanctioned env boundary — never direct - // process.env). The audience is closed over here, fail-closed if blank. - const getEnv = (name: string): string | undefined => env?.get(name); + // process.env; the getEnv accessor is resolved once above). The audience + // is closed over here, fail-closed if blank. const gcAdapter = plugin.adapter as GoogleChatAdapterHandle; googlechatIngress = createGoogleChatIngress({ validateInboundJwt: resolveTestGoogleChatVerifier( From 65b7a6d6fe01c29e4f6019b8cc3043f5e6b6080a Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 15:33:53 +0300 Subject: [PATCH 168/200] test(238-06): add failing four-probe test for googlechat doctor check - creds-parse asserts private_key + client_email; SECRET-SAFE (raw key never surfaces in any finding message/suggestion) - inbound-path branches on mode: pubsub subscription presence (names roles/pubsub.subscriber when blank); webhook unauth /channels/googlechat - recent-inbound reads lastInboundAt over the channel-status RPC - email-shaped allowFrom entry warns, steering toward users/{id} - fails RED: googlechat-health.js does not exist yet --- .../doctor/checks/googlechat-health.test.ts | 447 ++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 packages/cli/src/doctor/checks/googlechat-health.test.ts diff --git a/packages/cli/src/doctor/checks/googlechat-health.test.ts b/packages/cli/src/doctor/checks/googlechat-health.test.ts new file mode 100644 index 000000000..ef7b960f2 --- /dev/null +++ b/packages/cli/src/doctor/checks/googlechat-health.test.ts @@ -0,0 +1,447 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat doctor-check unit tests. + * + * Google Chat defaults to a Pub/Sub pull transport and offers an opt-in webhook + * mode; a webhook ingress is stale-reap-exempt, so `comis doctor` MUST answer "is + * my Google Chat app actually receiving?" directly. This check runs four probes: + * + * 1. creds-parse — the service-account key parses into a key JSON carrying + * private_key + client_email. SECRET-SAFE: the raw key text + * never appears in any finding. + * 2. inbound path — pubsub mode: the pull subscription is configured (a blank + * subscription names roles/pubsub.subscriber); webhook mode: + * the mounted /channels/googlechat route rejects an unauth + * request (401/405) vs is absent (404). + * 3. recent-inbound — the INBOUND-ONLY lastInboundAt over the channel-status RPC + * (never a conflated last-activity signal). + * 4. allowlist lint — an email-shaped allowFrom entry WARNs, steering the + * operator toward the immutable users/{id}. + * + * The webhook endpoint probe (fetch) and recent-inbound probe (channel-status RPC + * + liveness guard) are mocked so the unit test never opens a socket. + * + * @module + */ + +import { vi, describe, it, expect, beforeEach } from "vitest"; +import { systemNowMs } from "@comis/core"; +import type { AppConfig } from "@comis/core"; +import type { DoctorContext, DoctorFinding } from "../types.js"; + +// The recent-inbound probe reads lastInboundAt over the channel-status RPC, +// gated on a liveness probe — both mocked (withClient throws under VITEST +// unless COMIS_CLI_E2E=true). +vi.mock("../../sync-tooling/daemon-guard.js", () => ({ + isDaemonRunning: vi.fn(async () => true), +})); +vi.mock("../../client/rpc-client.js", () => ({ + withClient: vi.fn(async (fn: (c: unknown) => unknown) => fn({})), + callTyped: vi.fn(async () => ({ channels: [], timestamp: 0, enabled: true })), +})); + +const daemonGuard = await import("../../sync-tooling/daemon-guard.js"); +const rpcClient = await import("../../client/rpc-client.js"); +const { googlechatHealthCheck } = await import("./googlechat-health.js"); + +const baseContext: DoctorContext = { + configPaths: ["/cfg/config.yaml"], + dataDir: "/tmp/test-comis", + daemonPidFile: "/tmp/test-comis/daemon.pid", + gatewayUrl: "http://127.0.0.1:4766", +}; + +/** + * A distinctive private-key marker. The secret-safe assertions verify it NEVER + * appears in any finding message or suggestion. + */ +const SECRET_MARKER = "PRIVATE-KEY-MATERIAL-MUST-NOT-LEAK-9f83a2c1"; + +/** A well-formed service-account key JSON string carrying the two required fields. */ +const validSaKey = JSON.stringify({ + type: "service_account", + project_id: "test-project", + private_key: `-----BEGIN PRIVATE KEY-----\n${SECRET_MARKER}\n-----END PRIVATE KEY-----\n`, + client_email: "bot@test-project.iam.gserviceaccount.com", +}); + +/** Build a DoctorContext whose googlechat config is exactly `googlechat`. */ +function contextWith( + googlechat: Record, + extra: Partial = {}, +): DoctorContext { + const config = { channels: { googlechat } } as unknown as AppConfig; + return { + ...baseContext, + config, + configResolution: { foundPath: "/cfg/config.yaml", config }, + ...extra, + }; +} + +/** Set the channel-status RPC payload (the googlechat health entry) for one test. */ +function mockChannelsHealth(channels: unknown[]): void { + vi.mocked(rpcClient.callTyped).mockResolvedValue({ + channels, + timestamp: systemNowMs(), + enabled: true, + } as never); +} + +/** Set the webhook endpoint probe's HTTP status (fetch) for one test. */ +function mockEndpointStatus(status: number): void { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ status }) as unknown as Response), + ); +} + +function find(findings: DoctorFinding[], check: string): DoctorFinding | undefined { + return findings.find((f) => f.check === check); +} + +describe("googlechatHealthCheck", () => { + beforeEach(() => { + vi.mocked(daemonGuard.isDaemonRunning).mockReset(); + vi.mocked(daemonGuard.isDaemonRunning).mockResolvedValue(true); + vi.mocked(rpcClient.withClient).mockReset(); + vi.mocked(rpcClient.withClient).mockImplementation( + async (fn: (c: unknown) => unknown) => fn({}), + ); + vi.mocked(rpcClient.callTyped).mockReset(); + mockChannelsHealth([]); + // Default webhook endpoint: mounted-but-unauth (so pubsub/creds tests don't + // depend on a fetch mock they don't care about). + mockEndpointStatus(401); + }); + + // ------------------------------------------------------------------------- + // Enabled / config gating + // ------------------------------------------------------------------------- + + it("skips with a single finding when the Google Chat channel is not enabled", async () => { + const findings = await googlechatHealthCheck.run(contextWith({ enabled: false })); + expect(findings).toHaveLength(1); + expect(findings[0]?.status).toBe("skip"); + }); + + it("names the config-resolution failure instead of claiming Google Chat is unconfigured", async () => { + const findings = await googlechatHealthCheck.run({ + ...baseContext, + config: undefined, + configResolution: { + foundPath: "/cfg/config.yaml", + unresolvedRefs: [{ path: "gateway.tokens[0].secret", varName: "COMIS_GATEWAY_TOKEN" }], + validationIssues: ["gateway.tokens.0.secret: Too small: expected string to have >=32 characters"], + }, + }); + expect(findings).toHaveLength(1); + expect(findings[0]?.status).toBe("skip"); + expect(findings[0]?.message).toContain("COMIS_GATEWAY_TOKEN"); + }); + + // ------------------------------------------------------------------------- + // Probe 1: creds-parse (SA key) — SECRET-SAFE + // ------------------------------------------------------------------------- + + it("passes creds-parse when the service-account key JSON carries private_key + client_email", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + subscriptionName: "projects/test-project/subscriptions/comis", + }), + ); + const creds = find(findings, "Google Chat credentials"); + expect(creds?.status).toBe("pass"); + }); + + it("fails creds-parse naming 'client_email' when the key JSON is missing that field", async () => { + const missingClientEmail = JSON.stringify({ + type: "service_account", + private_key: "-----BEGIN PRIVATE KEY-----\nx\n-----END PRIVATE KEY-----\n", + }); + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: missingClientEmail, + subscriptionName: "projects/test-project/subscriptions/comis", + }), + ); + const creds = find(findings, "Google Chat credentials"); + expect(creds?.status).toBe("fail"); + expect(creds?.message).toContain("client_email"); + }); + + it("fails creds-parse on malformed JSON without echoing the raw value", async () => { + const malformed = `{not valid json ${SECRET_MARKER}`; + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: malformed, + subscriptionName: "projects/test-project/subscriptions/comis", + }), + ); + const creds = find(findings, "Google Chat credentials"); + expect(creds?.status).toBe("fail"); + // SECRET-SAFE: the raw (malformed) key text is never placed in the message. + expect(creds?.message ?? "").not.toContain(SECRET_MARKER); + expect(creds?.suggestion ?? "").not.toContain(SECRET_MARKER); + }); + + it("fails creds-parse naming the exact unresolved ${GOOGLECHAT_SA_KEY} reference", async () => { + const config = { + channels: { + googlechat: { + enabled: true, + mode: "pubsub", + serviceAccountKey: "${GOOGLECHAT_SA_KEY}", + subscriptionName: "projects/test-project/subscriptions/comis", + }, + }, + } as unknown as AppConfig; + const findings = await googlechatHealthCheck.run({ + ...baseContext, + config, + configResolution: { + foundPath: "/cfg/config.yaml", + config, + unresolvedRefs: [ + { path: "channels.googlechat.serviceAccountKey", varName: "GOOGLECHAT_SA_KEY" }, + ], + }, + }); + const creds = find(findings, "Google Chat credentials"); + expect(creds?.status).toBe("fail"); + expect(creds?.message).toContain("GOOGLECHAT_SA_KEY"); + expect(creds?.suggestion ?? "").not.toBe(""); + }); + + it("never echoes the raw service-account key into ANY finding (secret-safe)", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + subscriptionName: "projects/test-project/subscriptions/comis", + allowFrom: ["ops@example.com"], + }), + ); + for (const f of findings) { + expect(f.message).not.toContain(SECRET_MARKER); + expect(f.suggestion ?? "").not.toContain(SECRET_MARKER); + } + }); + + // ------------------------------------------------------------------------- + // Probe 2: inbound path (mode branch) + // ------------------------------------------------------------------------- + + it("passes the inbound-path probe in pubsub mode when subscriptionName is set", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + subscriptionName: "projects/test-project/subscriptions/comis", + }), + ); + const inbound = find(findings, "Google Chat inbound path"); + expect(inbound?.status).toBe("pass"); + }); + + it("fails the inbound-path probe in pubsub mode with a blank subscription, naming roles/pubsub.subscriber", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + }), + ); + const inbound = find(findings, "Google Chat inbound path"); + expect(inbound?.status).toBe("fail"); + const text = `${inbound?.message ?? ""} ${inbound?.suggestion ?? ""}`; + expect(text).toContain("roles/pubsub.subscriber"); + }); + + it("passes the inbound-path probe in webhook mode when the ingress rejects an unauth request with 401", async () => { + mockEndpointStatus(401); + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "webhook", + serviceAccountKey: validSaKey, + audience: "1234567890", + }), + ); + const inbound = find(findings, "Google Chat inbound path"); + expect(inbound?.status).toBe("pass"); + }); + + it("fails the inbound-path probe in webhook mode when the ingress route is absent (404)", async () => { + mockEndpointStatus(404); + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "webhook", + serviceAccountKey: validSaKey, + audience: "1234567890", + }), + ); + const inbound = find(findings, "Google Chat inbound path"); + expect(inbound?.status).toBe("fail"); + }); + + it("skips the inbound-path probe in webhook mode when the gateway/daemon is unreachable", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => { + throw new Error("ECONNREFUSED"); + }), + ); + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "webhook", + serviceAccountKey: validSaKey, + audience: "1234567890", + }), + ); + const inbound = find(findings, "Google Chat inbound path"); + expect(inbound?.status).toBe("skip"); + }); + + // ------------------------------------------------------------------------- + // Probe 3: recent-inbound (keys on lastInboundAt) + // ------------------------------------------------------------------------- + + it("passes recent-inbound when lastInboundAt is within the recency window", async () => { + mockChannelsHealth([ + { channelType: "googlechat", lastInboundAt: systemNowMs() - 1000, lastMessageAt: systemNowMs() }, + ]); + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + subscriptionName: "projects/test-project/subscriptions/comis", + }), + ); + const inbound = find(findings, "Google Chat recent inbound"); + expect(inbound?.status).toBe("pass"); + }); + + it("warns recent-inbound for a dead ingress (lastInboundAt null, lastMessageAt fresh)", async () => { + mockChannelsHealth([ + { channelType: "googlechat", lastInboundAt: null, lastMessageAt: systemNowMs() }, + ]); + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "webhook", + serviceAccountKey: validSaKey, + audience: "1234567890", + }), + ); + const inbound = find(findings, "Google Chat recent inbound"); + expect(inbound?.status).toBe("warn"); + }); + + it("warns recent-inbound when the last inbound is beyond the recency window", async () => { + mockChannelsHealth([ + { + channelType: "googlechat", + lastInboundAt: systemNowMs() - 25 * 60 * 60 * 1000, + lastMessageAt: systemNowMs(), + }, + ]); + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + subscriptionName: "projects/test-project/subscriptions/comis", + }), + ); + const inbound = find(findings, "Google Chat recent inbound"); + expect(inbound?.status).toBe("warn"); + }); + + it("skips recent-inbound when the daemon is not reachable", async () => { + vi.mocked(daemonGuard.isDaemonRunning).mockResolvedValue(false); + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + subscriptionName: "projects/test-project/subscriptions/comis", + }), + ); + const inbound = find(findings, "Google Chat recent inbound"); + expect(inbound?.status).toBe("skip"); + // Did not even attempt the RPC. + expect(rpcClient.withClient).not.toHaveBeenCalled(); + }); + + // ------------------------------------------------------------------------- + // Probe 4: email-shaped allowFrom lint + // ------------------------------------------------------------------------- + + it("does NOT warn the allowlist probe when every entry is an immutable resource id", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + subscriptionName: "projects/test-project/subscriptions/comis", + allowFrom: ["users/123456789", "spaces/AAAA"], + }), + ); + const lint = find(findings, "Google Chat allowlist"); + expect(lint?.status).toBe("pass"); + }); + + it("warns the allowlist probe on an email-shaped entry, steering toward users/{id}", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + subscriptionName: "projects/test-project/subscriptions/comis", + allowFrom: ["ops@example.com"], + }), + ); + const lint = find(findings, "Google Chat allowlist"); + expect(lint?.status).toBe("warn"); + const text = `${lint?.message ?? ""} ${lint?.suggestion ?? ""}`; + expect(text).toContain("users/"); + }); + + // ------------------------------------------------------------------------- + // Aggregate: an enabled channel yields all four probes. + // ------------------------------------------------------------------------- + + it("reports all four probes for an enabled channel", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + subscriptionName: "projects/test-project/subscriptions/comis", + allowFrom: ["users/123456789"], + }), + ); + const checks = new Set(findings.map((f) => f.check)); + expect(checks).toEqual( + new Set([ + "Google Chat credentials", + "Google Chat inbound path", + "Google Chat recent inbound", + "Google Chat allowlist", + ]), + ); + }); +}); From 7a5b743c4e437232df1f7ff0bd8e692c4f4d2bd7 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 15:43:05 +0300 Subject: [PATCH 169/200] feat(238-06): add self-contained googlechat doctor check + register - googlechat-health.ts: four probes (creds-parse, inbound-path, recent-inbound, allowlist lint) on the shipped four-probe template - creds-parse inlines a secret-safe SA-key parse (never echoes the key; names only the missing field/requirement or unresolved ${VAR} ref) - inbound-path branches on mode: pubsub does an OFFLINE subscription presence check (blank names roles/pubsub.subscriber); webhook does an unauth POST to /channels/googlechat (401/405 mounted, 404 absent, skip) - recent-inbound reads lastInboundAt over the channel-status RPC - allowlist lint inlines the email-shaped predicate, warns toward users/{id} - self-contained: imports only @comis/core + CLI-internal modules, NO @comis/channels (the hard-forbidden edge stays absent) - registered in ALL_CHECKS after msteams; self-descriptions bumped to 11 --- packages/cli/src/commands/doctor.test.ts | 7 +- packages/cli/src/commands/doctor.ts | 15 +- .../src/doctor/checks/googlechat-health.ts | 434 ++++++++++++++++++ 3 files changed, 449 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/doctor/checks/googlechat-health.ts diff --git a/packages/cli/src/commands/doctor.test.ts b/packages/cli/src/commands/doctor.test.ts index 835949618..90dd987f7 100644 --- a/packages/cli/src/commands/doctor.test.ts +++ b/packages/cli/src/commands/doctor.test.ts @@ -17,10 +17,11 @@ describe("registerDoctorCommand", () => { const doctorCmd = program.commands.find((c) => c.name() === "doctor"); expect(doctorCmd).toBeDefined(); - // Description names all 10 diagnostic subsystems (including version-skew, - // Teams, secrets-audit, and LCD, which the earlier 6-subsystem string omitted). + // Description names all 11 diagnostic subsystems (including version-skew, + // Teams, Google Chat, secrets-audit, and LCD, which the earlier 6-subsystem + // string omitted). expect(doctorCmd!.description()).toBe( - "Diagnose 10 subsystems: configuration, daemon, gateway, version-skew, channel, Teams, workspace, OAuth, secrets-audit, and LCD health", + "Diagnose 11 subsystems: configuration, daemon, gateway, version-skew, channel, Teams, Google Chat, workspace, OAuth, secrets-audit, and LCD health", ); const optionNames = doctorCmd!.options.map((o) => o.long); diff --git a/packages/cli/src/commands/doctor.ts b/packages/cli/src/commands/doctor.ts index d568e8eac..aa7d1e26b 100644 --- a/packages/cli/src/commands/doctor.ts +++ b/packages/cli/src/commands/doctor.ts @@ -22,6 +22,7 @@ import { daemonHealthCheck } from "../doctor/checks/daemon-health.js"; import { gatewayHealthCheck } from "../doctor/checks/gateway-health.js"; import { channelHealthCheck } from "../doctor/checks/channel-health.js"; import { msteamsHealthCheck } from "../doctor/checks/msteams-health.js"; +import { googlechatHealthCheck } from "../doctor/checks/googlechat-health.js"; import { workspaceHealthCheck } from "../doctor/checks/workspace-health.js"; import { oauthHealthCheck } from "../doctor/checks/oauth-health.js"; import { lcdHealthCheck } from "../doctor/checks/lcd-health.js"; @@ -36,7 +37,7 @@ import { resolveDoctorConfig } from "../doctor/config-resolve.js"; import { readCliVersion } from "../util/cli-version.js"; import type { DoctorContext } from "../doctor/types.js"; -/** All doctor checks in execution order (10 checks). */ +/** All doctor checks in execution order (11 checks). */ const ALL_CHECKS = [ configHealthCheck, daemonHealthCheck, @@ -50,6 +51,11 @@ const ALL_CHECKS = [ // the health monitor — this check probes creds, the mounted ingress endpoint, // recent INBOUND-ONLY activity, and tenant presence directly. msteamsHealthCheck, + // Google Chat defaults to a Pub/Sub pull transport with an opt-in webhook + // mode; a webhook ingress is stale-reap-exempt, so this check probes the SA + // key, the pull subscription (pubsub) or mounted ingress (webhook), recent + // INBOUND-ONLY activity, and an email-shaped allowFrom lint directly. + googlechatHealthCheck, workspaceHealthCheck, oauthHealthCheck, secretsAuditHealthCheck, @@ -117,8 +123,9 @@ function buildDoctorContext(configPaths: string[]): DoctorContext { * Register the `doctor` command on the program. * * Provides: - * - `comis doctor` -- run 10 health check categories (config, daemon, gateway, - * version-skew, channel, Teams, workspace, OAuth, secrets-audit, LCD store) + * - `comis doctor` -- run 11 health check categories (config, daemon, gateway, + * version-skew, channel, Teams, Google Chat, workspace, OAuth, secrets-audit, + * LCD store) * - `comis doctor --repair` -- auto-fix repairable issues * - `comis doctor --refresh-test` -- opt-in refresh probe per profile. * WARNING: rotates the refresh token at OpenAI. @@ -129,7 +136,7 @@ export function registerDoctorCommand(program: Command): void { program .command("doctor") .description( - "Diagnose 10 subsystems: configuration, daemon, gateway, version-skew, channel, Teams, workspace, OAuth, secrets-audit, and LCD health", + "Diagnose 11 subsystems: configuration, daemon, gateway, version-skew, channel, Teams, Google Chat, workspace, OAuth, secrets-audit, and LCD health", ) .option("--repair", "Auto-fix repairable issues") .option("-c, --config ", "Config file paths to check") diff --git a/packages/cli/src/doctor/checks/googlechat-health.ts b/packages/cli/src/doctor/checks/googlechat-health.ts new file mode 100644 index 000000000..c95fc4d1f --- /dev/null +++ b/packages/cli/src/doctor/checks/googlechat-health.ts @@ -0,0 +1,434 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat health check for `comis doctor`. + * + * Google Chat defaults to a Cloud Pub/Sub pull transport (no public IP) with an + * opt-in Bearer-JWT-verified webhook mode. A webhook ingress is exempt from the + * health monitor's stale-reap (a webhook has no socket to go quiet), so a dead + * ingress would never surface through the monitor. An operator therefore needs a + * one-command answer to "is my Google Chat app actually receiving?" — which this + * check provides via four probes: + * + * 1. creds-parse — the service-account key parses into a key JSON carrying + * the two fields the outbound JWT mint needs (private_key + * and client_email). Secret-safe: the raw key text is never + * placed in a finding — only a missing field/requirement is + * named. A ${VAR} reference that nothing resolved is named + * (never its value). + * 2. inbound path — branches on transport mode. pubsub: an OFFLINE presence + * check that the pull subscription is configured (a blank + * subscription names the roles/pubsub.subscriber grant the + * service account needs — no live Pub/Sub call is made). + * webhook: an UNAUTH request to the mounted + * /channels/googlechat route — 401/405 means the ingress is + * mounted and rejecting unauth (good); 404 means the route + * is absent (misconfigured). + * 3. recent-inbound — the INBOUND-ONLY lastInboundAt read over the channel- + * status RPC. It is NEVER a conflated last-activity signal: + * an outbound send bumps last-activity, so a send-only app + * would read as healthy on last-activity while its ingress + * is dead. The probe reads the dedicated inbound signal so + * that case is caught. + * 4. allowlist lint — an email-shaped allowFrom entry surfaces a warn steering + * the operator toward the immutable users/{id} resource id + * (an email display id is mutable and spoofable). + * + * The webhook endpoint + recent-inbound probes degrade to `skip` when the + * daemon/gateway is unreachable (mirrors the other daemon-dependent doctor + * checks). The probe never calls the adapter directly — the daemon owns live + * adapter state and surfaces it over RPC. This check is self-contained: it + * inlines a secret-safe key parse and the email-shaped predicate rather than + * importing channel code. + * + * @module + */ + +import { ChannelsHealthContract, systemNowMs } from "@comis/core"; +import type { DoctorCheck, DoctorContext, DoctorFinding, DoctorStatus } from "../types.js"; +import { describeConfigUnavailable } from "../config-resolve.js"; +import { isDaemonRunning } from "../../sync-tooling/daemon-guard.js"; +import { withClient, callTyped } from "../../client/rpc-client.js"; + +const CATEGORY = "channels"; +const CHANNEL_TYPE = "googlechat"; + +/** The mounted webhook inbound route (relative to the gateway origin). */ +const GOOGLECHAT_ENDPOINT_PATH = "/channels/googlechat"; + +/** Endpoint-probe HTTP timeout. */ +const ENDPOINT_PROBE_TIMEOUT_MS = 3_000; +/** Liveness-probe timeout before the recent-inbound RPC. */ +const LIVENESS_PROBE_TIMEOUT_MS = 1_000; +/** + * Recent-inbound recency window. A `warn` (never `fail`): a quiet channel may be + * legitimate, but zero inbound in a full day on a health check is worth flagging + * as a possible dead ingress. The continuous liveness monitor is a separate + * concern; this is the point-in-time doctor read. + */ +const RECENT_INBOUND_WINDOW_MS = 24 * 60 * 60 * 1000; +const RECENT_INBOUND_WINDOW_HOURS = RECENT_INBOUND_WINDOW_MS / 3_600_000; + +/** + * Minimal view of the resolved `channels.googlechat` config block the probes + * read. `serviceAccountKey` is `string | SecretRef`: a resolved `${VAR}` string + * ref (or an inline JSON blob) is a string; a SecretRef object is present as-is + * (its resolution is a store concern, not verifiable offline). + */ +interface GoogleChatConfigView { + readonly enabled?: boolean; + readonly mode?: "pubsub" | "webhook"; + readonly serviceAccountKey?: unknown; + readonly subscriptionName?: string; + readonly audienceType?: "project-number" | "app-url"; + readonly audience?: string; + readonly allowFrom?: readonly string[]; +} + +/** A value is blank when absent or all-whitespace. */ +function isBlank(value: string | undefined): boolean { + return !value || value.trim() === ""; +} + +/** + * True when an allowlist entry looks like a bare email address rather than an + * immutable resource id. Entries that are already an immutable `users/{id}` or + * `spaces/{id}` are exempt. + */ +function isEmailShaped(entry: string): boolean { + if (entry.startsWith("users/") || entry.startsWith("spaces/")) return false; + return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(entry); +} + +/** Compact finding constructor (all googlechat findings are non-repairable). */ +function finding( + status: DoctorStatus, + check: string, + message: string, + suggestion?: string, +): DoctorFinding { + return { + category: CATEGORY, + check, + status, + message, + ...(suggestion !== undefined ? { suggestion } : {}), + repairable: false, + }; +} + +// --------------------------------------------------------------------------- +// Probe 1: creds-parse (service-account key) — SECRET-SAFE +// --------------------------------------------------------------------------- + +function credsParseFinding( + gc: GoogleChatConfigView, + context: DoctorContext, +): DoctorFinding { + const check = "Google Chat credentials"; + + // A ${VAR} reference nothing resolved: name the reference, never its value. + const unresolvedKey = (context.configResolution?.unresolvedRefs ?? []).find((ref) => + ref.path.startsWith("channels.googlechat.serviceAccountKey"), + ); + if (unresolvedKey !== undefined) { + return finding( + "fail", + check, + `Unresolved serviceAccountKey reference: \${${unresolvedKey.varName}} at ${unresolvedKey.path}` + + " — not in env, ~/.comis/.env, or the encrypted secret store", + "Store the service-account key JSON via comis secrets set, or set the variable in the environment", + ); + } + + const key = gc.serviceAccountKey; + if (key === undefined || key === null) { + return finding( + "fail", + check, + "serviceAccountKey is not set — Google Chat requires a service-account key JSON", + "Set channels.googlechat.serviceAccountKey (or a ${VAR} ref resolvable via env or comis secrets set)", + ); + } + + // A SecretRef object resolves at daemon boot; its content is not verifiable + // offline, so presence of the object is what this probe asserts. + if (typeof key !== "string") { + return finding( + "pass", + check, + "serviceAccountKey present as a secret reference (parse verified at daemon boot)", + ); + } + if (isBlank(key)) { + return finding( + "fail", + check, + "serviceAccountKey is empty — Google Chat requires a service-account key JSON", + "Set channels.googlechat.serviceAccountKey to the downloaded service-account key JSON", + ); + } + + // Parse the service-account key. A parse failure is caught locally and turned + // into a message that names the requirement — the raw string is never placed + // in the message, so no key material can leak through the failure path. + let parsed: unknown; + try { + parsed = JSON.parse(key); + } catch { + return finding( + "fail", + check, + "serviceAccountKey did not parse as JSON — it must be a service-account key JSON", + "Paste the full service-account key JSON downloaded from the Google Cloud console", + ); + } + if (typeof parsed !== "object" || parsed === null) { + return finding( + "fail", + check, + "serviceAccountKey must be a service-account key JSON object", + "Use the full service-account key JSON (an object with client_email and private_key)", + ); + } + + // Assert the two fields the outbound JWT mint requires. The message names the + // missing field only — its value is never read into the message. + const fields = parsed as { private_key?: unknown; client_email?: unknown }; + const privateKey = typeof fields.private_key === "string" ? fields.private_key : undefined; + const clientEmail = typeof fields.client_email === "string" ? fields.client_email : undefined; + if (isBlank(privateKey)) { + return finding( + "fail", + check, + "serviceAccountKey is missing 'private_key'", + "Use the full service-account key JSON downloaded from the Google Cloud console", + ); + } + if (isBlank(clientEmail)) { + return finding( + "fail", + check, + "serviceAccountKey is missing 'client_email'", + "Use the full service-account key JSON downloaded from the Google Cloud console", + ); + } + return finding("pass", check, "service-account key parsed (private_key + client_email present)"); +} + +// --------------------------------------------------------------------------- +// Probe 2: inbound path — pubsub subscription presence OR webhook endpoint +// --------------------------------------------------------------------------- + +async function subscriptionOrIngressFinding( + gc: GoogleChatConfigView, + gatewayUrl: string | undefined, +): Promise { + const check = "Google Chat inbound path"; + + if (gc.mode === "webhook") { + // Webhook mode receives inbound over the mounted gateway ingress. + if (gatewayUrl === undefined) { + return finding("skip", check, "Ingress not checked — no gateway URL configured"); + } + const url = `${gatewayUrl.replace(/\/+$/, "")}${GOOGLECHAT_ENDPOINT_PATH}`; + let status: number; + try { + // Unauth probe: no Authorization header. The ingress rejects at its + // Bearer-JWT pre-gate (401) BEFORE reading a body — no secret is sent and + // no body is processed, so this reads only the status code. + const response = await fetch(url, { + method: "POST", + signal: AbortSignal.timeout(ENDPOINT_PROBE_TIMEOUT_MS), + }); + status = response.status; + } catch { + // Connection refused / timeout / DNS — the daemon or gateway is unreachable. + return finding( + "skip", + check, + `Ingress not reachable at ${url} — daemon/gateway may be down`, + ); + } + if (status === 401 || status === 405) { + return finding( + "pass", + check, + `Ingress mounted at ${GOOGLECHAT_ENDPOINT_PATH} (rejects an unauthenticated request with ${status})`, + ); + } + if (status === 404) { + return finding( + "fail", + check, + `The inbound route ${GOOGLECHAT_ENDPOINT_PATH} returned 404 — the Google Chat ingress is not mounted`, + "Ensure webhook mode is enabled with the gateway running so it mounts the ingress (check the daemon startup logs)", + ); + } + return finding( + "warn", + check, + `The unauth probe to ${GOOGLECHAT_ENDPOINT_PATH} returned an unexpected ${status} (expected 401/405 mounted, or 404 absent)`, + "Inspect the gateway route table and the daemon logs", + ); + } + + // pubsub mode (default): offline presence check of the pull subscription. No + // live Pub/Sub call is made — a blank subscription names the IAM grant. + if (isBlank(gc.subscriptionName)) { + return finding( + "fail", + check, + "subscriptionName is not set — pubsub mode pulls inbound from a Pub/Sub subscription", + "Set channels.googlechat.subscriptionName to your pull subscription (projects/{project}/subscriptions/{name}) and grant the service account roles/pubsub.subscriber on it", + ); + } + return finding( + "pass", + check, + "Pub/Sub pull subscription configured (grant the service account roles/pubsub.subscriber if inbound never arrives)", + ); +} + +// --------------------------------------------------------------------------- +// Probe 3: recent-inbound (INBOUND-ONLY lastInboundAt, never last-activity) +// --------------------------------------------------------------------------- + +async function recentInboundFinding(): Promise { + const check = "Google Chat recent inbound"; + + // Only probe a daemon that is actually up — a down daemon is the daemon + // check's signal, not a recent-inbound verdict. + const daemonUp = await isDaemonRunning(LIVENESS_PROBE_TIMEOUT_MS); + if (!daemonUp) { + return finding("skip", check, "Recent-inbound not checked — daemon not reachable"); + } + + let lastInboundAt: number | null | undefined; + let found = false; + try { + const health = await withClient((client) => + callTyped(client, ChannelsHealthContract, {}), + ); + const entry = health.channels.find((c) => c.channelType === CHANNEL_TYPE); + if (entry !== undefined) { + found = true; + lastInboundAt = entry.lastInboundAt; + } + } catch (e) { + return finding( + "skip", + check, + `Recent-inbound not checked — channel-status RPC failed: ${e instanceof Error ? e.message : String(e)}`, + ); + } + + if (!found) { + return finding( + "warn", + check, + "No Google Chat adapter is reporting health — the channel may be enabled in config but not running", + "Check that the Google Chat adapter started (see the daemon/channel doctor findings and the daemon logs)", + ); + } + + if (lastInboundAt === null || lastInboundAt === undefined) { + // Null even when last-activity may be fresh: a send-only app. Webhook + // channels are stale-reap-exempt, so this inbound-only signal — not + // last-activity — is the liveness check for a dead ingress. + return finding( + "warn", + check, + "No inbound Google Chat activity recorded — the ingress has received nothing. This inbound-only signal (not last-activity) is the liveness check, since a webhook channel is exempt from stale detection.", + "In pubsub mode verify the pull subscription and roles/pubsub.subscriber; in webhook mode verify the app's endpoint points at this gateway's /channels/googlechat route", + ); + } + + const ageMs = systemNowMs() - lastInboundAt; + if (ageMs > RECENT_INBOUND_WINDOW_MS) { + return finding( + "warn", + check, + `Last inbound Google Chat activity was ${Math.floor(ageMs / 60_000)} min ago, beyond the ${RECENT_INBOUND_WINDOW_HOURS}h recency window`, + "If you expect steady inbound traffic, the ingress may be dead — verify the subscription (pubsub) or the app endpoint (webhook)", + ); + } + + return finding( + "pass", + check, + `Recent inbound Google Chat activity within the last ${RECENT_INBOUND_WINDOW_HOURS}h`, + ); +} + +// --------------------------------------------------------------------------- +// Probe 4: email-shaped allowFrom lint +// --------------------------------------------------------------------------- + +function emailAllowFromLintFindings(gc: GoogleChatConfigView): DoctorFinding[] { + const check = "Google Chat allowlist"; + const entries = gc.allowFrom ?? []; + const emailShaped = entries.filter(isEmailShaped); + + if (emailShaped.length === 0) { + return [ + finding( + "pass", + check, + "No email-shaped allowlist entries — allowFrom uses immutable resource ids (or is empty)", + ), + ]; + } + + return emailShaped.map((entry) => + finding( + "warn", + check, + `allowFrom entry '${entry}' is an email display id, which is mutable and spoofable`, + "Prefer the immutable users/{id} resource id in channels.googlechat.allowFrom", + ), + ); +} + +/** + * Doctor check: Google Chat health. + * + * Never throws — every probe returns a finding (or degrades to `skip` when the + * daemon/gateway is unreachable). Returns a single `skip` when Google Chat is + * not enabled or the config did not resolve. + */ +export const googlechatHealthCheck: DoctorCheck = { + id: "googlechat-health", + name: "Google Chat", + run: async (context) => { + const channels = context.config?.channels as + | { googlechat?: GoogleChatConfigView } + | undefined; + const gc = channels?.googlechat; + + if (gc === undefined) { + // A valid config always carries a channels section (schema defaults), so + // reaching here means the config itself did not resolve — say WHY. + const why = describeConfigUnavailable(context.configResolution); + return [ + finding( + "skip", + "Google Chat config", + why !== undefined + ? `Google Chat health not checked — ${why}` + : "Google Chat not configured", + ), + ]; + } + + if (gc.enabled !== true) { + return [finding("skip", "Google Chat enabled", "Google Chat channel not enabled")]; + } + + return [ + credsParseFinding(gc, context), + await subscriptionOrIngressFinding(gc, context.gatewayUrl), + await recentInboundFinding(), + ...emailAllowFromLintFindings(gc), + ]; + }, +}; From 5c573ae50814da7660ba51647ce96b3678382286 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 15:58:08 +0300 Subject: [PATCH 170/200] test(238-07): failing caps + payloads unit tests for the Google Chat emulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - googlechat-caps.test.ts: the flat ChannelCaps descriptor + the caps↔adapter drift tripwire (reconciles the emulator flags against the real createGoogleChatPlugin().capabilities field-by-field) - googlechat-payloads.test.ts: MESSAGE / attachment builders round-tripped through the real mapGoogleChatEventToNormalized, plus the CARD_CLICKED wire shape + deterministic event-id source RED: both modules do not exist yet (module-not-found). --- .../googlechat/googlechat-caps.test.ts | 156 +++++++++++++++ .../googlechat/googlechat-payloads.test.ts | 177 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 test/live/emulators/googlechat/googlechat-caps.test.ts create mode 100644 test/live/emulators/googlechat/googlechat-payloads.test.ts diff --git a/test/live/emulators/googlechat/googlechat-caps.test.ts b/test/live/emulators/googlechat/googlechat-caps.test.ts new file mode 100644 index 000000000..c306bb89c --- /dev/null +++ b/test/live/emulators/googlechat/googlechat-caps.test.ts @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Stage-A contract test for the Google Chat capability descriptor + the + * caps↔adapter reconciliation (the drift tripwire applied to the Google Chat + * channel). + * + * `googlechat-caps.ts` carries the FLAT emulator `ChannelCaps`; the real + * production adapter declares a NESTED `ChannelCapability` (channel-capability.ts: + * `features{}`/`limits{}`/`replyToMetaKey`). This test is the DRIFT TRIPWIRE: it + * imports the adapter's OWN declared capabilities from `@comis/channels` (via + * `createGoogleChatPlugin(...).capabilities`, the exported surface that returns + * the module-local `CAPABILITIES`) and asserts the overlapping fields reconcile + * field-by-field. If the adapter ever flips a feature flag or changes + * `maxMessageChars`, this test fails LOUDLY — the emulator's caps can never + * silently drift from the real adapter. + * + * THE KEY GOOGLE CHAT DIFFERENCES vs Teams: + * - `features.reactions: false` — a service-account app reaches no reaction + * surface at all (neither inbound nor outbound), so BOTH `inbound.reactions` + * and `outbound.reactions` are `false` (unlike Teams, where reactions are an + * inbound capability). + * - `features.buttons: "cardsv2"` (a non-"none" flavour) → `outbound.buttons: true`, + * and a Cards v2 click is an INBOUND event (`inbound.buttons: true`). + * - `features.attachments: false` / `features.typing: false` — outbound upload + * and typing indicators are app-auth-unreachable. + * + * `@comis/channels` resolves from `dist/` via the live vitest alias, so this + * reads the REAL built adapter declaration (run `pnpm build` first if stale). + * + * Run under the LIVE vitest config (the bare root config excludes `test/live`): + * pnpm vitest run -c test/live/vitest.config.ts \ + * test/live/emulators/googlechat/googlechat-caps.test.ts + * + * @module + */ + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import { createGoogleChatPlugin } from "@comis/channels"; +import type { ChannelCapability } from "@comis/core"; +import { createMockLogger } from "../../../support/mock-logger.js"; +import { googlechatCaps, GOOGLECHAT_MAX_MESSAGE_CHARS } from "./googlechat-caps.js"; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const CAPS_SOURCE = resolve(HERE, "googlechat-caps.ts"); + +/** + * The adapter's REAL declared capabilities (the reconciliation TARGET). Built via + * the exported plugin factory — the Google Chat factory is lazy (it constructs + * the adapter + token provider but opens no pull loop and mints no token until an + * outbound send), so a bare construction safely reads the module-local + * `CAPABILITIES` declaration. + */ +function adapterCapabilities(): ChannelCapability { + const plugin = createGoogleChatPlugin({ + serviceAccountKey: "{}", + subscriptionName: "projects/test-project/subscriptions/comis-inbound", + allowFrom: [], + allowMode: "open", + logger: createMockLogger(), + }); + return plugin.capabilities; +} + +describe("googlechat-caps — Google Chat ChannelCaps descriptor", () => { + it("is a flat ChannelCaps for googlechat over http with the reconciled message limit", () => { + expect(googlechatCaps.channel).toBe("googlechat"); + expect(googlechatCaps.protocol).toBe("http"); + expect(googlechatCaps.inbound).toBeDefined(); + expect(googlechatCaps.outbound).toBeDefined(); + // The reconciled limit lives as a sibling const (the flat shape has no slot). + expect(GOOGLECHAT_MAX_MESSAGE_CHARS).toBe(4000); + }); + + it("declares the Google Chat surface: Cards v2 buttons in+out, threads, edit/delete, NO reactions either way", () => { + // GOOGLE CHAT: a service-account app has no reaction surface — both the + // inbound and the outbound reaction flags are honestly false (unlike Teams, + // where reactions are an inbound capability). + expect(googlechatCaps.inbound.reactions).toBe(false); + expect(googlechatCaps.outbound.reactions).toBe(false); + // Cards v2 button clicks are an inbound event (CARD_CLICKED); the bot renders + // Cards v2 interactive buttons outbound. + expect(googlechatCaps.inbound.buttons).toBe(true); + expect(googlechatCaps.outbound.buttons).toBe(true); + // Threaded replies route through the send path — supported both ways. + expect(googlechatCaps.inbound.threads).toBe(true); + expect(googlechatCaps.outbound.threads).toBe(true); + // Edit/delete are supported outbound (a text-masked patch / a self-delete). + expect(googlechatCaps.outbound.edits).toBe(true); + expect(googlechatCaps.outbound.deletes).toBe(true); + // Outbound upload + typing are app-auth-unreachable — honestly false. + expect(googlechatCaps.outbound.attachments).toBe(false); + expect(googlechatCaps.outbound.typing).toBe(false); + }); +}); + +describe("googlechat-caps — caps↔adapter reconciliation (the drift tripwire)", () => { + it("reconciles the emulator's flat flags against the adapter's nested features field-by-field", () => { + const caps = adapterCapabilities(); + const f = caps.features; + + // GOOGLE CHAT: features.reactions is false (no reaction surface at all). + expect(googlechatCaps.outbound.reactions).toBe(f.reactions); // false + expect(googlechatCaps.inbound.reactions).toBe(false); // no inbound reaction path + + // emulator FLAT outbound ⇄ adapter NESTED features + expect(googlechatCaps.outbound.edits).toBe(f.editMessages); // true — text-masked patch + expect(googlechatCaps.outbound.deletes).toBe(f.deleteMessages); // true — self-delete + expect(googlechatCaps.outbound.attachments).toBe(f.attachments); // false — no upload + expect(googlechatCaps.outbound.typing).toBe(f.typing); // false — no typing API + expect(googlechatCaps.outbound.threads).toBe(f.threads); // true — threaded replies + + // emulator buttons:true ⇄ the adapter declares a non-"none" flavour + // ("cardsv2"). The Google Chat honest-support signal. + expect(f.buttons).toBe("cardsv2"); + expect(googlechatCaps.outbound.buttons).toBe(f.buttons === "none" ? false : true); + + // The reconciled message-length limit. + expect(GOOGLECHAT_MAX_MESSAGE_CHARS).toBe(caps.limits.maxMessageChars); // 4000 + + // The adapter declares NO inbound history-fetch surface (admin-approval-gated). + expect(f.fetchHistory).toBe(false); + }); + + it("asserts the EXACT adapter values (a drift in any flips this test red)", () => { + const caps = adapterCapabilities(); + expect(caps.features).toMatchObject({ + reactions: false, + editMessages: true, + deleteMessages: true, + fetchHistory: false, + attachments: false, + typing: false, + threads: true, + buttons: "cardsv2", + }); + expect(caps.limits.maxMessageChars).toBe(4000); + // The reply/edit/delete target metadata key the adapter self-declares. + expect(caps.replyToMetaKey).toBe("googlechatMessageName"); + }); + + it("documents the not-reconciled-yet inbound-only fields (the adapter has no broader inbound caps surface)", () => { + // These emulator-only inbound fields (beyond the button/thread overlap) are + // NOT asserted against the adapter (it declares no broader inbound capability + // surface). The reconciliation scope is the overlap only — this test proves + // they EXIST on the emulator caps, documented as not-reconciled-yet. + expect(googlechatCaps.inbound).toHaveProperty("text"); + expect(googlechatCaps.inbound).toHaveProperty("media"); + expect(googlechatCaps.inbound).toHaveProperty("threads"); + // The source explicitly documents the not-reconciled-yet boundary. + const src = readFileSync(CAPS_SOURCE, "utf8"); + expect(src).toMatch(/not-reconciled-yet/i); + }); +}); diff --git a/test/live/emulators/googlechat/googlechat-payloads.test.ts b/test/live/emulators/googlechat/googlechat-payloads.test.ts new file mode 100644 index 000000000..d7f769626 --- /dev/null +++ b/test/live/emulators/googlechat/googlechat-payloads.test.ts @@ -0,0 +1,177 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Stage-A unit tests for the Google Chat interaction-event payload builders. + * + * Two layers of proof: + * 1. RUNTIME shape — each builder emits exactly the fields the adapter's + * inbound path reads. + * 2. REAL-MAPPER round-trip — the message/attachment builders are fed through + * the adapter's OWN exported `mapGoogleChatEventToNormalized` (from + * `@comis/channels`, resolved from `dist/`) and the resulting + * `NormalizedMessage` is asserted. This is the fidelity tripwire: if the + * mapper's field reads drift, these fail. (The CARD_CLICKED builder + * round-trips end-to-end through the adapter's `handleChatEvent` in the + * scenario proof — its normalizer is not individually exported, so here we + * assert its wire shape + that the message mapper returns null for it, the + * way the adapter routes a click to the card-action path.) + * + * Run under the LIVE vitest config (the bare root config excludes `test/live`): + * pnpm vitest run -c test/live/vitest.config.ts \ + * test/live/emulators/googlechat/googlechat-payloads.test.ts + * + * @module + */ + +import { describe, expect, it, beforeEach } from "vitest"; +import { mapGoogleChatEventToNormalized } from "@comis/channels"; +import { + makeMessageEvent, + makeCardClickedEvent, + makeAttachmentEvent, + nextEventId, + resetEventIdCounter, + GOOGLECHAT_APPROVAL_FUNCTION, +} from "./googlechat-payloads.js"; + +beforeEach(() => { + resetEventIdCounter(); +}); + +describe("googlechat-payloads — makeMessageEvent (space text round-trip)", () => { + it("emits a MESSAGE event carrying the space, immutable sender, and text", () => { + const event = makeMessageEvent("hello from the emulator", { + space: "spaces/AAAA", + user: "users/123", + }); + expect(event.type).toBe("MESSAGE"); + expect(event.space?.name).toBe("spaces/AAAA"); + expect(event.message?.sender?.name).toBe("users/123"); + // argumentText is the mention-stripped text the mapper prefers over text. + expect(event.message?.argumentText ?? event.message?.text).toBe( + "hello from the emulator", + ); + // The stable message resource name (the pull-loop dedup key) is present. + expect(typeof event.message?.name).toBe("string"); + expect(event.message?.name).toMatch(/^spaces\/AAAA\/messages\//); + }); + + it("round-trips through the REAL adapter mapper to a NormalizedMessage (senderId = users/{id})", () => { + const event = makeMessageEvent("ping", { + space: "spaces/AAAA", + user: "users/123", + }); + const normalized = mapGoogleChatEventToNormalized(event); + expect(normalized).not.toBeNull(); + expect(normalized!.channelType).toBe("googlechat"); + // The adapter keys the allowlist on the immutable users/{id} resource name. + expect(normalized!.senderId).toBe("users/123"); + expect(normalized!.text).toBe("ping"); + expect(normalized!.channelId).toBe("spaces/AAAA"); + // A default SPACE spaceType maps to a "group" chat; the emulator can flip it. + expect(normalized!.chatType).toBe("group"); + }); + + it("maps a DIRECT_MESSAGE spaceType to a dm chatType", () => { + const event = makeMessageEvent("hi", { + space: "spaces/DM", + user: "users/9", + spaceType: "DIRECT_MESSAGE", + }); + const normalized = mapGoogleChatEventToNormalized(event); + expect(normalized!.chatType).toBe("dm"); + expect(normalized!.metadata.isGroup).toBe(false); + }); + + it("threads a reply via thread.name and flags a mention via a USER_MENTION annotation", () => { + const event = makeMessageEvent("do the thing", { + space: "spaces/AAAA", + user: "users/123", + thread: "spaces/AAAA/threads/T1", + mentioned: true, + }); + expect(event.message?.thread?.name).toBe("spaces/AAAA/threads/T1"); + const normalized = mapGoogleChatEventToNormalized(event); + // The generic threadId key drives inbound→outbound thread propagation. + expect(normalized!.metadata.threadId).toBe("spaces/AAAA/threads/T1"); + expect(normalized!.metadata.wasMentioned).toBe(true); + }); +}); + +describe("googlechat-payloads — makeAttachmentEvent (attachment round-trip)", () => { + it("emits an attachment with an attachmentDataRef the mapper rewrites to googlechat-attachment://", () => { + const event = makeAttachmentEvent({ + space: "spaces/AAAA", + user: "users/123", + resourceName: "spaces/AAAA/messages/CCC/attachments/D1", + contentType: "image/png", + contentName: "photo.png", + }); + expect(event.message?.attachment?.[0]?.attachmentDataRef?.resourceName).toBe( + "spaces/AAAA/messages/CCC/attachments/D1", + ); + const normalized = mapGoogleChatEventToNormalized(event); + expect(normalized!.attachments).toHaveLength(1); + expect(normalized!.attachments[0]?.url).toMatch(/^googlechat-attachment:\/\//); + expect(normalized!.attachments[0]?.fileName).toBe("photo.png"); + }); +}); + +describe("googlechat-payloads — makeCardClickedEvent (Cards v2 button click)", () => { + it("emits a CARD_CLICKED event carrying the invoked function, opaque callback, and verified clicker", () => { + const event = makeCardClickedEvent({ + space: "spaces/AAAA", + user: "users/approver", + callback: "signed-cb-blob", + }); + expect(event.type).toBe("CARD_CLICKED"); + // The invoked function defaults to the rendered approval verb. + expect(event.common?.invokedFunction ?? event.action?.actionMethodName).toBe( + GOOGLECHAT_APPROVAL_FUNCTION, + ); + // The opaque callback rides both the classic and the newer payload shapes. + expect(event.common?.parameters?.cb).toBe("signed-cb-blob"); + // The clicker identity is the verified user.name (never a parameter). + expect(event.user?.name).toBe("users/approver"); + // A CARD_CLICKED is NOT a MESSAGE — the message mapper returns null and the + // adapter routes it to the card-action normalizer instead. + expect( + mapGoogleChatEventToNormalized(event as unknown as Parameters[0]), + ).toBeNull(); + }); + + it("supports an arbitrary invoked function + a missing callback (the drop-path probes)", () => { + const event = makeCardClickedEvent({ + space: "spaces/AAAA", + user: "users/approver", + invokedFunction: "attacker.arbitrary.method", + callback: undefined, + }); + expect(event.common?.invokedFunction).toBe("attacker.arbitrary.method"); + expect(event.common?.parameters?.cb).toBeUndefined(); + }); +}); + +describe("googlechat-payloads — event-id source + scope guard", () => { + it("mints strictly-increasing event ids and resets deterministically", () => { + resetEventIdCounter(); + const a = nextEventId(); + const b = nextEventId(); + expect(a).toBe(1001); + expect(b).toBe(1002); + resetEventIdCounter(); + expect(nextEventId()).toBe(1001); + }); + + it("only mints the two in-scope event kinds (MESSAGE / CARD_CLICKED)", () => { + const kinds = new Set([ + makeMessageEvent("t", { space: "spaces/A", user: "users/1" }).type, + makeAttachmentEvent({ + space: "spaces/A", + user: "users/1", + resourceName: "spaces/A/messages/C/attachments/D", + }).type, + makeCardClickedEvent({ space: "spaces/A", user: "users/1" }).type, + ]); + expect([...kinds].sort()).toEqual(["CARD_CLICKED", "MESSAGE"]); + }); +}); From d8293f00786b7fae6e25a4c9da6775ba433551d7 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 16:00:12 +0300 Subject: [PATCH 171/200] feat(238-07): Google Chat emulator caps + interaction-event builders - googlechat-caps.ts: the flat ChannelCaps descriptor (4000-char cap, Cards v2 buttons in+out, threads, edit/delete; NO reactions either way, NO outbound upload/typing) + the field-by-field reconciliation map - googlechat-payloads.ts: makeMessageEvent / makeAttachmentEvent return-annotated against the adapter GoogleChatEvent (compile-time wire fidelity), makeCardClickedEvent, and a deterministic nextEventId source - channel-emulator.ts: add "googlechat" to the shared ChannelCaps channel union (additive; the harness is designed for a new channel to extend it) GREEN: caps + payloads unit tests pass (14/14). --- .../emulators/googlechat/googlechat-caps.ts | 114 ++++++++ .../googlechat/googlechat-payloads.ts | 273 ++++++++++++++++++ test/live/harness/channel-emulator.ts | 3 +- 3 files changed, 389 insertions(+), 1 deletion(-) create mode 100644 test/live/emulators/googlechat/googlechat-caps.ts create mode 100644 test/live/emulators/googlechat/googlechat-payloads.ts diff --git a/test/live/emulators/googlechat/googlechat-caps.ts b/test/live/emulators/googlechat/googlechat-caps.ts new file mode 100644 index 000000000..4f04fb1db --- /dev/null +++ b/test/live/emulators/googlechat/googlechat-caps.ts @@ -0,0 +1,114 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * `googlechat-caps` — the Google Chat capability descriptor + the caps↔adapter + * reconciliation seam (the Google Chat mirror of `msteams-caps.ts`). + * + * Two capability shapes exist in this codebase and they DIFFER: + * + * - The emulator side (this file) is a FLAT `ChannelCaps`: + * `{ channel, inbound{}, outbound{}, protocol }`. + * - The production adapter (googlechat-plugin.ts `CAPABILITIES`, + * core/channel-capability.ts) is NESTED `ChannelCapability`: + * `{ features{}, limits{}, replyToMetaKey }`. + * + * By design, this file carries the flat descriptor AND the reconciliation map; + * the contract test (`googlechat-caps.test.ts`) reads the adapter's REAL declared + * capabilities from `@comis/channels` (via `createGoogleChatPlugin(...).capabilities`) + * and asserts the overlapping fields match — a drift tripwire so the emulator's + * caps can never silently diverge from the adapter's self-declaration. + * + * THE KEY GOOGLE CHAT DIFFERENCE vs Teams: a service-account app reaches NO + * reaction surface at all — `features.reactions` is `false`, so BOTH + * `inbound.reactions` and `outbound.reactions` are honestly `false` (Teams maps a + * `true` `features.reactions` to its INBOUND messageReaction path; Google Chat has + * neither an inbound nor an outbound reaction). A Cards v2 click is an INBOUND + * event (`inbound.buttons: true`) and the bot renders Cards v2 buttons outbound + * (`features.buttons: "cardsv2"` → `outbound.buttons: true`), and threaded replies + * route through the send path (`features.threads: true`). Outbound upload and + * typing indicators are app-auth-unreachable (`features.attachments`/`typing` + * false). + * + * --- FIELD-BY-FIELD MAP (emulator FLAT ⇄ adapter NESTED) --- + * inbound.reactions:false ⇄ (no inbound reaction path — GOOGLE CHAT: none at all) + * outbound.reactions == features.reactions (false — no send-reaction API) + * outbound.edits == features.editMessages (true — text-masked messages.patch) + * outbound.deletes == features.deleteMessages (true — messages.delete of the bot's own) + * outbound.attachments == features.attachments (false — outbound upload is user-auth-only) + * outbound.typing == features.typing (false — no typing API) + * outbound.threads == features.threads (true — threaded reply on the send path) + * outbound.buttons:true ⇄ features.buttons !== "none" (true ⇄ "cardsv2") + * GOOGLECHAT_MAX_MESSAGE_CHARS == limits.maxMessageChars (4000) + * (inbound has no history claim) ⇄ features.fetchHistory (false) + * + * --- NOT-RECONCILED-YET (documented) --- + * The emulator's inbound-only fields other than the button/thread overlap + * (`inbound.text` / `inbound.media` / `inbound.edits` / `inbound.slashCommands` / + * `inbound.location`) have no counterpart in the adapter's capability surface (it + * declares no inbound caps beyond the overlap above). They are deliberately NOT + * asserted against `CAPABILITIES` — the reconciliation scope is the overlap only. + * `outbound.richCards` (Cards v2) has no dedicated `features` field either (it + * rides `features.buttons: "cardsv2"`), so it is documented, not reconciled. + * + * TEST-HARNESS — lives under `test/`, never `packages`; ZERO production code + * change. + * + * @module + */ + +import type { ChannelCaps } from "../../harness/channel-emulator.js"; + +/** + * The reconciled Google Chat message-length limit (the reconciliation seam). + * + * The flat `ChannelCaps` shape carries no `maxMessageChars` field, so the + * reconciled value lives here as a sibling const. The contract test asserts it + * equals the adapter's `limits.maxMessageChars` (googlechat-plugin.ts), so a + * drift in the adapter's limit flips the test red. + */ +export const GOOGLECHAT_MAX_MESSAGE_CHARS = 4000; + +/** + * The Google Chat emulator capability descriptor (the flat design-side shape). + * + * `outbound.*` mirrors the adapter's `features.*` (the reconciled overlap — see + * the field map above). `inbound.*` describes the emulator's inbound surface: + * text, media (images/voice/documents/video resolved via the + * `googlechat-attachment://` resolver), Cards v2 button clicks (CARD_CLICKED + * events), and space threads. It has no reaction path (a service-account app + * cannot react), no inbound edit path, no slash-command kind (a slash command + * arrives as a MESSAGE event), and no location messages — all honestly false. + */ +export const googlechatCaps: ChannelCaps = { + channel: "googlechat", + protocol: "http", + inbound: { + // Google Chat delivers inbound text (MESSAGE events → mapGoogleChatEventToNormalized), + // media attachments (attachmentDataRef.resourceName → googlechat-attachment:// + // resolver), Cards v2 button clicks (CARD_CLICKED events with the rendered + // approval verb), and space threads (message.thread.name). It has no inbound + // reaction path, no inbound edit path, no distinct slash-command kind, and no + // location messages — represented as false (honest, not omitted). + text: true, + media: ["photo", "voice", "document", "video"], + // GOOGLE CHAT: no reaction surface at all (unlike Teams, where reactions are inbound). + reactions: false, + edits: false, + buttons: true, // Cards v2 button click is an inbound CARD_CLICKED event. + threads: true, + slashCommands: false, + location: false, + }, + outbound: { + // A service-account app has no send-reaction API — reactToMessage/removeReaction + // are permanently omitted (googlechat-plugin.ts). == features.reactions (false). + reactions: false, + // RECONCILED field-by-field against the adapter's `features` (see the map). + edits: true, // == features.editMessages (text-masked messages.patch / edit-in-place) + deletes: true, // == features.deleteMessages (messages.delete of the bot's own message) + buttons: true, // ⇄ features.buttons === "cardsv2" (a non-"none" flavour → true) + attachments: false, // == features.attachments (outbound upload is user-auth-only) + typing: false, // == features.typing (no typing API) + threads: true, // == features.threads (threaded reply routes through the send path) + richCards: true, // Cards v2 render (rides features.buttons:"cardsv2"; documented, not reconciled). + }, +}; diff --git a/test/live/emulators/googlechat/googlechat-payloads.ts b/test/live/emulators/googlechat/googlechat-payloads.ts new file mode 100644 index 000000000..d0ec9c22a --- /dev/null +++ b/test/live/emulators/googlechat/googlechat-payloads.ts @@ -0,0 +1,273 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat classic interaction-event builders. + * + * The Google Chat emulator drives inbound by ENQUEUEING these builder-produced + * events onto its fake Pub/Sub subscription; the real production pull loop pulls + * them (base64-decoded from `receivedMessages[].message.data`), and the scenario + * proof round-trips them through the REAL production adapter + * (`mapGoogleChatEventToNormalized` / `normalizeGoogleChatCardAction`). Because + * the message/attachment builders import the adapter's OWN exported + * `GoogleChatEvent` wire interface (`type`-only from the `@comis/channels` barrel; + * defined in `googlechat/message-mapper.ts`) and return-annotate against it, every + * emitted MESSAGE event is *guaranteed* to be exactly the shape the adapter parses + * — a wire-shape drift becomes a COMPILE error here, not a silent runtime + * mismatch. That forecloses the hand-rolled-struct drift problem. The grammy-typed + * Telegram twin is `tg-payloads.ts`; the TeamsActivity-typed Teams twin is + * `msteams-payloads.ts`. + * + * Scope (the event kinds the adapter's inbound path routes — googlechat-adapter.ts + * handleChatEvent): + * - `type: "MESSAGE"` — the space/DM text round-trip (+ optional mention, + * thread root, and an `attachmentDataRef` media attachment). + * - `type: "CARD_CLICKED"` — a Cards v2 button click, routed to + * `normalizeGoogleChatCardAction` (the rendered `comis.approval.resolve` verb). + * Out-of-scope event kinds (ADDED_TO_SPACE, REMOVED_FROM_SPACE, …) are deliberately + * NOT minted — the adapter maps them to null, so building them would be dead + * payloads. + * + * SECURITY: the `GoogleChatEvent` import is `type`-only (erased at build) so this + * file carries NO `@comis/*` runtime edge into the never-published harness. + * + * TEST-HARNESS — lives under `test/`, never `packages`; ZERO production runtime + * change. + * + * @module + */ + +import type { GoogleChatEvent } from "@comis/channels"; + +/** + * The action-method name the approval card renders on its Cards v2 buttons — the + * only method in the adapter's rendered set (googlechat-actions.ts + * `RENDERED_FUNCTIONS`). A click naming any other method is dropped before it + * becomes a message; the scenario proof round-trips this verb through the real + * adapter, so a drift in the rendered set fails there. Kept a local const because + * the adapter does not export it on the `@comis/channels` barrel (its only + * consumer would be this harness — a dead-export governance violation). + */ +export const GOOGLECHAT_APPROVAL_FUNCTION = "comis.approval.resolve"; + +/** + * Minimal CARD_CLICKED interaction-event shape — the fields the adapter's + * `normalizeGoogleChatCardAction` reads. Defined here rather than imported from + * the barrel because the adapter does not export its `GoogleChatCardClickEvent` + * type (its only consumer would be this harness). The MESSAGE builders below + * still return-annotate against the adapter's exported `GoogleChatEvent`, so the + * MESSAGE wire shape is compile-checked; the CARD_CLICKED shape is a small, stable + * local mirror validated end-to-end by the scenario round-trip. + */ +export interface GoogleChatCardClickEvent { + /** Event kind — always "CARD_CLICKED" here. */ + type: "CARD_CLICKED"; + /** The acting user; the ONLY source of the verified clicker id. */ + user?: { name?: string }; + /** The space the click happened in ("spaces/AAAA"). */ + space?: { name?: string }; + /** The clicked card message ("spaces/AAAA/messages/CCCC") — the pull-loop dedup key. */ + message?: { name?: string }; + /** Classic click payload: the invoked method plus a `{key,value}` parameter list. */ + action?: { + actionMethodName?: string; + parameters?: Array<{ key?: string; value?: string }>; + }; + /** Newer click payload: the same invoked method plus a keyed parameter map. */ + common?: { + invokedFunction?: string; + parameters?: Record; + }; +} + +/** + * Module-level strictly-monotonic event-id source. + * + * Chat message resource names are opaque; the emulator mints an increasing suffix + * so a suite gets strictly-ordered message names (`spaces/X/messages/`) without + * managing a counter. The Pub/Sub ackIds are minted separately by the emulator. + */ +let eventIdCounter = 1_000; + +/** Return the next strictly-increasing event id (used to mint message resource names). */ +export function nextEventId(): number { + eventIdCounter += 1; + return eventIdCounter; +} + +/** + * Reset the {@link nextEventId} counter to its base (so the next call returns + * 1001). For per-test isolation when a suite needs a deterministic sequence. + */ +export function resetEventIdCounter(): void { + eventIdCounter = 1_000; +} + +/** Options for {@link makeMessageEvent}. */ +export interface MakeMessageEventOpts { + /** The space resource name — the routing `channelId` ("spaces/AAAA"). */ + readonly space: string; + /** The immutable sender resource id ("users/123") — the allowlist key. */ + readonly user: string; + /** The thread resource name a reply threads under ("spaces/AAAA/threads/T"). Omitted when absent. */ + readonly thread?: string; + /** + * DIRECT_MESSAGE → a "dm" chatType (isGroup false); anything else is a "group" + * space. Defaults to SPACE (a multi-person space → group). + */ + readonly spaceType?: "SPACE" | "DIRECT_MESSAGE" | "GROUP_CHAT"; + /** When true, add a USER_MENTION annotation so the mapper flags `wasMentioned`. */ + readonly mentioned?: boolean; + /** Explicit message resource name (the pull-loop dedup key). Defaults to a monotonic emulator name. */ + readonly name?: string; +} + +/** + * Build a classic Chat `MESSAGE` interaction event (the space/DM text round-trip). + * + * The return annotation IS the adapter's OWN `GoogleChatEvent` (the compile-time + * tripwire): a drift in the wire interface (`message-mapper.ts`) fails to compile + * here. The text is set on BOTH `text` and `argumentText` (the mapper prefers + * `argumentText`, the mention-stripped form). `thread` sets `message.thread.name`; + * `mentioned` adds the `USER_MENTION` annotation the mapper reads. + */ +export function makeMessageEvent( + text: string, + opts: MakeMessageEventOpts, +): GoogleChatEvent { + const name = opts.name ?? `${opts.space}/messages/${nextEventId()}`; + const spaceType = opts.spaceType ?? "SPACE"; + return { + type: "MESSAGE", + eventTime: "2026-01-01T00:00:00Z", + user: { name: opts.user }, + space: { name: opts.space, spaceType }, + message: { + name, + sender: { name: opts.user }, + text, + // The platform strips the app mention into argumentText; set it so the + // mapper's preferred field carries the faithful command text. + argumentText: text, + space: { name: opts.space, spaceType }, + ...(opts.thread !== undefined ? { thread: { name: opts.thread } } : {}), + ...(opts.mentioned === true + ? { annotations: [{ type: "USER_MENTION" }] } + : {}), + }, + }; +} + +/** Options for {@link makeAttachmentEvent}. */ +export interface MakeAttachmentEventOpts { + /** The space resource name — the routing `channelId` ("spaces/AAAA"). */ + readonly space: string; + /** The immutable sender resource id ("users/123"). */ + readonly user: string; + /** + * The downloadable attachment resource name (`attachmentDataRef.resourceName`) + * the mapper rewrites to a `googlechat-attachment://` ref. Present here → the + * attachment is surfaced (not skipped). + */ + readonly resourceName: string; + /** MIME type (`contentType` → the normalized attachment type). Defaults to image/png. */ + readonly contentType?: string; + /** The original filename (`contentName` → `fileName`). Defaults to file.png. */ + readonly contentName?: string; + /** Optional caption text alongside the attachment. */ + readonly text?: string; + /** Explicit message resource name (the pull-loop dedup key). Defaults to a monotonic emulator name. */ + readonly name?: string; +} + +/** + * Build a classic Chat `MESSAGE` event carrying an inbound `attachmentDataRef` + * attachment (the media-pipeline round-trip). + * + * The return annotation IS the adapter's OWN `GoogleChatEvent` — the `attachment[]` + * shape (SINGULAR field name, `message-mapper.ts`) the mapper rewrites to the + * `googlechat-attachment://` scheme. The attachment carries a downloadable + * `attachmentDataRef.resourceName`, so it is surfaced rather than skipped. + */ +export function makeAttachmentEvent( + opts: MakeAttachmentEventOpts, +): GoogleChatEvent { + const name = opts.name ?? `${opts.space}/messages/${nextEventId()}`; + const contentType = opts.contentType ?? "image/png"; + const contentName = opts.contentName ?? "file.png"; + return { + type: "MESSAGE", + eventTime: "2026-01-01T00:00:00Z", + user: { name: opts.user }, + space: { name: opts.space, spaceType: "SPACE" }, + message: { + name, + sender: { name: opts.user }, + ...(opts.text !== undefined ? { text: opts.text, argumentText: opts.text } : {}), + space: { name: opts.space, spaceType: "SPACE" }, + attachment: [ + { + name: `${name}/attachments/0`, + contentName, + contentType, + source: "UPLOADED_CONTENT", + attachmentDataRef: { resourceName: opts.resourceName }, + }, + ], + }, + }; +} + +/** Options for {@link makeCardClickedEvent}. */ +export interface MakeCardClickedEventOpts { + /** The space the click happened in ("spaces/AAAA"). */ + readonly space: string; + /** The verified clicker resource id ("users/123") — the only source of identity. */ + readonly user: string; + /** + * The opaque signed callback (`common.parameters.cb` + the classic + * `action.parameters[cb]`). Opaque to the emulator; the adapter validates the + * signature downstream. Set `undefined` to exercise the missing-callback drop. + * Defaults to a fixed non-empty blob. + */ + readonly callback?: string; + /** + * The invoked action-method. Defaults to the rendered {@link GOOGLECHAT_APPROVAL_FUNCTION}; + * pass another to exercise the unrendered-method drop. + */ + readonly invokedFunction?: string; + /** The clicked card message resource name (also the pull-loop dedup key). Defaults to a monotonic emulator name. */ + readonly messageName?: string; +} + +/** + * Build a `CARD_CLICKED` interaction event (a Cards v2 button click). + * + * The event carries the invoked function + the opaque callback in BOTH the classic + * `action` object and the newer `common` object (the adapter reads either), and + * the clicker identity ONLY on the verified `user.name` — never a parameter, + * matching the adapter's default-deny gate. `callback: undefined` omits the cb + * (the missing-callback drop probe); a non-rendered `invokedFunction` exercises + * the unrendered-method drop. Round-tripped end-to-end through the real adapter in + * the scenario proof. + */ +export function makeCardClickedEvent( + opts: MakeCardClickedEventOpts, +): GoogleChatCardClickEvent { + const fn = opts.invokedFunction ?? GOOGLECHAT_APPROVAL_FUNCTION; + const cb = "callback" in opts ? opts.callback : "signed-cb-blob"; + const messageName = + opts.messageName ?? `${opts.space}/messages/${nextEventId()}`; + return { + type: "CARD_CLICKED", + user: { name: opts.user }, + space: { name: opts.space }, + message: { name: messageName }, + action: { + actionMethodName: fn, + ...(cb !== undefined ? { parameters: [{ key: "cb", value: cb }] } : {}), + }, + common: { + invokedFunction: fn, + ...(cb !== undefined ? { parameters: { cb } } : {}), + }, + }; +} diff --git a/test/live/harness/channel-emulator.ts b/test/live/harness/channel-emulator.ts index f86d4350c..d422363db 100644 --- a/test/live/harness/channel-emulator.ts +++ b/test/live/harness/channel-emulator.ts @@ -56,7 +56,8 @@ export interface ChannelCaps { | "line" | "irc" | "email" - | "msteams"; + | "msteams" + | "googlechat"; /** What the channel can deliver INTO the agent (inbound surface). */ inbound: { text: boolean; From 4f012939d9d66bc8fe65956cb2da4b435d6b278c Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 16:03:11 +0300 Subject: [PATCH 172/200] test(238-07): failing unit test for the Google Chat wire emulator Proves (against the REAL adapter exports) the emulator's fake surfaces: - outbound token: the fake SA key JSON is minted by the real createGoogleChatTokenProvider against the emulator token endpoint - Chat REST oracle: create/edit/delete recorded per-space with wire text - Pub/Sub :pull serves an injected event (base64), :acknowledge acks it - inbound JWT: signInboundToken is accepted by the real local-JWKS verifier, rejected for wrong audience / mismatched key - /emu control surface: sign-token / outbound / pubsub-inject RED: the emulator module does not exist yet (module-not-found). --- .../googlechat/googlechat-emulator.test.ts | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 test/live/emulators/googlechat/googlechat-emulator.test.ts diff --git a/test/live/emulators/googlechat/googlechat-emulator.test.ts b/test/live/emulators/googlechat/googlechat-emulator.test.ts new file mode 100644 index 000000000..9a4d87353 --- /dev/null +++ b/test/live/emulators/googlechat/googlechat-emulator.test.ts @@ -0,0 +1,299 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Stage-A unit tests for the Google Chat wire emulator. + * + * Proves the emulator's fake surfaces in isolation (no adapter, no daemon): + * 1. OUTBOUND TOKEN FIDELITY — the emulator's fake service-account key JSON is + * minted into an access token by the adapter's OWN + * `createGoogleChatTokenProvider` (from `@comis/channels`) when its `tokenUrl` + * is pointed at the emulator. This is the load-bearing outbound proof: the + * real send path mints exactly this way, so a key + token endpoint that + * satisfy the real provider here satisfy the booted adapter. + * 2. CHAT REST ORACLE — a create/edit/delete REST call to the fake Chat API is + * recorded to the per-space oracle with the wire text. + * 3. PUB/SUB PULL/ACK — an injected inbound event is served (base64) on `:pull` + * and removed on `:acknowledge` (the ack contract). + * 4. INBOUND JWT SIGNER — the emulator's signed Bearer is accepted by the + * adapter's OWN local-JWKS inbound verifier and rejected for a wrong audience + * / mismatched key (the webhook-mode trust anchor). + * + * Run under the LIVE vitest config (the bare root config excludes `test/live`): + * pnpm vitest run -c test/live/vitest.config.ts \ + * test/live/emulators/googlechat/googlechat-emulator.test.ts + * + * @module + */ + +import { readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + createGoogleChatTokenProvider, + createLocalGoogleChatInboundVerifier, + CHAT_SCOPE, +} from "@comis/channels"; +import { createMockLogger } from "../../../support/mock-logger.js"; +import { + createGoogleChatEmulator, + registerGoogleChatDriveControl, +} from "./googlechat-emulator.js"; + +let active: ReturnType | undefined; +let running: Awaited["start"]>> | undefined; + +afterEach(async () => { + if (active) await active.stop(); + active = undefined; + running = undefined; +}); + +async function boot() { + active = createGoogleChatEmulator(); + running = await active.start(); + return { emu: active, apiRoot: running.apiRoot }; +} + +/** Build the REAL local-JWKS inbound verifier over the emulator's publicJwks(). */ +function verifierFor(emu: ReturnType) { + return createLocalGoogleChatInboundVerifier( + emu.publicJwks() as unknown as Parameters< + typeof createLocalGoogleChatInboundVerifier + >[0], + { audienceType: "project-number", audience: emu.projectNumber }, + ); +} + +describe("googlechat-emulator — boot + loopback bind", () => { + it("boots on a loopback-only kernel-allocated port", async () => { + const { apiRoot } = await boot(); + expect(apiRoot).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/); + }); +}); + +describe("googlechat-emulator — outbound token fidelity (the REAL token provider)", () => { + it("mints a fake SA key the adapter's OWN token provider exchanges for an access token", async () => { + const { emu, apiRoot } = await boot(); + // The fake SA key parses as a service-account key JSON. + const keyJson = JSON.parse(emu.fakeServiceAccountKeyJson()) as { + client_email?: string; + private_key?: string; + }; + expect(typeof keyJson.client_email).toBe("string"); + expect(keyJson.private_key).toMatch(/BEGIN PRIVATE KEY/); + + // The REAL token provider imports the key, signs an assertion, and exchanges + // it at the emulator's token endpoint — the exact outbound-auth path. + const provider = createGoogleChatTokenProvider({ + serviceAccountKey: emu.fakeServiceAccountKeyJson(), + logger: createMockLogger(), + tokenUrl: `${apiRoot}/token`, + }); + expect(emu.tokenMintCount()).toBe(0); + const tok = await provider.getToken(CHAT_SCOPE); + expect(tok.ok).toBe(true); + expect(emu.tokenMintCount()).toBe(1); + }); +}); + +describe("googlechat-emulator — fake Chat REST outbound oracle", () => { + const SPACE = "spaces/AAAA"; + + async function chatFetch( + apiRoot: string, + resource: string, + method: string, + body?: unknown, + ) { + return fetch(`${apiRoot}/${resource}`, { + method, + headers: { + authorization: "Bearer emulator-token", + "content-type": "application/json", + }, + ...(method === "DELETE" ? {} : { body: JSON.stringify(body ?? {}) }), + }); + } + + it("records a messages.create send with the wire text + returns a message name", async () => { + const { emu, apiRoot } = await boot(); + const res = await chatFetch(apiRoot, `${SPACE}/messages`, "POST", { + text: "bot reply", + }); + const json = (await res.json()) as { name?: string }; + expect(res.status).toBe(200); + expect(json.name).toMatch(/^spaces\/AAAA\/messages\//); + const out = emu.outbound(SPACE); + expect(out).toHaveLength(1); + expect(out[0]).toMatchObject({ op: "send", text: "bot reply" }); + expect(emu.lastBotReply(SPACE)?.text).toBe("bot reply"); + }); + + it("flags a cardsV2 body on the recorded send", async () => { + const { emu, apiRoot } = await boot(); + await chatFetch(apiRoot, `${SPACE}/messages`, "POST", { + text: "approve?", + cardsV2: [{ cardId: "c1", card: { sections: [] } }], + }); + expect(emu.lastBotReply(SPACE)?.hasCards).toBe(true); + }); + + it("records an edit (PATCH) and a delete (DELETE) keyed by the space", async () => { + const { emu, apiRoot } = await boot(); + await chatFetch(apiRoot, `${SPACE}/messages/5001?updateMask=text`, "PATCH", { + text: "v2", + }); + await chatFetch(apiRoot, `${SPACE}/messages/5001`, "DELETE"); + const ops = emu.outbound(SPACE).map((o) => o.op); + expect(ops).toEqual(["edit", "delete"]); + expect(emu.outbound(SPACE)[0]?.text).toBe("v2"); + }); + + it("isolates spaces and resets a space's oracle", async () => { + const { emu, apiRoot } = await boot(); + await chatFetch(apiRoot, `${SPACE}/messages`, "POST", { text: "x" }); + expect(emu.outbound(SPACE)).toHaveLength(1); + // An unseen space is an honest empty (never a cross-space leak). + expect(emu.outbound("spaces/other")).toHaveLength(0); + emu.resetChat(SPACE); + expect(emu.outbound(SPACE)).toHaveLength(0); + }); +}); + +describe("googlechat-emulator — fake Pub/Sub pull + acknowledge", () => { + it("serves an injected inbound event on :pull as a base64 receivedMessage, then acks it", async () => { + const { emu, apiRoot } = await boot(); + const SUB = "projects/test-project/subscriptions/comis-inbound"; + const event = { type: "MESSAGE", message: { name: "spaces/AAAA/messages/1" } }; + emu.injectInbound(event); + expect(emu.pendingCount()).toBe(1); + + const pullRes = await fetch(`${apiRoot}/${SUB}:pull`, { + method: "POST", + headers: { authorization: "Bearer t", "content-type": "application/json" }, + body: JSON.stringify({ maxMessages: 10 }), + }); + const pullBody = (await pullRes.json()) as { + receivedMessages?: Array<{ ackId?: string; message?: { data?: string } }>; + }; + expect(pullRes.status).toBe(200); + expect(pullBody.receivedMessages).toHaveLength(1); + const rm = pullBody.receivedMessages![0]!; + // STANDARD base64 decode round-trips to the injected event (the loop's decode). + const decoded = JSON.parse( + Buffer.from(rm.message!.data!, "base64").toString("utf8"), + ) as { type?: string }; + expect(decoded.type).toBe("MESSAGE"); + + // Acknowledge removes it (the ack contract) — a re-pull is then empty. + expect(emu.ackedCount()).toBe(0); + await fetch(`${apiRoot}/${SUB}:acknowledge`, { + method: "POST", + headers: { authorization: "Bearer t", "content-type": "application/json" }, + body: JSON.stringify({ ackIds: [rm.ackId] }), + }); + expect(emu.ackedCount()).toBe(1); + expect(emu.pendingCount()).toBe(0); + }); +}); + +describe("googlechat-emulator — inbound JWT signer (the REAL local-JWKS verifier)", () => { + it("mints a Bearer the adapter's OWN local verifier ACCEPTS against publicJwks()", async () => { + const { emu } = await boot(); + const verify = verifierFor(emu); + const token = await emu.signInboundToken(); + expect((await verify(`Bearer ${token}`)).ok).toBe(true); + }); + + it("is REJECTED for a wrong audience (aud != configured project number)", async () => { + const { emu } = await boot(); + const verify = verifierFor(emu); + const forged = await emu.signInboundToken({ audience: "999999999999" }); + expect((await verify(`Bearer ${forged}`)).ok).toBe(false); + }); + + it("is REJECTED when verified against a DIFFERENT emulator's JWKS (mismatched key)", async () => { + const { emu } = await boot(); + const other = createGoogleChatEmulator(); + const verifyWrong = createLocalGoogleChatInboundVerifier( + other.publicJwks() as unknown as Parameters< + typeof createLocalGoogleChatInboundVerifier + >[0], + { audienceType: "project-number", audience: emu.projectNumber }, + ); + const token = await emu.signInboundToken(); + expect((await verifyWrong(`Bearer ${token}`)).ok).toBe(false); + }); + + it("exposes a JWKS with the signing kid/alg/use and persists it to disk", async () => { + const { emu } = await boot(); + const jwks = emu.publicJwks(); + expect(jwks.keys).toHaveLength(1); + expect(jwks.keys[0]).toMatchObject({ alg: "RS256", use: "sig" }); + expect(typeof (jwks.keys[0] as { kid?: string }).kid).toBe("string"); + const file = join(tmpdir(), `googlechat-emu-jwks-${running!.port}.json`); + emu.writeJwksFile(file); + const onDisk = JSON.parse(readFileSync(file, "utf8")) as { keys: unknown[] }; + expect(onDisk.keys).toHaveLength(1); + }); +}); + +describe("googlechat-emulator — out-of-process drive-control surface", () => { + it("injects an inbound event over /emu/pubsub-inject and reads it back on :pull", async () => { + const { emu, apiRoot } = await boot(); + registerGoogleChatDriveControl(emu); + const SUB = "projects/test-project/subscriptions/comis-inbound"; + const res = await fetch(`${apiRoot}/emu/pubsub-inject`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "MESSAGE", message: { name: "spaces/AAAA/messages/9" } }), + }); + expect(res.status).toBe(200); + expect(emu.pendingCount()).toBe(1); + const pull = await fetch(`${apiRoot}/${SUB}:pull`, { + method: "POST", + headers: { authorization: "Bearer t", "content-type": "application/json" }, + body: JSON.stringify({ maxMessages: 10 }), + }); + const body = (await pull.json()) as { receivedMessages?: unknown[] }; + expect(body.receivedMessages).toHaveLength(1); + }); + + it("signs an inbound token the REAL verifier accepts, over /emu/sign-token", async () => { + const { emu, apiRoot } = await boot(); + registerGoogleChatDriveControl(emu); + const res = await fetch(`${apiRoot}/emu/sign-token`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({}), + }); + const { token } = (await res.json()) as { token: string }; + expect(typeof token).toBe("string"); + expect((await verifierFor(emu)(`Bearer ${token}`)).ok).toBe(true); + }); + + it("reads the Chat outbound oracle over /emu/outbound (with an afterCount cursor)", async () => { + const { emu, apiRoot } = await boot(); + registerGoogleChatDriveControl(emu); + const SPACE = "spaces/drive-read"; + await fetch(`${apiRoot}/${SPACE}/messages`, { + method: "POST", + headers: { authorization: "Bearer t", "content-type": "application/json" }, + body: JSON.stringify({ text: "reply one" }), + }); + const res = await fetch( + `${apiRoot}/emu/outbound?space=${encodeURIComponent(SPACE)}&afterCount=0`, + ); + const body = (await res.json()) as { + outbound: Array<{ text?: string }>; + total: number; + }; + expect(body.total).toBe(1); + expect(body.outbound[0]?.text).toBe("reply one"); + const res2 = await fetch( + `${apiRoot}/emu/outbound?space=${encodeURIComponent(SPACE)}&afterCount=1`, + ); + const body2 = (await res2.json()) as { outbound: unknown[] }; + expect(body2.outbound).toHaveLength(0); + }); +}); From c7da9cd714a25a929dbf9d0a286700e441226361 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 16:05:03 +0300 Subject: [PATCH 173/200] feat(238-07): the Google Chat wire emulator (token + Pub/Sub + Chat REST + JWKS) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createGoogleChatEmulator over the shared http-backend serves, on ONE loopback port, the three surfaces the real adapter drives via its base-URL DI seams (no host-rewrite fetch needed, unlike Teams): - token: opaque {access_token, expires_in}, mint-counted - Pub/Sub :pull (bounded long-poll, non-destructive) + :acknowledge (ack removes the queued message — the real ack contract) - Chat REST create/edit/delete → the per-space outbound oracle Routes match on path SHAPE so the same emulator serves the bare-origin in-process scenario AND the /chat/v1 · /pubsub/v1 · /token leg-prefixed daemon egress. One RS256 keypair backs both the fake SA key (PKCS#8 PEM) and the inbound webhook signer (publicJwks / signInboundToken). registerGoogleChatDriveControl adds /emu/pubsub-inject|sign-token|outbound|info. GREEN: emulator unit test passes (14/14). --- .../googlechat/googlechat-emulator.ts | 503 ++++++++++++++++++ 1 file changed, 503 insertions(+) create mode 100644 test/live/emulators/googlechat/googlechat-emulator.ts diff --git a/test/live/emulators/googlechat/googlechat-emulator.ts b/test/live/emulators/googlechat/googlechat-emulator.ts new file mode 100644 index 000000000..b7ce1bcfe --- /dev/null +++ b/test/live/emulators/googlechat/googlechat-emulator.ts @@ -0,0 +1,503 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * `GoogleChatEmulator` — the fake Google the REAL production Google Chat adapter + * talks to over loopback HTTP. Built ON the generalized `http-backend` base and + * `extends ChannelEmulator`. + * + * GOOGLE CHAT IS A PULL CHANNEL (like Telegram/Signal, unlike Teams' push): the + * adapter connects OUT to three fake surfaces the emulator serves on ONE loopback + * port, redirected via the adapter's shipped base-URL DI seams + * (`tokenUrl`/`pubsubBaseUrl`/`chatBaseUrl`) — so NO host-rewrite fetch is needed + * (unlike Teams' `connectorRedirectFetch`): + * + * - TOKEN: `POST {tokenUrl}` — the service-account JWT-bearer exchange. The + * emulator IS Google here, so it never verifies the assertion; it returns an + * opaque `{ access_token, expires_in }` and counts the mint. Both the chat.bot + * and pubsub scopes hit the same endpoint. + * - PUB/SUB (inbound): `POST {pubsubBaseUrl}/{sub}:pull` (long-poll) serves the + * queued inbound events as base64 `receivedMessages[].message.data`, and + * `POST {pubsubBaseUrl}/{sub}:acknowledge` removes the acked ones. A queued + * event stays until it is acked (the real ack contract) — the pull is + * non-destructive, so a skip-ack redelivers. + * - CHAT REST (outbound): `POST {chatBaseUrl}/spaces/{space}/messages` (create), + * `PATCH …/messages/{id}` (edit), `DELETE …/messages/{id}` — each recorded to + * the per-space outbound oracle (the dual-oracle read). + * + * The routes match on path SHAPE (a `:pull`/`:acknowledge` suffix, a `/token` + * suffix, a `/spaces/…/messages` segment), so ONE emulator serves the adapter + * whether the base URLs are the bare loopback origin (the in-process scenario) or + * carry the `/chat/v1` · `/pubsub/v1` · `/token` leg prefixes the daemon egress + * seam collapses onto one host (the full-daemon self-drive). + * + * Webhook mode is not exercised by the pull-driven scenario, but the emulator also + * holds an RS256 keypair and mints inbound Chat-event Bearers ({@link signInboundToken}) + * the adapter's OWN local-JWKS verifier accepts, so the webhook leg can round-trip + * offline via {@link publicJwks} / {@link writeJwksFile}. + * + * TEST-HARNESS — lives under `test/`, never `packages`; ZERO production runtime + * change. `@comis/*` is imported `type`-only elsewhere in the harness; this file + * imports none. + * + * @module + */ + +import { generateKeyPairSync, type KeyObject } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { SignJWT } from "jose"; +import { + createHttpBackend, + type HttpBackend, + type RouteContext, + type RouteResult, +} from "../../harness/backends/http-backend.js"; +import type { ChannelCaps, ChannelEmulator } from "../../harness/channel-emulator.js"; +import type { RecordedOutbound } from "../../harness/recorded-outbound.js"; +import { googlechatCaps } from "./googlechat-caps.js"; + +/** + * The issuer of a project-number-audience Chat event token — Google's Chat system + * service account. The adapter's `project-number` verifier defaults its expected + * issuer to this, so the emulator signs inbound tokens with it. A real code + * contract token (not build pre-history). + */ +const CHAT_SYSTEM_ISSUER = "chat@system.gserviceaccount.com"; +/** The signing key id, stamped on both the JWK and every minted inbound token header. */ +const EMULATOR_KID = "googlechat-emulator-key-1"; +/** The default Cloud project number the emulator signs inbound tokens for (the token `aud`). */ +const DEFAULT_PROJECT_NUMBER = "000000000001"; +/** The default service-account client email stamped on the fake SA key JSON. */ +const DEFAULT_CLIENT_EMAIL = "comis-emulator@test-project.iam.gserviceaccount.com"; +/** How many messages a single `:pull` serves at most (mirrors the loop's maxMessages). */ +const PULL_MAX_MESSAGES = 10; +/** Long-poll window on an empty `:pull` before returning empty (bounds the loop's re-poll). */ +const PULL_LONG_POLL_MS = 2_000; +/** Poll interval inside the long-poll wait. */ +const PULL_POLL_INTERVAL_MS = 15; + +/** + * A recorded outbound Chat REST mutation (the Google Chat superset of the shared + * {@link RecordedOutbound} — assignable to it, so the control-api + dual-oracle + * read the `{ method, messageId, text }` subset unchanged). + */ +export interface GoogleChatRecordedOutbound extends RecordedOutbound { + /** The Chat operation: create (send) / edit / delete. */ + readonly op: "send" | "edit" | "delete"; + /** The space resource name the mutation targeted ("spaces/AAAA"). */ + readonly space: string; + /** The message resource name (minted on create, the target on edit/delete). */ + readonly messageName?: string; + /** True when the body carried a `cardsV2` array (a Cards v2 render). */ + readonly hasCards?: boolean; + /** The thread resource name a threaded reply set (`body.thread.name`), when present. */ + readonly threadName?: string; +} + +/** Options for {@link createGoogleChatEmulator}. */ +export interface CreateGoogleChatEmulatorOptions { + /** The Cloud project number the emulator signs inbound tokens for. Defaults to {@link DEFAULT_PROJECT_NUMBER}. */ + readonly projectNumber?: string; + /** The service-account client email on the fake SA key. Defaults to {@link DEFAULT_CLIENT_EMAIL}. */ + readonly clientEmail?: string; +} + +/** + * `GoogleChatEmulator` — `ChannelEmulator` + the Google-specific outbound oracle, + * the Pub/Sub inbound queue, the fake SA key, and the inbound-token signer. + * `start()`/`stop()` delegate to the http-backend base. + * + * A Google Chat "chat" is the space resource name ("spaces/AAAA"); the per-space + * oracle keys on it (matching the adapter's `msg.channelId`). + */ +export interface GoogleChatEmulator extends ChannelEmulator { + /** + * The SHARED loopback http-backend base this emulator composes. Exposed so the + * rig / control API can register additional routes on the SAME loopback port. + * The emulator owns the base's lifecycle — `start()`/`stop()` delegate to it. + */ + readonly backend: HttpBackend; + /** The configured Cloud project number (the inbound token `aud`). */ + readonly projectNumber: string; + /** + * A parseable service-account key JSON (`client_email` + an unencrypted PKCS#8 + * `private_key`) the adapter's token provider imports and signs with. The token + * endpoint never verifies the resulting assertion — this key only needs to be a + * valid RS256 signing key jose can import. + */ + fakeServiceAccountKeyJson(): string; + /** + * The public JWKS the adapter's local-JWKS inbound verifier verifies against (a + * single RS256 signing key). Feed it to `createLocalGoogleChatInboundVerifier` + * in an in-process scenario, or persist it with {@link writeJwksFile} for the + * daemon's `COMIS_GOOGLECHAT_TEST_JWKS` seam. + */ + publicJwks(): { keys: JsonWebKey[] }; + /** Persist {@link publicJwks} to `filePath` (the daemon `COMIS_GOOGLECHAT_TEST_JWKS` seam reads it). */ + writeJwksFile(filePath: string): void; + /** + * Mint a signed inbound Chat-event Bearer (`iss=chat@system.gserviceaccount.com`, + * `aud=projectNumber`, RS256, 5-min expiry) the adapter's local-JWKS verifier + * accepts. Override `audience` to exercise the wrong-audience reject path, or + * `issuer` for a fully-synthetic issuer. + */ + signInboundToken(opts?: { audience?: string; issuer?: string }): Promise; + /** Enqueue an inbound event onto the fake Pub/Sub subscription (the pull queue). */ + injectInbound(event: unknown): void; + /** The full recorded outbound Chat log for a space, in order (the oracle). `[]` for an unseen space. */ + outbound(space: string): readonly GoogleChatRecordedOutbound[]; + /** The most recent recorded outbound for a space, or `undefined` (the dual-oracle read). */ + lastBotReply(space: string): GoogleChatRecordedOutbound | undefined; + /** Clear a space's recorded outbound (per-test isolation). */ + resetChat(space: string): void; + /** How many times the fake token endpoint was hit (proves the SA-token mint ran). */ + tokenMintCount(): number; + /** How many Pub/Sub messages have been acknowledged (proves the ack contract). */ + ackedCount(): number; + /** How many inbound events are still queued (un-acked). */ + pendingCount(): number; +} + +/** Parse a raw JSON body defensively (a malformed body → empty object). */ +function parseJson(body: string): Record { + if (body.length === 0) return {}; + try { + const parsed = JSON.parse(body) as unknown; + return typeof parsed === "object" && parsed !== null + ? (parsed as Record) + : {}; + } catch { + return {}; + } +} + +/** + * Match a Chat REST message path and pull out the space + optional message-id + * segment, tolerating an optional leg prefix (`/chat/v1`). Group 1 = the space + * resource name ("spaces/AAAA"); group 2 = the message-id segment on edit/delete. + */ +const CHAT_MESSAGE_RE = /\/(spaces\/[^/]+)\/messages(?:\/([^/]+))?$/; + +/** One queued Pub/Sub message (a base64 envelope + its ack id + the dedup name). */ +interface QueuedMessage { + ackId: string; + messageId: string; + data: string; +} + +/** + * Create the Google Chat wire emulator on the shared http-backend base. + * + * Composes `createHttpBackend()`, registers the token + Pub/Sub + Chat REST + * surfaces on the base's generalized path routes, and returns an object literal + * whose `caps`/`start`/`stop` delegate to the base plus the per-space outbound + * oracle, the inbound queue, and the inbound-token signer. The RS256 keypair is + * generated SYNCHRONOUSLY at construction (node:crypto), so the factory stays sync + * like the Telegram/Signal/Teams factories. + */ +export function createGoogleChatEmulator( + opts: CreateGoogleChatEmulatorOptions = {}, +): GoogleChatEmulator { + const backend: HttpBackend = createHttpBackend(); + const projectNumber = opts.projectNumber ?? DEFAULT_PROJECT_NUMBER; + const clientEmail = opts.clientEmail ?? DEFAULT_CLIENT_EMAIL; + + // ONE RSA keypair, generated synchronously (2048-bit, the RS256 the adapter + // pins). It serves BOTH roles the emulator needs — the private half is exported + // as the fake SA key's PKCS#8 PEM (the adapter's outbound token mint imports it) + // and is used to sign inbound webhook Bearers; the public half is the JWKS the + // adapter's local-JWKS inbound verifier verifies against. The two roles are + // never cross-checked (the token endpoint is opaque), so sharing one keypair is + // a harmless test simplification that halves keygen cost. + const { privateKey, publicKey }: { privateKey: KeyObject; publicKey: KeyObject } = + generateKeyPairSync("rsa", { modulusLength: 2048 }); + const saPrivateKeyPem = privateKey.export({ type: "pkcs8", format: "pem" }) as string; + const publicJwk = publicKey.export({ format: "jwk" }) as JsonWebKey; + const signingJwk: JsonWebKey = { ...publicJwk, kid: EMULATOR_KID, alg: "RS256", use: "sig" }; + + // Per-space ORACLE state (the outbound Chat REST mutation log), keyed on the + // space resource name (the adapter's msg.channelId). + const spaces = new Map(); + // The Pub/Sub inbound queue — messages stay until acked (the ack contract). + const queue: QueuedMessage[] = []; + // Strictly-monotonic sources: outbound message ids, ackIds, and mint/ack counts. + let outboundSeq = 5_000; + let ackSeq = 0; + let mintCount = 0; + let ackedTotal = 0; + let stopped = false; + + function spaceLog(space: string): GoogleChatRecordedOutbound[] { + let log = spaces.get(space); + if (log === undefined) { + log = []; + spaces.set(space, log); + } + return log; + } + + function record(ro: GoogleChatRecordedOutbound): void { + spaceLog(ro.space).push(ro); + } + + const sleep = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); + + // --- Fake token endpoint (the service-account JWT-bearer exchange) --- + // POST {tokenUrl} → an opaque access token. The emulator IS Google, so it never + // validates the assertion — but the adapter must obtain a token before it POSTs, + // so this answers 200 with a token + expiry. Both scopes hit the same endpoint. + backend.registerPathRoute( + (path) => path.endsWith("/token"), + (): RouteResult => { + mintCount += 1; + return { + status: 200, + body: { access_token: "emulator-access-token", expires_in: 3600 }, + }; + }, + ); + + // --- Fake Pub/Sub :pull (long-poll, non-destructive) --- + backend.registerPathRoute( + (path) => path.endsWith(":pull"), + async (): Promise => { + const startedAt = Date.now(); + // Bounded long-poll: mirror the real subscription pull so the adapter's + // self-rescheduling loop parks here instead of hammering the loopback base + // on an empty subscription. The `stopped` flag bails immediately on stop(). + while ( + !stopped && + queue.length === 0 && + Date.now() - startedAt < PULL_LONG_POLL_MS + ) { + await sleep(PULL_POLL_INTERVAL_MS); + } + const batch = queue.slice(0, PULL_MAX_MESSAGES); + return { + status: 200, + body: { + receivedMessages: batch.map((m) => ({ + ackId: m.ackId, + message: { data: m.data, messageId: m.messageId }, + })), + }, + }; + }, + ); + + // --- Fake Pub/Sub :acknowledge (removes acked messages) --- + backend.registerPathRoute( + (path) => path.endsWith(":acknowledge"), + (ctx: RouteContext): RouteResult => { + const body = parseJson(ctx.body); + const ackIds = Array.isArray(body["ackIds"]) ? (body["ackIds"] as unknown[]) : []; + for (const id of ackIds) { + const idx = queue.findIndex((m) => m.ackId === id); + if (idx >= 0) { + queue.splice(idx, 1); + ackedTotal += 1; + } + } + return { status: 200, body: {} }; + }, + ); + + // --- Fake Chat REST (create / edit / delete) → the per-space oracle --- + backend.registerPathRoute( + (path) => CHAT_MESSAGE_RE.test(path), + (ctx: RouteContext): RouteResult => { + const match = ctx.path.match(CHAT_MESSAGE_RE); + const space = match?.[1]; + if (space === undefined) { + return { status: 404, body: { error: "not a chat messages path" } }; + } + const targetId = match[2]; + const body = parseJson(ctx.body); + const text = typeof body["text"] === "string" ? (body["text"] as string) : undefined; + const hasCards = Array.isArray(body["cardsV2"]) && (body["cardsV2"] as unknown[]).length > 0; + const threadName = + typeof (body["thread"] as { name?: unknown } | undefined)?.name === "string" + ? ((body["thread"] as { name?: string }).name as string) + : undefined; + + if (ctx.httpMethod === "POST") { + // messages.create — mint a message name and record the send. + const messageId = ++outboundSeq; + const messageName = `${space}/messages/${messageId}`; + record({ + method: "send", + op: "send", + messageId, + space, + messageName, + ...(text !== undefined ? { text } : {}), + ...(hasCards ? { hasCards } : {}), + ...(threadName !== undefined ? { threadName } : {}), + }); + // The adapter reads res.json().name as the created message resource name. + return { status: 200, body: { name: messageName } }; + } + + if (ctx.httpMethod === "PATCH" && targetId !== undefined) { + const messageName = `${space}/messages/${targetId}`; + record({ + method: "edit", + op: "edit", + messageId: Number(targetId) || 0, + space, + messageName, + ...(text !== undefined ? { text } : {}), + ...(hasCards ? { hasCards } : {}), + }); + return { status: 200, body: { name: messageName } }; + } + + if (ctx.httpMethod === "DELETE" && targetId !== undefined) { + const messageName = `${space}/messages/${targetId}`; + record({ + method: "delete", + op: "delete", + messageId: Number(targetId) || 0, + space, + messageName, + }); + return { status: 200, body: {} }; + } + + return { status: 405, body: { error: "unsupported chat method" } }; + }, + ); + + const emulator: GoogleChatEmulator = { + caps: googlechatCaps satisfies ChannelCaps, + backend, + projectNumber, + + start() { + stopped = false; + return backend.start(); + }, + + async stop() { + // Set before closing so an in-flight long-poll bails immediately. + stopped = true; + await backend.stop(); + }, + + fakeServiceAccountKeyJson() { + return JSON.stringify({ + type: "service_account", + project_id: "test-project", + private_key_id: EMULATOR_KID, + private_key: saPrivateKeyPem, + client_email: clientEmail, + client_id: "000000000000000000000", + token_uri: "https://oauth2.googleapis.com/token", + }); + }, + + publicJwks() { + return { keys: [signingJwk] }; + }, + + writeJwksFile(filePath) { + writeFileSync(filePath, JSON.stringify({ keys: [signingJwk] }), "utf8"); + }, + + async signInboundToken(signOpts) { + return new SignJWT({}) + .setProtectedHeader({ alg: "RS256", kid: EMULATOR_KID }) + .setIssuer(signOpts?.issuer ?? CHAT_SYSTEM_ISSUER) + .setAudience(signOpts?.audience ?? projectNumber) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); + }, + + injectInbound(event) { + const seq = ++ackSeq; + // STANDARD base64 (not the URL-safe alphabet): the loop decodes with + // Buffer.from(data, "base64"), so the round-trip is exact. + const data = Buffer.from(JSON.stringify(event), "utf8").toString("base64"); + queue.push({ ackId: `ack-${seq}`, messageId: `pubsub-msg-${seq}`, data }); + }, + + outbound(space) { + return spaces.get(space) ?? []; + }, + + lastBotReply(space) { + const log = spaces.get(space); + return log && log.length > 0 ? log[log.length - 1] : undefined; + }, + + resetChat(space) { + spaces.delete(space); + }, + + tokenMintCount() { + return mintCount; + }, + + ackedCount() { + return ackedTotal; + }, + + pendingCount() { + return queue.length; + }, + }; + + return emulator; +} + +/** + * Register the OUT-OF-PROCESS drive-control surface on the emulator's loopback + * backend, so a SEPARATE driver process (a self-drive script, or a VPS launcher + * wired to an external daemon) can (a) enqueue an inbound event onto the fake + * subscription, (b) obtain a signed inbound Bearer — the emulator holds the + * private key, the driver does not — and (c) read the Chat outbound oracle. + * + * - POST /emu/pubsub-inject → { ok, pending } + * - POST /emu/sign-token { audience?, issuer? } → { token } + * - GET /emu/outbound ?space=&afterCount= → { outbound, total } + * - GET /emu/info → { projectNumber, tokenMintCount, ackedCount, pendingCount } + * + * Loopback-only + test-only (the adapter never calls `/emu/*`; it only hits the + * token / `:pull` / `:acknowledge` / `/spaces/…/messages` surfaces). No auth — the + * surface is unreachable off 127.0.0.1 (the shared backend binds loopback only). + */ +export function registerGoogleChatDriveControl(emu: GoogleChatEmulator): void { + emu.backend.registerPathRoute("/emu/pubsub-inject", (ctx): RouteResult => { + const event = parseJson(ctx.body); + emu.injectInbound(event); + return { status: 200, body: { ok: true, pending: emu.pendingCount() } }; + }); + emu.backend.registerPathRoute("/emu/sign-token", async (ctx): Promise => { + const body = parseJson(ctx.body); + const audience = typeof body["audience"] === "string" ? (body["audience"] as string) : undefined; + const issuer = typeof body["issuer"] === "string" ? (body["issuer"] as string) : undefined; + const token = await emu.signInboundToken({ + ...(audience !== undefined ? { audience } : {}), + ...(issuer !== undefined ? { issuer } : {}), + }); + return { status: 200, body: { token } }; + }); + emu.backend.registerPathRoute("/emu/outbound", (ctx): RouteResult => { + const params = new URLSearchParams(ctx.query); + const space = params.get("space") ?? ""; + const afterCount = Number(params.get("afterCount") ?? "0") || 0; + const all = emu.outbound(space); + return { status: 200, body: { outbound: all.slice(afterCount), total: all.length } }; + }); + emu.backend.registerPathRoute("/emu/info", (): RouteResult => { + return { + status: 200, + body: { + projectNumber: emu.projectNumber, + tokenMintCount: emu.tokenMintCount(), + ackedCount: emu.ackedCount(), + pendingCount: emu.pendingCount(), + }, + }; + }); +} From d7de4e934fca5067e1dd465c524b6f6de3fe67f4 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 16:09:17 +0300 Subject: [PATCH 174/200] test(238-07): offline scenario drives the REAL Google Chat adapter (text + card) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stage-B, keyless, no daemon: constructs the real createGoogleChatPlugin with its base-URL DI seams (chatBaseUrl/pubsubBaseUrl/tokenUrl) pointed at the emulator origin, then drives, through the REAL pull loop + mapper + card normalizer + allowlist + send path: - a text MESSAGE round-trip → the emulator per-space Chat oracle (echo reply) - a well-formed CARD_CLICKED → the card-action synthetic-message path → reply - default-deny gates honored: a non-allowlisted sender and an unrendered-method card click are dropped (acked, no message, no reply) The offline leg references no daemon egress seam — it injects base URLs at construction (the daemon seam is orthogonal, out of scope here). GREEN: 4/4; the full googlechat live suite is 32/32. --- .../channels/googlechat-emulator.test.ts | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 test/live/scenarios/channels/googlechat-emulator.test.ts diff --git a/test/live/scenarios/channels/googlechat-emulator.test.ts b/test/live/scenarios/channels/googlechat-emulator.test.ts new file mode 100644 index 000000000..ff4a0e923 --- /dev/null +++ b/test/live/scenarios/channels/googlechat-emulator.test.ts @@ -0,0 +1,221 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat emulator — the offline round-trip proof (the Google Chat analog of + * `msteams-emulator.test.ts` / `telegram-emulator.test.ts`). + * + * STAGE-B (always runs; no `COMIS_LIVE`, no model, no daemon): the whole Google + * Chat wire stack is exercised in-process by constructing the REAL production + * adapter directly and pointing its shipped base-URL DI seams at the emulator — + * so NO product wiring and NO new daemon egress seam is needed for this proof + * (unlike Teams, which needed a host-rewrite `fetchImpl`; Google Chat exposes an + * explicit base URL for every leg): + * + * injected event ──▶ the emulator's fake Pub/Sub subscription + * ──▶ the REAL pull loop (:pull long-poll, base64 decode, dedup) + * ──▶ adapter.handleChatEvent (REAL mapper + card normalizer + allowlist) + * ──▶ onMessage handler replies + * ──▶ adapter.sendMessage (REAL chat.bot token mint + Chat REST) + * ──▶ chatBaseUrl = the emulator ──▶ its per-space oracle. + * + * This is the drift tripwire + the security proof in one: the real default-deny + * allowlist gate, the real Cards v2 default-deny card-action gate, the real + * per-space send path, and the real Chat REST shape — all offline. The offline leg + * references NO daemon egress seam at all; it injects the base URLs at + * construction. The daemon-side egress seam for the full-daemon self-drive is + * orthogonal and out of this file's scope. + * + * Run under the LIVE vitest config (the bare root config excludes `test/live`): + * pnpm vitest run -c test/live/vitest.config.ts \ + * test/live/scenarios/channels/googlechat-emulator.test.ts + * + * @module + */ + +import { afterEach, describe, expect, it } from "vitest"; +import { createGoogleChatPlugin } from "@comis/channels"; +import type { ChannelPort, ComisLogger, NormalizedMessage } from "@comis/core"; +import { createMockLogger } from "../../../support/mock-logger.js"; +import { + createGoogleChatEmulator, + type GoogleChatEmulator, +} from "../../emulators/googlechat/googlechat-emulator.js"; +import { + makeMessageEvent, + makeCardClickedEvent, +} from "../../emulators/googlechat/googlechat-payloads.js"; + +/** The pull subscription resource the adapter is pointed at (loopback via pubsubBaseUrl). */ +const SUBSCRIPTION = "projects/test-project/subscriptions/comis-inbound"; + +interface Stack { + emu: GoogleChatEmulator; + adapter: ChannelPort; + messages: NormalizedMessage[]; +} + +const stacks: GoogleChatEmulator[] = []; +afterEach(async () => { + while (stacks.length > 0) await stacks.pop()!.stop(); +}); + +/** + * Build a full offline Google Chat stack: the emulator + the REAL adapter with its + * base-URL DI seams (chatBaseUrl / pubsubBaseUrl / tokenUrl) pointed at the + * emulator origin. An `onMessage` handler auto-replies "echo: " through the + * real send path. `allowMode` defaults to "open". + */ +async function buildStack(opts?: { + allowMode?: "allowlist" | "open"; + allowFrom?: string[]; + autoReply?: boolean; +}): Promise { + const emu = createGoogleChatEmulator(); + const { apiRoot } = await emu.start(); + stacks.push(emu); + + const logger: ComisLogger = createMockLogger(); + const messages: NormalizedMessage[] = []; + + const plugin = createGoogleChatPlugin({ + serviceAccountKey: emu.fakeServiceAccountKeyJson(), + subscriptionName: SUBSCRIPTION, + allowFrom: opts?.allowFrom ?? [], + allowMode: opts?.allowMode ?? "open", + mode: "pubsub", + logger, + // The load-bearing DI: EVERY outbound leg (token mint, Pub/Sub pull/ack, Chat + // REST send) is redirected to the loopback emulator by an explicit base URL — + // no host-rewrite fetch is needed (unlike Teams' connectorRedirectFetch). The + // fetchImpl seam is injected as a plain passthrough to complete the four-seam + // set; the base URLs do all the redirection. + chatBaseUrl: apiRoot, + pubsubBaseUrl: apiRoot, + tokenUrl: `${apiRoot}/token`, + fetchImpl: (input, init) => fetch(input, init), + }); + const adapter: ChannelPort = plugin.adapter; + + // Register the handler BEFORE the pull loop opens so a promptly-pulled inbound is + // never skip-acked for want of a handler. + adapter.onMessage(async (msg) => { + messages.push(msg); + if (opts?.autoReply !== false) { + await adapter.sendMessage(msg.channelId, `echo: ${msg.text}`); + } + }); + + const started = await adapter.start(); + expect(started.ok).toBe(true); + + return { emu, adapter, messages }; +} + +/** Poll the emulator's Chat oracle until an outbound lands in a space (or timeout). */ +async function waitForOutbound( + emu: GoogleChatEmulator, + space: string, + timeoutMs = 8000, +): Promise> { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const out = emu.outbound(space); + if (out.length > 0) return out; + await new Promise((r) => setTimeout(r, 20)); + } + return emu.outbound(space); +} + +/** Poll until `predicate` is true (or timeout); returns the final predicate value. */ +async function waitFor( + predicate: () => boolean, + timeoutMs = 8000, +): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return true; + await new Promise((r) => setTimeout(r, 20)); + } + return predicate(); +} + +describe("googlechat-emulator scenario — offline inbound→agent→outbound round-trip", () => { + it("a text MESSAGE event round-trips to the emulator's Chat oracle (echo reply)", async () => { + const stack = await buildStack(); + const SPACE = "spaces/roundtrip"; + stack.emu.injectInbound( + makeMessageEvent("hello google", { space: SPACE, user: "users/123" }), + ); + + // The reply landed on the fake Chat API with the exact wire text. + const out = await waitForOutbound(stack.emu, SPACE); + expect(stack.messages).toHaveLength(1); + expect(stack.messages[0]?.text).toBe("hello google"); + expect(stack.messages[0]?.channelId).toBe(SPACE); + expect(out).toHaveLength(1); + expect(out[0]?.op).toBe("send"); + expect(out[0]?.text).toBe("echo: hello google"); + // The service-account token mint ran (pubsub-scope pull + chat-scope send). + expect(stack.emu.tokenMintCount()).toBeGreaterThan(0); + }); + + it("a well-formed CARD_CLICKED routes through the card-action path to a reply", async () => { + const stack = await buildStack(); + const SPACE = "spaces/cardclick"; + stack.emu.injectInbound( + makeCardClickedEvent({ + space: SPACE, + user: "users/approver", + callback: "signed-cb-blob", + }), + ); + + // The verified, rendered click normalized to a button-callback message and + // fanned out to the handler (the synthetic-message path). + await waitFor(() => stack.messages.length > 0); + expect(stack.messages).toHaveLength(1); + expect(stack.messages[0]?.metadata.isButtonCallback).toBe(true); + expect(stack.messages[0]?.text).toBe("signed-cb-blob"); + // And its reply round-tripped to the Chat oracle. + const out = await waitForOutbound(stack.emu, SPACE); + expect(out[0]?.op).toBe("send"); + expect(out[0]?.text).toBe("echo: signed-cb-blob"); + }); +}); + +describe("googlechat-emulator scenario — security gates (unchanged in the emulator setup)", () => { + it("drops a non-allowlisted sender in allowMode:allowlist (no outbound)", async () => { + const stack = await buildStack({ + allowMode: "allowlist", + allowFrom: ["users/allowed"], + }); + const SPACE = "spaces/drop"; + stack.emu.injectInbound( + makeMessageEvent("let me in", { space: SPACE, user: "users/not-allowed" }), + ); + + // The adapter drops at the allowlist gate and ACKS (resolves) — wait for the + // ack, then assert nothing was delivered or sent (no infinite redelivery). + await waitFor(() => stack.emu.ackedCount() > 0); + expect(stack.messages).toHaveLength(0); + expect(stack.emu.outbound(SPACE)).toHaveLength(0); + }); + + it("default-denies an unrendered-method card click (no message, no reply)", async () => { + const stack = await buildStack(); + const SPACE = "spaces/unrendered"; + stack.emu.injectInbound( + makeCardClickedEvent({ + space: SPACE, + user: "users/approver", + // A method the bot never rendered — the default-deny drop path. + invokedFunction: "attacker.arbitrary.method", + callback: "signed-cb-blob", + }), + ); + + // The card-action gate drops the unrendered method and ACKS (resolves). + await waitFor(() => stack.emu.ackedCount() > 0); + expect(stack.messages).toHaveLength(0); + expect(stack.emu.outbound(SPACE)).toHaveLength(0); + }); +}); From 0bec2d4e5957859d646d98b7ea0b592b35d4cf76 Mon Sep 17 00:00:00 2001 From: comis-agent Date: Mon, 6 Jul 2026 16:27:03 +0300 Subject: [PATCH 175/200] =?UTF-8?q?feat(238-08):=20Google=20Chat=20self-dr?= =?UTF-8?q?iving=20rig=20=E2=80=94=20emulator=20launcher=20+=20hybrid=20dr?= =?UTF-8?q?iver?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - vps-emu-googlechat.ts: launches the loopback emulator (token + Pub/Sub + Chat REST oracle + JWKS/SA-key signer), writes the JWKS + a parseable SA key + a /tmp descriptor, prints GOOGLECHAT_EMU_UP; daemonEnv names BOTH seams — COMIS_GOOGLECHAT_TEST_JWKS (inbound verify) and COMIS_GOOGLECHAT_TEST_API (outbound Chat/Pub-Sub/token egress redirect, so the reply lands in the oracle). - googlechat-drive.mjs: hybrid driver. --mode pubsub injects the event on the fake subscription for the daemon to pull; --mode webhook signs a Chat-event Bearer and POSTs to /channels/googlechat. Polls /emu/outbound for the reply; drives a text turn and a Cards v2 click (--type card). --no-auth/--bad-token webhook SEC probes. - README: document the Google Chat rig alongside Telegram + Teams. --- test/live/bin/vps-emu-googlechat.ts | 103 +++++++ test/live/self-driving/scripts/README.md | 13 +- .../self-driving/scripts/googlechat-drive.mjs | 260 ++++++++++++++++++ 3 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 test/live/bin/vps-emu-googlechat.ts create mode 100644 test/live/self-driving/scripts/googlechat-drive.mjs diff --git a/test/live/bin/vps-emu-googlechat.ts b/test/live/bin/vps-emu-googlechat.ts new file mode 100644 index 000000000..3d6f5a408 --- /dev/null +++ b/test/live/bin/vps-emu-googlechat.ts @@ -0,0 +1,103 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * `vps-emu-googlechat` — STANDALONE Google Chat emulator launcher for an EXTERNAL + * daemon (the VPS production daemon, a test env). The Google Chat sibling of + * `vps-emu.ts` (Telegram) and `vps-emu-msteams.ts` (Teams). + * + * Google Chat DEFAULTS to a PULL transport (like Telegram/Signal, unlike Teams' + * push): the daemon connects OUT to three fake Google surfaces — the OAuth token + * mint, the Pub/Sub pull endpoint, and the Chat REST API — that this emulator + * serves on ONE loopback port. It also mints inbound Chat-event Bearers for the + * OPT-IN webhook mode. So this launcher starts the `GoogleChatEmulator` + its + * `/emu/*` drive-control surface, writes its public JWKS + a parseable + * service-account key to files, and stays up. It is wired to an already-running + * daemon by setting, ON THE DAEMON, the two OFF-BY-DEFAULT test seams: + * + * channels.googlechat: (config.yaml — enable the channel + test creds) + * enabled: true + * mode: pubsub (or webhook, to exercise the inbound-verify leg) + * serviceAccountKey: # or paste the JSON blob + * subscriptionName: projects/test-project/subscriptions/comis-emulator + * audienceType: project-number + * audience: + * allowFrom: ["users/selfdrive"] # the driver's --from id (the immutable sender) + * env (daemon launch): + * COMIS_GOOGLECHAT_TEST_JWKS= # local-JWKS inbound verify (webhook mode) + * COMIS_GOOGLECHAT_TEST_API= # redirect Chat/Pub-Sub/token egress → here + * + * With both set, the daemon verifies inbound webhook tokens against this + * emulator's JWKS AND its outbound Chat/Pub-Sub/token egress is redirected to this + * emulator (a FULL local-JWKS verify, never a bypass — see + * packages/daemon/src/wiring/googlechat-test-seams.ts). So the agent reply lands + * in this emulator's per-space outbound oracle instead of escaping to real Google. + * The driver (`self-driving/scripts/googlechat-drive.mjs`) then, per --mode, either + * signs an inbound Bearer at `/emu/sign-token` and POSTs the event to the daemon's + * `/channels/googlechat` (webhook), or injects the event onto the fake Pub/Sub + * subscription at `/emu/pubsub-inject` for the daemon to pull (pubsub) — then polls + * `/emu/outbound` for the reply. + * + * Writes the wiring to /tmp/comis-googlechat-emu.json and prints + * `GOOGLECHAT_EMU_UP {json}`. + * + * TEST-HARNESS — lives under the test tree; consumes only the emulator subtree + * (node: built-ins + jose at runtime; @comis types are erased). + */ +import { writeFileSync } from "node:fs"; +import { + createGoogleChatEmulator, + registerGoogleChatDriveControl, +} from "../emulators/googlechat/googlechat-emulator.js"; + +const PROJECT_NUMBER = process.env["EMU_GOOGLECHAT_PROJECT_NUMBER"] ?? "000000000001"; +const CLIENT_EMAIL = + process.env["EMU_GOOGLECHAT_CLIENT_EMAIL"] ?? + "comis-emulator@test-project.iam.gserviceaccount.com"; +const JWKS_PATH = + process.env["EMU_GOOGLECHAT_JWKS_PATH"] ?? "/tmp/comis-googlechat-jwks.json"; +const SA_KEY_PATH = + process.env["EMU_GOOGLECHAT_SA_KEY_PATH"] ?? "/tmp/comis-googlechat-sa.json"; + +const emu = createGoogleChatEmulator({ + projectNumber: PROJECT_NUMBER, + clientEmail: CLIENT_EMAIL, +}); +registerGoogleChatDriveControl(emu); + +const { apiRoot, port } = await emu.start(); +// Persist the public JWKS so the daemon's COMIS_GOOGLECHAT_TEST_JWKS seam can read it. +emu.writeJwksFile(JWKS_PATH); +// Persist a parseable service-account key so the daemon's outbound token mint has +// creds to sign an assertion with — the emulator's token endpoint is opaque and +// never verifies it, but the adapter must obtain a token before it posts. +writeFileSync(SA_KEY_PATH, emu.fakeServiceAccountKeyJson(), "utf8"); + +const info = { + apiRoot, + port, + projectNumber: PROJECT_NUMBER, + clientEmail: CLIENT_EMAIL, + jwksPath: JWKS_PATH, + saKeyPath: SA_KEY_PATH, + pid: process.pid, + // The exact daemon-side wiring the operator must set (echoed for copy/paste). + daemonEnv: { + COMIS_GOOGLECHAT_TEST_JWKS: JWKS_PATH, + COMIS_GOOGLECHAT_TEST_API: apiRoot, + }, +}; +writeFileSync("/tmp/comis-googlechat-emu.json", JSON.stringify(info, null, 2)); +// eslint-disable-next-line no-console +console.log("GOOGLECHAT_EMU_UP " + JSON.stringify(info)); + +const stop = async (): Promise => { + try { + await emu.stop(); + } catch { + /* best-effort */ + } + process.exit(0); +}; +process.on("SIGTERM", () => void stop()); +process.on("SIGINT", () => void stop()); +// Keep the event loop alive. +setInterval(() => {}, 1 << 30); diff --git a/test/live/self-driving/scripts/README.md b/test/live/self-driving/scripts/README.md index 8d786938f..2011b1b49 100644 --- a/test/live/self-driving/scripts/README.md +++ b/test/live/self-driving/scripts/README.md @@ -1,4 +1,4 @@ -# Live-test helper scripts — VPS + channel emulators (Telegram, Microsoft Teams) +# Live-test helper scripts — VPS + channel emulators (Telegram, Microsoft Teams, Google Chat) Ready-to-run versions of every helper used in the live-test. Read **`../01-SETUP.md`** for the full setup playbook and **`../03-OBSERVABILITY.md`** for the traps — @@ -53,6 +53,7 @@ ssh root@ 'MODELS="claude-sonnet-4-6 claude-opus-4-8" PRIMARY=claude-sonnet | `gate-probe.mjs` | VPS | root/comis | **DETERMINISTIC security-gate / jail oracle prover** — calls the DEPLOYED guard off `dist/` directly: `floor` (`validateExecCommand` destructive denylist), `ssrf` (`validateUrl` — ASYNC `Result{ok}`), `invisible` (`stripInvisible` zero-click). `gate-probe.mjs [floor\|ssrf\|invisible\|all]`, `SRC=` overridable, exit 0/1. **The PRIMARY method for the prove-once gate/jail/exfil oracles** — a cautious frontier model refuses every adversarial-framed probe (so you get no gate stdout); this proves the actual deployed code-path instead. (bwrap egress is a kernel test — run the `bwrap --unshare-net` one-liner in `03-OBSERVABILITY.md` separately.) | | `webhook-drive.mjs` | VPS | root/comis | the **HTTP-webhook analog of `drive.mjs`** — POST a signed webhook to the gateway `/hooks/` (computes the `x-webhook-signature` HMAC; the endpoint ALWAYS requires it). `webhook-drive.mjs [--secret\|--ts\|--ts-offset\|--no-sign\|--bad-sign\|--raw\|--allow-stale …]`. Proves the INBOUND contract (auth + mapping + code); the agent turn fires ASYNC past the 200 (read completion from the trajectory/`terminal-drive-observe`, not this script). **Hardened:** a missing `@file` exits 2 with a clean message (not a raw ENOENT stack — the `$ID`-unset → `wh-undefined.json` footgun), and a `@file` older than 120s HARD-FAILS unless `--allow-stale` (the reused-stale-body phantom-turn trap). | | `msteams-drive.mjs` | VPS | root/comis | the **Microsoft Teams push driver** (Teams is the INVERSE of Telegram: inbound is a signed webhook the daemon EXPOSES). Signs a BF Bearer via the emulator's `/emu/sign-token`, POSTs a Bot Framework Activity to the daemon ingress `/channels/msteams/api/messages`, then polls the emulator's Connector oracle (`/emu/outbound`) for the agent reply. `msteams-drive.mjs "" [--from\|--type message\|reaction\|--target\|--emoji-type\|--wait\|--no-auth\|--bad-token]`. Reads the emulator wiring from `/tmp/comis-msteams-emu.json` (written by `vps-emu-msteams.ts`). `--no-auth`/`--bad-token` drive the 401 SEC probes; the agent turn fires ASYNC past the 202 (the oracle poll + trajectory are the truth, not a chat reply). | +| `googlechat-drive.mjs` | VPS | root/comis | the **Google Chat driver** (HYBRID: Google Chat DEFAULTS to a PULL transport like Telegram but also supports a signed webhook like Teams). `--mode pubsub` (default) injects the event onto the emulator's fake Pub/Sub subscription (`/emu/pubsub-inject`) for the daemon to PULL; `--mode webhook` signs a Chat-event Bearer via `/emu/sign-token` and POSTs the event to the daemon ingress `/channels/googlechat`. Either way it polls the emulator's Chat-REST oracle (`/emu/outbound`) for the agent reply, driving a text turn and a Cards v2 click (`--type card`). `googlechat-drive.mjs "" [--mode\|--from\|--type message\|card\|--mentioned\|--card-message\|--invoked-function\|--callback\|--wait\|--no-auth\|--bad-token]`. Reads the emulator wiring from `/tmp/comis-googlechat-emu.json` (written by `vps-emu-googlechat.ts`). `--no-auth`/`--bad-token` drive the 401 SEC probes (webhook mode); the agent turn fires ASYNC past the ack (the oracle poll + trajectory are the truth, not a chat reply). | | `phase0-check.sh` | VPS | root | **PREFLIGHT readiness gate** — run BEFORE driving a webhook→claude terminal test: daemon-alive · gateway-port-bound · clean-boot (last record is `Comis daemon started`, not a FATAL) · **webhook-route mounted + HMAC active** (an UNSIGNED POST must be 401, needs no secret) · **msteams ingress mounted + BF-JWT active** (when `channels.msteams` enabled: unsigned POST → 401) · terminal-config `worker` block complete (the schema-FATAL this run hit) · jail deps (`bwrap`/`tmux`/`node`). Read-only, no token; exit 0 = ready, non-zero = a named blocker. Turns the "discover the rig isn't ready one failed drive at a time" loop into one 2s gate. | | `terminal-drive-observe.mjs` | VPS | root/comis | the **GROUND-TRUTH oracle for a webhook/cron→claude TERMINAL DRIVE** — `[screen\|secrets\|lifecycle\|progress\|all] [project] [--session ] [--watch ]`. **screen**=live tmux `capture-pane`; **secrets**=the JAIL HARD oracle (`/proc//environ` scanned for daemon secrets — COUNTS only, expect 0); **lifecycle**=the drive lifecycle from the daemon log (create/promote/settle/**EVICTED(reason)**/reaped/webhook_delivered) across log rotation; **progress**=the project's git + ROADMAP `[x]` (did it BUILD anything, not the chat reply). `--watch ` is the drive-poll loop. Bundles the four probes the run kept hand-rolling. | | `browser-oracle.mjs` | local/VPS | you | the **cheap, zero-dep half of the browser "it actually runs" oracle** for a drive that built a static web app — `browser-oracle.mjs [--port\|--entry]`: `node --check` every local JS · verify every `