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/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/googlechat.mdx b/docs/channels/googlechat.mdx new file mode 100644 index 000000000..346539610 --- /dev/null +++ b/docs/channels/googlechat.mdx @@ -0,0 +1,457 @@ +--- +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; `allowFrom` entries are immutable `users/{id}` ids rather + than email-shaped; the webhook `audience` shape matches `audienceType` (a + mismatch silently rejects every inbound request); and + `autoReplyEngine.groupActivation: "always"` draws a warning because it is + inert on this platform. 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. + + 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/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/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. 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/get-started/quickstart.mdx b/docs/get-started/quickstart.mdx index fe0f797b8..68735b5fb 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 7c86fa010..c9388bfd5 100644 --- a/docs/installation/install-linux.mdx +++ b/docs/installation/install-linux.mdx @@ -354,6 +354,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. 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. diff --git a/docs/reference/cli.mdx b/docs/reference/cli.mdx index 862943abb..4c68d2404 100644 --- a/docs/reference/cli.mdx +++ b/docs/reference/cli.mdx @@ -643,6 +643,15 @@ Interactive setup wizard for first-time configuration. Supports three modes: int | `--slack-app-token ` | `string` | - | Slack app token | | `--line-token ` | `string` | - | LINE channel token | | `--line-secret ` | `string` | - | LINE channel secret | +| `--msteams-app-id ` | `string` | - | Microsoft Teams bot app (client) ID | +| `--msteams-app-password ` | `string` | - | Microsoft Teams app password (client secret) | +| `--msteams-tenant-id ` | `string` | - | Microsoft Teams directory (tenant) ID | +| `--msteams-auth-mode ` | `secret` \| `certificate` \| `managedIdentity` | `secret` | Microsoft Teams auth mode | +| `--googlechat-sa-key ` | `string` | - | Google Chat service-account key JSON, or a path to the downloaded key file | +| `--googlechat-subscription ` | `string` | - | Google Chat Pub/Sub pull subscription (`pubsub` mode): `projects/P/subscriptions/S` | +| `--googlechat-mode ` | `pubsub` \| `webhook` | `pubsub` | Google Chat inbound transport | +| `--googlechat-audience ` | `string` | - | Google Chat inbound JWT audience (`webhook` mode) | +| `--googlechat-audience-type ` | `project-number` \| `app-url` | `project-number` | Google Chat webhook audience type | #### Media Generation and Processing diff --git a/docs/reference/config-yaml.mdx b/docs/reference/config-yaml.mdx index 892d5a06b..4e628da44 100644 --- a/docs/reference/config-yaml.mdx +++ b/docs/reference/config-yaml.mdx @@ -1142,6 +1142,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. 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` (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: `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 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 channels: telegram: @@ -1165,8 +1182,28 @@ 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"] +``` + +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 [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 96b51e29a..c8c59dc98 100644 --- a/docs/reference/environment-variables.mdx +++ b/docs/reference/environment-variables.mdx @@ -305,6 +305,80 @@ 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 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` + +| 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 +``` + +#### `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` @@ -677,6 +751,15 @@ 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 + +#### `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 | Variable | Purpose | diff --git a/docs/security/defense-in-depth.mdx b/docs/security/defense-in-depth.mdx index 8f21668a4..d8d008642 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 + [Google Chat setup guide](/channels/googlechat) for the full exposure and + liveness walkthrough. + ### When a Message Arrives diff --git a/packages/channels/src/__tests__/fakes/googlechat-fake.test.ts b/packages/channels/src/__tests__/fakes/googlechat-fake.test.ts new file mode 100644 index 000000000..be70f0472 --- /dev/null +++ b/packages/channels/src/__tests__/fakes/googlechat-fake.test.ts @@ -0,0 +1,116 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Unit test for {@link createFakeGoogleChatAdapter} — the deterministic Google + * Chat `ChannelPort` double. + * + * Pins the contract the fixtures + activity tests rely on: monotonic `gchat-msg-N` + * ids, the connectionMode-per-mode status ("polling" for the Pub/Sub-pull default, + * "webhook" for webhook mode), a one-shot `nextError` seam that surfaces a + * Chat-API-shaped `{ status, retryAfter }` failure through the `Result` err branch + * exactly once, and the HONEST omission of the false-capability methods (no + * reactions, no outbound uploads, no history) so a false-flag call has nothing to + * hit. No `systemNowMs()` — the double is clock-free so fixtures never flap. + */ +import { describe, it, expect } from "vitest"; +import { + createFakeGoogleChatAdapter, + type GoogleChatErrorShape, +} from "./googlechat-fake.js"; +import type { RichButton } from "@comis/core"; + +describe("createFakeGoogleChatAdapter", () => { + it("mints monotonic gchat-msg-N ids and records the send in order", async () => { + const { port, recorded } = createFakeGoogleChatAdapter(); + const r0 = await port.sendMessage("spaces/AAAA", "hello"); + const r1 = await port.sendMessage("spaces/AAAA", "world"); + expect(r0).toEqual({ ok: true, value: "gchat-msg-0" }); + expect(r1).toEqual({ ok: true, value: "gchat-msg-1" }); + expect(recorded.calls).toEqual([ + { op: "send", id: "gchat-msg-0", text: "hello" }, + { op: "send", id: "gchat-msg-1", text: "world" }, + ]); + }); + + it("records buttons on send ONLY when present (button-less frames stay byte-stable)", async () => { + const { port, recorded } = createFakeGoogleChatAdapter(); + const buttons: RichButton[][] = [[{ text: "Approve", callback_data: "cb" }]]; + await port.sendMessage("spaces/AAAA", "plain"); + await port.sendMessage("spaces/AAAA", "with-buttons", { buttons }); + expect(recorded.calls[0]).toEqual({ op: "send", id: "gchat-msg-0", text: "plain" }); + expect(recorded.calls[1]).toEqual({ + op: "send", + id: "gchat-msg-1", + text: "with-buttons", + buttons, + }); + }); + + it("reports channelType googlechat and connectionMode polling by default (Pub/Sub pull)", () => { + const { port } = createFakeGoogleChatAdapter(); + expect(port.channelType).toBe("googlechat"); + const status = port.getStatus?.(); + expect(status?.channelType).toBe("googlechat"); + expect(status?.connected).toBe(true); + expect(status?.connectionMode).toBe("polling"); + }); + + it("reports connectionMode webhook in webhook mode", () => { + const { port } = createFakeGoogleChatAdapter({ mode: "webhook" }); + expect(port.getStatus?.().connectionMode).toBe("webhook"); + }); + + it("nextError fails the next call ONCE with a {status, retryAfter} error, then clears", async () => { + const { port, nextError } = createFakeGoogleChatAdapter(); + nextError({ status: 429, retryAfter: 2 }); + const failed = await port.sendMessage("spaces/AAAA", "will-fail"); + expect(failed.ok).toBe(false); + if (!failed.ok) { + const e = failed.error as GoogleChatErrorShape & Error; + expect(e.status).toBe(429); + expect(e.retryAfter).toBe(2); + } + // The seam is one-shot: the next send succeeds AND is the first recorded id + // (the failed call never recorded). + const recovered = await port.sendMessage("spaces/AAAA", "recovers"); + expect(recovered).toEqual({ ok: true, value: "gchat-msg-0" }); + }); + + it("records edit and delete; both honor the one-shot error seam", async () => { + const { port, recorded, nextError } = createFakeGoogleChatAdapter(); + const send = await port.sendMessage("spaces/AAAA", "orig"); + const id = send.ok ? send.value : ""; + const edited = await port.editMessage?.("spaces/AAAA", id, "edited"); + expect(edited).toEqual({ ok: true, value: undefined }); + nextError({ status: 500 }); + const delFailed = await port.deleteMessage?.("spaces/AAAA", id); + expect(delFailed?.ok).toBe(false); + // The failed delete consumed the seam and did NOT record; the retry lands. + const delOk = await port.deleteMessage?.("spaces/AAAA", id); + expect(delOk).toEqual({ ok: true, value: undefined }); + expect(recorded.calls).toEqual([ + { op: "send", id: "gchat-msg-0", text: "orig" }, + { op: "edit", id: "gchat-msg-0", text: "edited" }, + { op: "delete", id: "gchat-msg-0" }, + ]); + }); + + it("start/stop/platformAction return ok without touching the call log", async () => { + const { port, recorded } = createFakeGoogleChatAdapter(); + expect(await port.start()).toEqual({ ok: true, value: undefined }); + expect(await port.stop()).toEqual({ ok: true, value: undefined }); + const acted = await port.platformAction("noop", { a: 1 }); + expect(acted.ok).toBe(true); + expect(recorded.calls).toEqual([]); + }); + + it("OMITS the honest-false capability methods (no reactions / uploads / history)", () => { + const { port } = createFakeGoogleChatAdapter(); + // Reactions, outbound upload, and history are unreachable for a service-account + // app — the methods are omitted (an honest gap), not stubbed. + expect(port.reactToMessage).toBeUndefined(); + expect(port.removeReaction).toBeUndefined(); + expect(port.onReaction).toBeUndefined(); + expect(port.sendAttachment).toBeUndefined(); + expect(port.fetchMessages).toBeUndefined(); + }); +}); diff --git a/packages/channels/src/__tests__/fakes/googlechat-fake.ts b/packages/channels/src/__tests__/fakes/googlechat-fake.ts new file mode 100644 index 000000000..09f3dac6e --- /dev/null +++ b/packages/channels/src/__tests__/fakes/googlechat-fake.ts @@ -0,0 +1,197 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * FakeGoogleChatAdapter — a deterministic, clock-free `ChannelPort` test double for + * the Google Chat renderer; it records every method call. + * + * Mirrors `createFakeMSTeamsAdapter` (the sibling rich-channel fake) but: + * - mints `gchat-msg-N` ids (the determinism source for byte-stable fixtures), + * - reports `connectionMode:"polling"` for the Pub/Sub-pull default and + * `"webhook"` for webhook mode — the switch the liveness monitor keys on, and + * - exposes a Chat-API-shaped (`{ status?, retryAfter? }`) `nextError` injection + * seam: the STRUCTURAL numeric `status` `classifyGoogleChatError` reads. + * + * The seam returns the raw Chat-API-shaped failure through the `Result` err branch + * (one-shot, then clears) so a classifier is driven STRUCTURALLY off `e.status` — + * never by parsing a message string. All methods return `Result`; they NEVER throw + * across the port boundary (AGENTS.md §2.1). No `systemNowMs()` (would flap + * fixtures). + * + * The reaction, outbound-upload, and history methods are OMITTED — not stubbed: + * those capabilities are unreachable for a service-account Google Chat app, and the + * honest-capability contract omits the method so a false-flag call has nothing to + * hit (the daemon capability gate blocks it first). The approval buttons ride on + * `send.buttons` — recorded ONLY when `options.buttons` is present so the + * button-less frames stay byte-stable. + */ +import { ok, err, type Result } from "@comis/shared"; +import type { + ChannelPort, + ChannelStatus, + MessageHandler, + SendMessageOptions, + RichButton, +} from "@comis/core"; + +/** + * A Chat / Pub-Sub / token REST-shaped platform error (the structural fields the + * classifier reads). `status` is the HTTP status of the response (`429`, `401`, + * `404`, …); `retryAfter` is the rate-limit backoff in SECONDS. + */ +export interface GoogleChatErrorShape { + status?: number; + retryAfter?: number; + message?: string; +} + +/** One recorded adapter call — discriminated by `op`, deterministic ids, no timestamps. */ +export type FakeGoogleChatCall = + | { op: "send"; id: string; text: string; buttons?: RichButton[][] } + | { op: "edit"; id: string; text: string } + | { op: "delete"; id: string }; + +/** The transport the double reports through `getStatus().connectionMode`. */ +export type FakeGoogleChatMode = "pubsub" | "webhook"; + +/** Options for {@link createFakeGoogleChatAdapter}. */ +export interface CreateFakeGoogleChatAdapterOptions { + /** Pub/Sub pull (default → connectionMode "polling") or webhook (→ "webhook"). */ + readonly mode?: FakeGoogleChatMode; + /** The adapter instance id / space resource name. Defaults to "spaces/AAAA". */ + readonly channelId?: string; +} + +/** What {@link createFakeGoogleChatAdapter} returns: the port + call log + a one-shot error seam. */ +export interface FakeGoogleChatAdapter { + /** The `ChannelPort` double under test. */ + readonly port: ChannelPort; + /** Ordered call-log — the fixture artifact. Deterministic `gchat-msg-N` ids, no timestamps. */ + readonly recorded: { calls: FakeGoogleChatCall[] }; + /** + * Arm the one-shot platform-error seam. The NEXT recording port call returns + * `err(...)` (a Chat-API-shaped failure carrying the structural `status` / + * `retryAfter`) and clears the seam. Drives a classifier off `e.status`. + */ + nextError(e: GoogleChatErrorShape | Error): void; +} + +/** + * Coerce an injected error into a genuine `Error` carrying the structural + * `{ status, retryAfter }` fields, so the port's `Result<_, Error>` contract holds + * and a classifier reads the numeric status off it (never a message string). An + * already-`Error` value is passed through untouched. + */ +function toInjectedError(e: GoogleChatErrorShape | Error): Error { + if (e instanceof Error) return e; + const out = new Error(e.message ?? "google chat platform error") as Error & + GoogleChatErrorShape; + if (e.status !== undefined) out.status = e.status; + if (e.retryAfter !== undefined) out.retryAfter = e.retryAfter; + return out; +} + +/** + * Create a {@link FakeGoogleChatAdapter}. Mints `gchat-msg-0`, `gchat-msg-1`, … on + * each `sendMessage` and appends every method call to `recorded.calls` in order. + */ +export function createFakeGoogleChatAdapter( + opts: CreateFakeGoogleChatAdapterOptions = {}, +): FakeGoogleChatAdapter { + const channelId = opts.channelId ?? "spaces/AAAA"; + const connectionMode: "polling" | "webhook" = + opts.mode === "webhook" ? "webhook" : "polling"; + + const recorded: { calls: FakeGoogleChatCall[] } = { calls: [] }; + let messageCounter = 0; + const handlers: MessageHandler[] = []; + + // The one-shot error seam. When armed, the NEXT recording call returns err() + // and clears it. + let pending: Error | undefined; + function takePending(): Error | undefined { + if (pending === undefined) return undefined; + const e = pending; + pending = undefined; + return e; + } + + const port: ChannelPort = { + channelId, + channelType: "googlechat", + + async start(): Promise> { + return ok(undefined); + }, + + async stop(): Promise> { + return ok(undefined); + }, + + async sendMessage( + _channelId: string, + text: string, + options?: SendMessageOptions, + ): Promise> { + const injected = takePending(); + if (injected) return err(injected); + const id = `gchat-msg-${messageCounter++}`; + // The approval buttons ride on `buttons` — recorded ONLY when present so the + // button-less frames stay byte-stable. + recorded.calls.push({ + op: "send", + id, + text, + ...(options?.buttons !== undefined ? { buttons: options.buttons } : {}), + }); + return ok(id); + }, + + async editMessage( + _channelId: string, + messageId: string, + text: string, + _options?: SendMessageOptions, + ): Promise> { + const injected = takePending(); + if (injected) return err(injected); + recorded.calls.push({ op: "edit", id: messageId, text }); + return ok(undefined); + }, + + async deleteMessage(_channelId: string, messageId: string): Promise> { + const injected = takePending(); + if (injected) return err(injected); + recorded.calls.push({ op: "delete", id: messageId }); + return ok(undefined); + }, + + async platformAction( + action: string, + params: Record, + ): Promise> { + return ok({ action, params, echoed: true }); + }, + + onMessage(handler: MessageHandler): void { + handlers.push(handler); + }, + + getStatus(): ChannelStatus { + return { + connected: true, + channelId, + channelType: "googlechat", + connectionMode, + }; + }, + // reactToMessage / removeReaction / onReaction / sendAttachment / fetchMessages + // are deliberately OMITTED — the honest app-auth capability matrix. + }; + + return { + port, + recorded, + nextError(e: GoogleChatErrorShape | Error): void { + pending = toInjectedError(e); + }, + }; +} diff --git a/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts b/packages/channels/src/__tests__/plugin-capabilities-explicit.test.ts index 08cb0266a..b8a11d796 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 @@ -50,7 +50,8 @@ interface ExpectedCaps { | "blockkit" | "quickreply" | "none" - | "adaptivecard"; + | "adaptivecard" + | "cardsv2"; } const EXPECTED: readonly ExpectedCaps[] = [ @@ -65,6 +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: "cardsv2" }, ]; /** The three fields that MUST be declared (not defaulted) per plugin. */ @@ -134,9 +136,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) { 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..9293fdb51 --- /dev/null +++ b/packages/channels/src/googlechat/credential-validator.test.ts @@ -0,0 +1,417 @@ +// 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"); + }); + + // 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({ + 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); + }); + + // 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); + }); + + // ------------------------------------------------------------------------- + // Advisory audience-shape lint (webhook mode): audienceType vs audience shape + // ------------------------------------------------------------------------- + // + // The inbound verifier binds to a different key set + claim shape per + // audienceType. A URL audience with audienceType "project-number" (or a + // numeric audience with "app-url") selects the wrong path and silently rejects + // every inbound request — so a contradiction surfaces a content-free WARN. + + it("WARNs when audienceType is 'project-number' but audience is an endpoint URL (webhook)", () => { + const logger = makeLogger(); + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + mode: "webhook", + audienceType: "project-number", + audience: "https://chat.example.com/hook", + logger, + }); + // Advisory: the mismatch does NOT fail validation. + expect(result.ok).toBe(true); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + channelType: "googlechat", + errorKind: "config", + // The hint names BOTH knobs and the fix. + hint: expect.stringContaining("audienceType"), + }), + expect.any(String), + ); + const warnFields = (logger.warn as ReturnType).mock.calls[0]![0] as { + hint: string; + }; + expect(warnFields.hint).toContain("audience"); + // Content-free: the SA key material never crosses into the WARN. + const warnDump = JSON.stringify( + (logger.warn as ReturnType).mock.calls, + ); + expect(warnDump).not.toContain(PRIVATE_KEY_SENTINEL); + }); + + it("WARNs when audienceType is 'app-url' but audience is not URL-shaped (webhook)", () => { + const logger = makeLogger(); + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + mode: "webhook", + audienceType: "app-url", + audience: "1234567890", + logger, + }); + expect(result.ok).toBe(true); + expect(logger.warn).toHaveBeenCalledTimes(1); + expect(logger.warn).toHaveBeenCalledWith( + expect.objectContaining({ + channelType: "googlechat", + errorKind: "config", + hint: expect.stringContaining("audienceType"), + }), + expect.any(String), + ); + }); + + it("does NOT WARN when audienceType 'app-url' matches a URL-shaped audience (webhook)", () => { + const logger = makeLogger(); + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + mode: "webhook", + audienceType: "app-url", + audience: "https://chat.example.com/hook", + logger, + }); + expect(result.ok).toBe(true); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("does NOT WARN when audienceType 'project-number' matches a numeric audience (webhook)", () => { + const logger = makeLogger(); + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + mode: "webhook", + audienceType: "project-number", + audience: "1234567890", + logger, + }); + expect(result.ok).toBe(true); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("does NOT run the audience-shape lint in pubsub mode (audienceType is inert there)", () => { + const logger = makeLogger(); + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + mode: "pubsub", + subscriptionName: VALID_SUBSCRIPTION, + // A URL-shaped audience + project-number would mismatch in webhook mode, but + // audienceType/audience are ignored in pubsub mode, so no WARN fires. + audienceType: "project-number", + audience: "https://chat.example.com/hook", + logger, + }); + expect(result.ok).toBe(true); + expect(logger.warn).not.toHaveBeenCalled(); + }); + + it("does not throw on an audience/audienceType mismatch when no logger is injected", () => { + const result = validateGoogleChatCredentials({ + serviceAccountKey: VALID_SA_KEY, + mode: "webhook", + audienceType: "project-number", + audience: "https://chat.example.com/hook", + }); + expect(result.ok).toBe(true); + }); +}); diff --git a/packages/channels/src/googlechat/credential-validator.ts b/packages/channels/src/googlechat/credential-validator.ts new file mode 100644 index 000000000..37462e6e3 --- /dev/null +++ b/packages/channels/src/googlechat/credential-validator.ts @@ -0,0 +1,256 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat Credential Validator: a fail-fast guard that the service-account + * 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 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 carries three 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); an + * `"always"` group-activation mode surfaces a WARN naming that Google Chat never + * delivers unmentioned space messages, so `"always"` is inert on this channel; + * and, in webhook mode, an `audience` whose SHAPE contradicts `audienceType` (a + * URL audience declared `project-number`, or a non-URL audience declared + * `app-url`) surfaces a WARN — that mismatch makes the inbound verifier select + * the wrong key set and silently reject every request. + * + * @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; + /** + * 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; + /** + * Which audience shape the webhook verifier expects: `project-number` (a + * self-signed Chat-system token whose `aud` is the Cloud project number) or + * `app-url` (a Google OIDC token whose `aud` is the endpoint URL). Linted + * against the shape of {@link audience}: a contradiction selects the wrong key + * set and silently rejects every inbound request. Advisory only, webhook mode. + */ + audienceType?: "project-number" | "app-url"; + /** The configured sender allowlist, linted for mutable email-shaped ids. */ + allowFrom?: string[]; + /** + * 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; +} + +/** 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); +} + +/** + * True when an audience value looks like an endpoint URL (an `app-url` audience) + * rather than a numeric project number (a `project-number` audience). The two + * shapes are what the inbound verifier keys its key-set/claim selection on. + */ +function isUrlShapedAudience(audience: string): boolean { + return /^https?:\/\//i.test(audience.trim()); +} + +/** + * 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 (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.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 + */ +export function validateGoogleChatCredentials( + opts: GoogleChatValidateOpts, +): Result { + if (isBlank(opts.serviceAccountKey)) { + return err( + new Error("Google Chat credentials invalid: serviceAccountKey must not be empty"), + ); + } + // 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. + 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)", + ), + ); + } + + // 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", + ); + } + } + } + + // Advisory audience-shape lint (does not fail validation): in webhook mode the + // inbound verifier binds to a DIFFERENT key set + claim shape per audienceType — + // a self-signed Chat-system token whose `aud` is the project number, or a Google + // OIDC token whose `aud` is the endpoint URL. If the configured audience SHAPE + // contradicts audienceType the verifier selects the wrong path and silently + // rejects EVERY inbound request, so surface the mismatch here. Webhook-only: + // audienceType is inert in pubsub mode. Content-free — the WARN names only the + // two config keys and the fix, never the audience value. (A blank audience has + // already failed validation above in webhook mode, so audience is non-blank here.) + if ( + opts.logger && + opts.mode === "webhook" && + opts.audienceType && + !isBlank(opts.audience) + ) { + const urlShaped = isUrlShapedAudience(opts.audience as string); + if (opts.audienceType === "app-url" && !urlShaped) { + opts.logger.warn( + { + channelType: "googlechat" as const, + hint: 'channels.googlechat.audienceType is "app-url" but channels.googlechat.audience is not an endpoint URL — set audience to your https:// endpoint URL, or set audienceType to "project-number" if audience is your numeric Cloud project number', + errorKind: "config" as const, + }, + "Google Chat audience shape contradicts audienceType", + ); + } else if (opts.audienceType === "project-number" && urlShaped) { + opts.logger.warn( + { + channelType: "googlechat" as const, + hint: 'channels.googlechat.audienceType is "project-number" but channels.googlechat.audience looks like an endpoint URL — set audienceType to "app-url" for a URL audience, or set audience to your numeric Cloud project number', + errorKind: "config" as const, + }, + "Google Chat audience shape contradicts audienceType", + ); + } + } + + // 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); +} diff --git a/packages/channels/src/googlechat/errors.test.ts b/packages/channels/src/googlechat/errors.test.ts new file mode 100644 index 000000000..8f1927405 --- /dev/null +++ b/packages/channels/src/googlechat/errors.test.ts @@ -0,0 +1,150 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect } from "vitest"; +import { classifyGoogleChatError, parseRetryAfterSeconds } from "./errors.js"; + +describe("classifyGoogleChatError", () => { + it("classifies an absent status (transport failure) as a network error", () => { + const classified = classifyGoogleChatError(undefined); + expect(classified.errorKind).toBe("network"); + expect(classified.status).toBeUndefined(); + expect(classified.hint.toLowerCase()).toContain("connectivity"); + }); + + 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.status).toBe(401); + expect(classified.hint.trim().length).toBeGreaterThan(0); + expect(classified.hint.toLowerCase()).toContain("scope"); + }); + + it("classifies a 403 response as an auth failure", () => { + const classified = classifyGoogleChatError(403); + expect(classified.errorKind).toBe("auth"); + expect(classified.status).toBe(403); + }); + + it("classifies a 429 response as a platform failure that surfaces the status", () => { + const classified = classifyGoogleChatError(429); + expect(classified.errorKind).toBe("platform"); + expect(classified.status).toBe(429); + expect(classified.hint.trim().length).toBeGreaterThan(0); + }); + + 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.status).toBe(status); + } + }); + + 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"); + 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"); + }); +}); + +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(); + }); +}); diff --git a/packages/channels/src/googlechat/errors.ts b/packages/channels/src/googlechat/errors.ts new file mode 100644 index 000000000..bb328ffa5 --- /dev/null +++ b/packages/channels/src/googlechat/errors.ts @@ -0,0 +1,157 @@ +// 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. 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 + * 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" + | "config" + | "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. 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`, 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. + * @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", + }; + } + 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)); +} 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..25d97d15b --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-actions.test.ts @@ -0,0 +1,215 @@ +// 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 { 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); + 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]); + }); +}); + +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(); + }); +}); diff --git a/packages/channels/src/googlechat/googlechat-actions.ts b/packages/channels/src/googlechat/googlechat-actions.ts new file mode 100644 index 000000000..80afdf3af --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-actions.ts @@ -0,0 +1,177 @@ +// 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"; +import { systemNowMs } from "@comis/core"; +import { randomUUID } from "node:crypto"; + +/** + * 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; + }; +} + +/** + * 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 } : {}), + }, + }, + }; +} 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..8a60e7447 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-activity.test.ts @@ -0,0 +1,600 @@ +// 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("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); + 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(); + }); +}); 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)), + }); +} 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..f8bcab293 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-adapter.test.ts @@ -0,0 +1,2046 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, vi } from "vitest"; +import type { ComisLogger, NormalizedMessage } from "@comis/core"; +import { generateKeyPair, exportPKCS8 } from "jose"; +import { + createGoogleChatAdapter, + type GoogleChatAdapterDeps, +} from "./googlechat-adapter.js"; +import { GOOGLECHAT_APPROVAL_FUNCTION } from "./googlechat-actions.js"; +import { classifyGoogleChatRenderError } from "./googlechat-activity.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 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; + /** 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 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: status >= 200 && status < 300, + status, + headers: { + get: (name: string) => + name.toLowerCase() === "retry-after" ? (opts.retryAfter ?? null) : null, + }, + 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 }; +} + +/** 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, + }; +} + +/** 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(); + 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, + }, + }; +} + +/** + * 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, + 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); + }); + + 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(); + }); + + 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", () => { + 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("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); + 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("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(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 () => { + 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); + 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"); + }); + + // 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); + 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 [ + "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"); + }); +}); + +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("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 }); + 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, attaching the underlying err (never the token)", 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); + 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 () => { + 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); + }); + + 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); + }); +}); + +/** 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("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); + + // 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("Tom & Jerry _x_"); + }); + + 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(); + 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("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 }); + const adapter = createGoogleChatAdapter(deps); + + const result = await adapter.editMessage?.( + "spaces/AAAA", + "spaces/AAAA/messages/CCC", + "ok", + ); + + 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); + 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", () => { + 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(); + 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("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("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); + 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(); + 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 + }); + + 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)", () => { + 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); + + // 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"); + 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 result = await settleWithTimers( + adapter.sendMessage("spaces/AAAA", "hi"), + timers, + ); + + 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 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. + 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); + + // 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")); + 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(); + }); + + 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() + }); +}); + +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(); + }); +}); + +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"), + ); + } + + 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(); + 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"); + // 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"); + // 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", + "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("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()); + + await adapter.handleChatEvent( + messageEventWithAttachments([ + { source: "DRIVE_FILE", contentName: "a.pdf", driveDataRef: { driveFileId: "x" } }, + { source: "DRIVE_FILE", contentName: "b.pdf", driveDataRef: { driveFileId: "y" } }, + ]), + ); + + 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"]); + }); +}); + +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/googlechat-adapter.ts b/packages/channels/src/googlechat/googlechat-adapter.ts new file mode 100644 index 000000000..ccb77e19f --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-adapter.ts @@ -0,0 +1,984 @@ +// 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. + * + * 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. + * + * @module + */ + +import { randomUUID } from "node:crypto"; +import { + runWithContext, + systemNowMs, + systemSetTimeout, + systemClearTimeout, +} from "@comis/core"; +import type { + ChannelPort, + ChannelStatus, + ComisLogger, + MessageHandler, + NormalizedMessage, + ReconcileSendOutcome, + ReconcileSendQuery, + SendMessageOptions, +} from "@comis/core"; +import { ok, err, fromPromise, type Result } from "@comis/shared"; +import { + createGoogleChatTokenProvider, + CHAT_SCOPE, + PUBSUB_SCOPE, + type GoogleChatTokenProvider, +} from "./googlechat-auth.js"; +import { classifyGoogleChatError, parseRetryAfterSeconds } from "./errors.js"; +import { createSendPacer } from "./send-pacer.js"; +import { + createPubSubSource, + type PubSubSource, + type PubSubSourceDeps, +} from "./pubsub-source.js"; +import { + mapGoogleChatEventToNormalized, + extractGoogleChatAttachments, + type GoogleChatEvent, +} from "./message-mapper.js"; +import { + normalizeGoogleChatCardAction, + type GoogleChatCardClickEvent, +} from "./googlechat-actions.js"; +import { + renderGoogleChatCards, + renderGoogleChatButtons, +} from "./googlechat-rich-renderer.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; + +// --------------------------------------------------------------------------- +// Path safety for edit/delete resource names +// --------------------------------------------------------------------------- + +/** + * 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("..") && /^[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. */ + 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 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 + // 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, + // 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 + // 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 + // 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; + // 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) + ); + } + + /** + * 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(); 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. + 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 inbound redelivers. + throw new Error("no inbound handler registered"); + } + + _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", + ); + // @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)); + } + }, + ); + } + + 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": + // 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( + { + 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) + } + + // 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 + // 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, + ); + 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, + 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(s) skipped: no downloadable resource name", + ); + } + + await fanOutMessage(normalized); + } + + const adapter: GoogleChatAdapterHandle = { + channelId: CHANNEL_ID, + channelType: "googlechat", + + onMessage(handler: MessageHandler): void { + handlers.push(handler); + }, + + handleChatEvent, + + getPubSubTokenProvider(): GoogleChatTokenProvider { + return tokens; + }, + + 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); + + // 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. 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) { + 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: startErr, + hint: "Set channels.googlechat.serviceAccountKey (SecretRef) or GOOGLECHAT_SA_KEY, and channels.googlechat.subscriptionName", + errorKind: "auth" as const, + }, + "Adapter start failed", + ); + _lastError = startErr.message; + return err(startErr); + } + + 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> { + // Cancel any pending pace-wait so a parked send does not hold shutdown. + sendAbort.abort(); + 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> { + // 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")); + } + // 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"; + 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`; + // 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, thread: { name: threadName } } + : { 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: { + 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, 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 + // 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, + err: responded.error, + 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", + ); + // 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 (abortSignal.aborted) { + return err(new Error("send aborted during retry backoff")); + } + await abortableSleep(delayMs, abortSignal); + if (abortSignal.aborted) { + return err(new Error("send aborted during retry backoff")); + } + 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", + ); + // 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( + googleChatRestError( + `chat messages.create returned status ${res.status}`, + res.status, + retryAfter, + ), + ); + } + 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); + } + }, + + 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 — letters, digits, and ._/- only, with no query, fragment, or traversal 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 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(body), + }; + const responded = await fromPromise(doFetch(url, init)); + if (!responded.ok) { + const c = classifyGoogleChatError(undefined, responded.error); + deps.logger.error( + { + channelType: "googlechat" as const, + err: responded.error, + 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", + ); + // 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( + googleChatRestError( + `chat messages.patch returned status ${res.status}`, + res.status, + retryAfter, + ), + ); + } + 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 — letters, digits, and ._/- only, with no query, fragment, or traversal 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, + err: responded.error, + 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", + ); + // 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( + googleChatRestError( + `chat messages.delete returned status ${res.status}`, + res.status, + retryAfter, + ), + ); + } + return ok(undefined); + }, + + getStatus(): ChannelStatus { + return { + connected: _connected, + channelId: CHANNEL_ID, + channelType: "googlechat", + uptime: + _connected && _startedAt !== undefined + ? now() - _startedAt + : undefined, + lastMessageAt: _lastMessageAt, + lastInboundAt: _lastInboundAt, + error: _lastError ?? source?.lastError, + connectionMode: deps.mode === "webhook" ? "webhook" : "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; +} 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..28ece5937 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-auth.test.ts @@ -0,0 +1,856 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, vi } from "vitest"; +import type { ComisLogger } from "@comis/core"; +import { + generateKeyPair, + exportPKCS8, + exportJWK, + createLocalJWKSet, + jwtVerify, + decodeJwt, + decodeProtectedHeader, + SignJWT, +} from "jose"; +import { + createGoogleChatTokenProvider, + CHAT_SCOPE, + 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"; +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 — 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(); + 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") ?? ""; + // 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); + }); + + 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); + }); +}); + +// --- 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); + }); +}); + +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); + }); +}); diff --git a/packages/channels/src/googlechat/googlechat-auth.ts b/packages/channels/src/googlechat/googlechat-auth.ts new file mode 100644 index 000000000..0a872a1c0 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-auth.ts @@ -0,0 +1,524 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat auth — the two security-critical, transport-agnostic halves. + * + * 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 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. + * + * 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 + * 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, 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). + * + * @module + */ + +import { systemNowMs } from "@comis/core"; +import type { ComisLogger } from "@comis/core"; +import { ok, err, fromPromise, type Result } from "@comis/shared"; +import { + importPKCS8, + SignJWT, + createRemoteJWKSet, + createLocalJWKSet, + jwtVerify, + type JSONWebKeySet, +} 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>; + /** + * 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. */ +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 { + 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); + 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); + }, + }; +} + +// --- 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"; + +/** + * 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). + */ +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. 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, +): (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); + } + // 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/googlechat/googlechat-plugin.test.ts b/packages/channels/src/googlechat/googlechat-plugin.test.ts new file mode 100644 index 000000000..3431e0caf --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-plugin.test.ts @@ -0,0 +1,290 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect, vi } from "vitest"; +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, + 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"; +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 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): edit, + // 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, + deleteMessages: true, + fetchHistory: false, + attachments: false, + typing: false, + threads: true, + buttons: "cardsv2", + }); + }); + + 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 (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; + + // 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", + onReaction: "reactions:false", + fetchMessages: "fetchHistory:false", + sendAttachment: "attachments:false", + }; + for (const method of Object.keys(omittedForFalseFlag)) { + 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", () => { + 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); + }); +}); + +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"); + }); +}); diff --git a/packages/channels/src/googlechat/googlechat-plugin.ts b/packages/channels/src/googlechat/googlechat-plugin.ts new file mode 100644 index 000000000..dd512dcca --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-plugin.ts @@ -0,0 +1,139 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Google Chat Channel Plugin: wraps the Google Chat adapter as a + * ChannelPluginPort with an honest, app-auth capability matrix. + * + * The adapter sends and receives text over Pub/Sub pull, edits and deletes its + * 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 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(). 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 + */ + +import type { + ChannelCapability, + ChannelPluginPort, + MediaResolverPort, + PluginRegistryApi, +} from "@comis/core"; +import { ok, type Result } from "@comis/shared"; +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 = { + features: { + reactions: false, // user-auth-only — permanently omitted + 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: true, // threaded replies route through the send path + buttons: "cardsv2", // Cards v2 interactive widget buttons, rendered and routed + }, + 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 app-auth capability matrix (threaded + * replies, edit/delete; reactions, uploads, and history are omitted). + */ +export function createGoogleChatPlugin( + deps: GoogleChatAdapterDeps, +): GoogleChatPluginHandle { + 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(); + }, + + 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/googlechat/googlechat-resolver.test.ts b/packages/channels/src/googlechat/googlechat-resolver.test.ts new file mode 100644 index 000000000..967113db6 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-resolver.test.ts @@ -0,0 +1,413 @@ +// SPDX-License-Identifier: Apache-2.0 +import type { Attachment } from "@comis/core"; +import { ok, err } 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); + } + }); + + // ------------------------------------------------------------------------- + // 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(); + }, + ); + + // 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); + // 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); + }); +}); diff --git a/packages/channels/src/googlechat/googlechat-resolver.ts b/packages/channels/src/googlechat/googlechat-resolver.ts new file mode 100644 index 000000000..ac6d3242f --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-resolver.ts @@ -0,0 +1,259 @@ +// 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. + * + * 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 + * 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. + * + * 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 { sanitizeLogString, 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:\/\//; + +/** + * 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 +// --------------------------------------------------------------------------- + +/** + * 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 { + /** 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"], + + 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 || 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; + + // 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 || + 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(); + if (!tok.ok) return err(tok.error); + const authHeader = `Bearer ${tok.value}`; + + const startMs = systemNowMs(); + const fetched = await deps.ssrfFetcher.fetch(url, { + authHeader, + authAllowHosts: [CHAT_MEDIA_HOST], + }); + const durationMs = systemNowMs() - startMs; + + 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 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 }, + "Google Chat media resolved", + ); + + // Raw bytes only — the pipeline fences the DERIVED text, not the media bytes here. + return ok({ buffer, mimeType, sizeBytes }); + }, + }; +} 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..05f6e09e7 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-rich-renderer.test.ts @@ -0,0 +1,279 @@ +// 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"]); + }); + + 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", () => { + 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"'); + }); +}); 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..554c0d4a9 --- /dev/null +++ b/packages/channels/src/googlechat/googlechat-rich-renderer.ts @@ -0,0 +1,145 @@ +// 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 { + // 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: label, + onClick: { + action: { + function: GOOGLECHAT_APPROVAL_FUNCTION, + parameters: [{ key: "cb", value: btn.callback_data }], + }, + }, + }; + } + if (btn.url !== undefined) { + return { text: label, onClick: { openLink: { url: btn.url } } }; + } + return { text: label }; +} + +/** 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); +} 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..547782b3d --- /dev/null +++ b/packages/channels/src/googlechat/message-mapper.test.ts @@ -0,0 +1,557 @@ +// SPDX-License-Identifier: Apache-2.0 +import { describe, it, expect } from "vitest"; +import { parseMessage } from "@comis/core"; +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; + +/** + * 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("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 BOTH the generic threadId and the googlechat key", () => { + const result = mapGoogleChatEventToNormalized( + makeChatEvent({ + message: { + name: "spaces/AAAA/messages/1", + sender: { name: "users/1" }, + text: "hi", + thread: { name: "spaces/AAAA/threads/TTTT" }, + }, + }), + ); + // 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 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(); + }); + + 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); + }); +}); + +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(); + } + }); +}); + +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([]); + }); + + 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", () => { + 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); + }); + + // 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 new file mode 100644 index 000000000..889e01952 --- /dev/null +++ b/packages/channels/src/googlechat/message-mapper.ts @@ -0,0 +1,204 @@ +// 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 { 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 + * 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 }; + /** + * 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 }> = []; + // 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) { + // 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. */ +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 { + // 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; + 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"; + // 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 + // 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 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(), + // 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(), + // 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, + }; +} 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..1ec16aefa --- /dev/null +++ b/packages/channels/src/googlechat/pubsub-source.test.ts @@ -0,0 +1,724 @@ +// 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 { + 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"; +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-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-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([ + { 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); + }); +}); + +/** 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; +} + +/** 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(); + 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("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("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 () => { + 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); + }); +}); diff --git a/packages/channels/src/googlechat/pubsub-source.ts b/packages/channels/src/googlechat/pubsub-source.ts new file mode 100644 index 000000000..ae0db4a8c --- /dev/null +++ b/packages/channels/src/googlechat/pubsub-source.ts @@ -0,0 +1,480 @@ +// 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; + +/** 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}`. */ + 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; + // 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(); + + 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, + }; + } + + // 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) => { + // 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(); + }; + 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). + controller.signal.removeEventListener("abort", onAbort); + resolve(); + }, ms); + pendingBackoff = timer; + controller.signal.addEventListener("abort", onAbort, { 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; + // 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, + 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", + ); + } + + return { + start, + stop, + pollOnce, + get lastError() { + return lastError; + }, + get running() { + return running; + }, + }; +} 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 + }); +}); 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; + }, + }; +} diff --git a/packages/channels/src/index.ts b/packages/channels/src/index.ts index e873127a6..cdb099f9b 100644 --- a/packages/channels/src/index.ts +++ b/packages/channels/src/index.ts @@ -176,6 +176,34 @@ 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"; +export type { GoogleChatPluginHandle } 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, + createGoogleChatInboundVerifier, + createLocalGoogleChatInboundVerifier, + CHAT_SCOPE, + PUBSUB_SCOPE, +} from "./googlechat/googlechat-auth.js"; +export type { + GoogleChatTokenDeps, + GoogleChatTokenProvider, + GoogleChatScope, + GoogleChatInboundVerifierOpts, +} 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"; @@ -379,6 +407,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/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/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/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index e01152577..ed954f86c 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,11 @@ 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"); + expect(optionLongs).toContain("--googlechat-audience-type"); // Media generation + processing (8) expect(optionLongs).toContain("--image-provider"); @@ -99,11 +104,11 @@ describe("registerInitCommand", () => { expect(optionLongs).toContain("--reset-scope"); }); - it("has exactly 41 options", () => { + it("has exactly 46 options", () => { const program = new Command(); registerInitCommand(program); const initCmd = program.commands.find((c) => c.name() === "init")!; - expect(initCmd.options).toHaveLength(41); + expect(initCmd.options).toHaveLength(46); }); it("parses --channels as comma-separated list", () => { diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index 13d4ac011..7ab99ff37 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -125,6 +125,22 @@ 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, + // 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 project-number|app-url (same cast-then-validate + // contract as --googlechat-mode above). + googlechatAudienceType: options.googlechatAudienceType as + | "project-number" + | "app-url" + | undefined, imageProvider: options.imageProvider as string | undefined, imageApiKey: options.imageApiKey as string | undefined, videoProvider: options.videoProvider as string | undefined, @@ -206,6 +222,11 @@ 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)") + .option("--googlechat-audience-type ", "Google Chat webhook audience type: project-number|app-url (default: project-number)") // 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/doctor/checks/googlechat-health.test.ts b/packages/cli/src/doctor/checks/googlechat-health.test.ts new file mode 100644 index 000000000..9621d9530 --- /dev/null +++ b/packages/cli/src/doctor/checks/googlechat-health.test.ts @@ -0,0 +1,592 @@ +// 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`. + * `configExtra` merges additional top-level config sections (e.g. the global + * autoReplyEngine block the group-activation probe reads). + */ +function contextWith( + googlechat: Record, + extra: Partial = {}, + configExtra: Record = {}, +): DoctorContext { + const config = { channels: { googlechat }, ...configExtra } 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 the four applicable probes for a clean pubsub config", 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", + ]), + ); + }); + + // ------------------------------------------------------------------------- + // Probe 5: webhook audience shape vs audienceType cross-check + // ------------------------------------------------------------------------- + // + // The inbound verifier binds to a different key set + claim shape per + // audienceType, so an audience whose shape contradicts audienceType silently + // rejects every request. `comis doctor` must catch that before the daemon does. + + it("warns the webhook-audience probe when audienceType is project-number but audience is a URL", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "webhook", + serviceAccountKey: validSaKey, + audienceType: "project-number", + audience: "https://chat.example.com/hook", + }), + ); + const audienceCheck = find(findings, "Google Chat webhook audience"); + expect(audienceCheck?.status).toBe("warn"); + const text = `${audienceCheck?.message ?? ""} ${audienceCheck?.suggestion ?? ""}`; + expect(text).toContain("audienceType"); + }); + + it("warns the webhook-audience probe when audienceType is app-url but audience is not URL-shaped", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "webhook", + serviceAccountKey: validSaKey, + audienceType: "app-url", + audience: "1234567890", + }), + ); + const audienceCheck = find(findings, "Google Chat webhook audience"); + expect(audienceCheck?.status).toBe("warn"); + const text = `${audienceCheck?.message ?? ""} ${audienceCheck?.suggestion ?? ""}`; + expect(text).toContain("audienceType"); + }); + + it("passes the webhook-audience probe when audienceType app-url matches a URL audience", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "webhook", + serviceAccountKey: validSaKey, + audienceType: "app-url", + audience: "https://chat.example.com/hook", + }), + ); + const audienceCheck = find(findings, "Google Chat webhook audience"); + expect(audienceCheck?.status).toBe("pass"); + }); + + it("passes the webhook-audience probe when audienceType project-number matches a numeric audience", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "webhook", + serviceAccountKey: validSaKey, + audienceType: "project-number", + audience: "1234567890", + }), + ); + const audienceCheck = find(findings, "Google Chat webhook audience"); + expect(audienceCheck?.status).toBe("pass"); + }); + + it("omits the webhook-audience probe entirely in pubsub mode (audienceType is inert there)", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + subscriptionName: "projects/test-project/subscriptions/comis", + audienceType: "project-number", + }), + ); + expect(find(findings, "Google Chat webhook audience")).toBeUndefined(); + }); + + it("never echoes the service-account key into the webhook-audience finding (secret-safe)", async () => { + const findings = await googlechatHealthCheck.run( + contextWith({ + enabled: true, + mode: "webhook", + serviceAccountKey: validSaKey, + audienceType: "project-number", + audience: "https://chat.example.com/hook", + }), + ); + const audienceCheck = find(findings, "Google Chat webhook audience"); + expect(audienceCheck?.message ?? "").not.toContain(SECRET_MARKER); + expect(audienceCheck?.suggestion ?? "").not.toContain(SECRET_MARKER); + }); + + // ------------------------------------------------------------------------- + // Probe 6: inert "always" groupActivation lint + // ------------------------------------------------------------------------- + // + // Google Chat only delivers mentioned/slash-command space messages, so + // groupActivation "always" is inert there. The boot validator WARNs about it + // once in the daemon log; the doctor read is the surface an operator actually + // consults, so it must surface the same advisory. + + it('warns that groupActivation "always" is inert on Google Chat, naming the exact knob', async () => { + const findings = await googlechatHealthCheck.run( + contextWith( + { + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + subscriptionName: "projects/test-project/subscriptions/comis", + }, + {}, + { autoReplyEngine: { groupActivation: "always" } }, + ), + ); + const activation = find(findings, "Google Chat group activation"); + expect(activation?.status).toBe("warn"); + expect(activation?.message).toContain("autoReplyEngine.groupActivation"); + expect(activation?.message).toContain('"always"'); + }); + + it("omits the group-activation probe for mention-gated activation (nothing inert to flag)", async () => { + const findings = await googlechatHealthCheck.run( + contextWith( + { + enabled: true, + mode: "pubsub", + serviceAccountKey: validSaKey, + subscriptionName: "projects/test-project/subscriptions/comis", + }, + {}, + { autoReplyEngine: { groupActivation: "mention-gated" } }, + ), + ); + expect(find(findings, "Google Chat group activation")).toBeUndefined(); + }); +}); 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..cf3b3ce62 --- /dev/null +++ b/packages/cli/src/doctor/checks/googlechat-health.ts @@ -0,0 +1,526 @@ +// 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 six 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). + * 5. audience shape — webhook mode only. The inbound verifier binds to a + * different key set + claim shape per audienceType, so an + * audience whose shape contradicts audienceType (a URL + * audience declared project-number, or a non-URL audience + * declared app-url) warns — that mismatch silently rejects + * every inbound request. Omitted in pubsub mode. + * 6. activation lint — autoReplyEngine.groupActivation "always" warns: Google + * Chat only delivers mentioned/slash-command space + * messages, so "always" is inert on this platform. The + * boot validator logs the same advisory once; the doctor + * read is where an operator actually looks. Omitted for + * any other activation mode. + * + * 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); +} + +/** + * True when an audience value looks like an endpoint URL (an `app-url` audience) + * rather than a numeric project number (a `project-number` audience). + */ +function isUrlShapedAudience(audience: string): boolean { + return /^https?:\/\//i.test(audience.trim()); +} + +/** 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", + ), + ); +} + +// --------------------------------------------------------------------------- +// Probe 5: webhook audience shape vs audienceType cross-check +// --------------------------------------------------------------------------- + +/** + * Webhook-only cross-check: the inbound verifier binds to a different key set + + * claim shape per audienceType — a self-signed Chat-system token whose `aud` is + * the numeric project number (`project-number`), or a Google OIDC token whose + * `aud` is the endpoint URL (`app-url`). An audience whose SHAPE contradicts + * audienceType selects the wrong path and silently rejects EVERY inbound request, + * so `comis doctor` flags it before the daemon does. Returns [] in pubsub mode + * (audienceType is inert there) and when the audience is blank (a separate + * precondition the inbound-path probe and the daemon validator own). Content-free: + * the finding names the two config keys and the fix, never the audience value. + */ +function audienceShapeFindings(gc: GoogleChatConfigView): DoctorFinding[] { + const check = "Google Chat webhook audience"; + if (gc.mode !== "webhook" || isBlank(gc.audience)) return []; + + const audienceType = gc.audienceType ?? "project-number"; + const urlShaped = isUrlShapedAudience(gc.audience as string); + + if (audienceType === "app-url" && !urlShaped) { + return [ + finding( + "warn", + check, + 'audienceType is "app-url" but audience is not an endpoint URL — the inbound verifier expects a Google OIDC token whose audience is the endpoint URL', + 'Set channels.googlechat.audience to your https:// endpoint URL, or set channels.googlechat.audienceType to "project-number" if audience is your numeric Cloud project number', + ), + ]; + } + if (audienceType === "project-number" && urlShaped) { + return [ + finding( + "warn", + check, + 'audienceType is "project-number" but audience looks like an endpoint URL — the inbound verifier expects a self-signed Chat-system token whose audience is your numeric Cloud project number', + 'Set channels.googlechat.audienceType to "app-url" for a URL audience, or set channels.googlechat.audience to your numeric Cloud project number', + ), + ]; + } + return [finding("pass", check, "Webhook audience shape matches audienceType")]; +} + +// --------------------------------------------------------------------------- +// Probe 6: inert "always" groupActivation lint +// --------------------------------------------------------------------------- + +/** + * Google Chat delivers a space MESSAGE event only when the app is mentioned or + * slash-commanded, so autoReplyEngine.groupActivation "always" never sees the + * unmentioned traffic it is meant to answer — it is inert on this platform + * (mentions and slash commands still activate). The boot-time credential + * validator emits the same advisory once into the daemon log; this probe is the + * doctor-visible parity read so the operator's first troubleshooting command + * surfaces it too. Returns [] for any other activation mode. + */ +function groupActivationLintFindings(groupActivation: string | undefined): DoctorFinding[] { + if (groupActivation !== "always") return []; + return [ + finding( + "warn", + "Google Chat group activation", + 'autoReplyEngine.groupActivation is "always", but Google Chat never delivers unmentioned space messages — "always" is inert on this platform (mentions and slash commands still activate)', + 'Expect mention-gated behavior on Google Chat regardless of autoReplyEngine.groupActivation — "always" cannot broaden delivery here', + ), + ]; +} + +/** + * 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), + ...audienceShapeFindings(gc), + ...groupActivationLintFindings(context.config?.autoReplyEngine?.groupActivation), + ]; + }, +}; diff --git a/packages/cli/src/wizard/non-interactive.test.ts b/packages/cli/src/wizard/non-interactive.test.ts index 4f72008e5..516d646b4 100644 --- a/packages/cli/src/wizard/non-interactive.test.ts +++ b/packages/cli/src/wizard/non-interactive.test.ts @@ -12,9 +12,10 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { readFileSync } from "node:fs"; +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; -import { dirname, resolve } from "node:path"; +import { dirname, resolve, join } from "node:path"; // Stub the two @comis/core entry points the wizard's non-interactive path // touches: safePath (filesystem composition) + createModelCatalog (model @@ -45,7 +46,12 @@ vi.mock("@comis/core", () => ({ getProviders: vi.fn(), })), })); -vi.mock("node:os", () => ({ homedir: vi.fn(() => "/home/test") })); +// Override only homedir; keep the real tmpdir so the SA-key-path test can write +// a temp key file (node:fs is unmocked here). +vi.mock("node:os", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: vi.fn(() => "/home/test") }; +}); vi.mock("node:crypto", () => ({ randomBytes: vi.fn(() => ({ toString: () => "ab".repeat(24) })), })); @@ -404,6 +410,101 @@ 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("rejects an unknown --googlechat-audience-type (closed audience-shape vocabulary)", () => { + const opts = validOpts({ + googlechatAudienceType: + "numeric" as NonInteractiveOptions["googlechatAudienceType"], + }); + expect(() => validateNonInteractiveOptions(opts)).toThrow(NonInteractiveError); + try { + validateNonInteractiveOptions(opts); + } catch (e) { + expect((e as NonInteractiveError).field).toBe("googlechatAudienceType"); + expect((e as NonInteractiveError).message).toContain("project-number"); + expect((e as NonInteractiveError).message).toContain("app-url"); + } + }); + + it("accepts each valid --googlechat-audience-type value", () => { + for (const audienceType of ["project-number", "app-url"] as const) { + expect(() => + validateNonInteractiveOptions(validOpts({ googlechatAudienceType: audienceType })), + ).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" })), @@ -637,6 +738,115 @@ 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("threads --googlechat-audience-type into a webhook channel so the app-url shape is recorded", () => { + // The non-interactive parity for the interactive audience-type prompt: a CI + // webhook config whose audience is the endpoint URL must record + // audienceType: "app-url", or the config keeps the schema default + // ("project-number") and the inbound verifier silently rejects every request. + const state = buildNonInteractiveState( + validOpts({ + channels: ["googlechat"], + googlechatSaKey: '{"client_email":"bot@x.iam.gserviceaccount.com","private_key":"pk"}', + googlechatAudience: "https://chat.example.com/hook", + googlechatAudienceType: "app-url", + googlechatMode: "webhook", + }), + ); + const gc = state.channels![0]; + expect(gc.type).toBe("googlechat"); + expect(gc.mode).toBe("webhook"); + expect(gc.audience).toBe("https://chat.example.com/hook"); + expect(gc.audienceType).toBe("app-url"); + }); + + it("resolves a --googlechat-sa-key file PATH to the key contents, not the path string", () => { + // The flag help advertises "or a path to the key file". A CI user who + // follows it and passes a path must get the KEY read from that file + // persisted -- not the literal path string, which would JSON.parse-fail at + // daemon boot. This mirrors the interactive step's path resolution. + const dir = mkdtempSync(join(tmpdir(), "gc-sakey-")); + const keyPath = join(dir, "key.json"); + const keyObject = { + type: "service_account", + private_key: "-----BEGIN PRIVATE KEY-----\nMIIexample\n-----END PRIVATE KEY-----\n", + client_email: "bot@example-project.iam.gserviceaccount.com", + }; + // Pretty-printed, multi-line -- exactly a downloaded key file. + writeFileSync(keyPath, JSON.stringify(keyObject, null, 2), "utf-8"); + try { + const state = buildNonInteractiveState( + validOpts({ + channels: ["googlechat"], + googlechatSaKey: keyPath, + googlechatSubscription: "projects/p/subscriptions/s", + googlechatMode: "pubsub", + }), + ); + const gc = state.channels![0]; + expect(gc.serviceAccountKey).not.toBe(keyPath); + const parsed = JSON.parse(gc.serviceAccountKey as string) as { + client_email?: string; + private_key?: string; + }; + expect(parsed.client_email).toBe(keyObject.client_email); + expect(parsed.private_key).toBe(keyObject.private_key); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("stores an inline --googlechat-sa-key JSON blob verbatim (not treated as a path)", () => { + // The counterpart to the path case: a value that is not an existing file is + // the JSON content itself and must pass through untouched. + const inline = '{"client_email":"bot@x.iam.gserviceaccount.com","private_key":"pk"}'; + const state = buildNonInteractiveState( + validOpts({ + channels: ["googlechat"], + googlechatSaKey: inline, + googlechatSubscription: "projects/p/subscriptions/s", + googlechatMode: "pubsub", + }), + ); + expect(state.channels![0].serviceAccountKey).toBe(inline); + }); + 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 2d3df62b8..61d02376c 100644 --- a/packages/cli/src/wizard/non-interactive.ts +++ b/packages/cli/src/wizard/non-interactive.ts @@ -54,6 +54,7 @@ import type { } from "./prompter.js"; import { validatePort } from "./validators/port.js"; import { validateAgentName } from "./validators/agent-name.js"; +import { readServiceAccountKey } from "./service-account-key.js"; import { DAEMON_START_PROMPT, DAEMON_RESTART_PROMPT } from "./steps/11-daemon-start.js"; // ---------- Types ---------- @@ -88,6 +89,11 @@ export type NonInteractiveOptions = { msteamsAppPassword?: string; msteamsTenantId?: string; msteamsAuthMode?: "secret" | "certificate" | "managedIdentity"; + googlechatSaKey?: string; + googlechatSubscription?: string; + googlechatMode?: "pubsub" | "webhook"; + googlechatAudience?: string; + googlechatAudienceType?: "project-number" | "app-url"; // Media generation imageProvider?: string; imageApiKey?: string; @@ -318,6 +324,35 @@ 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", + ); + } + + // --googlechat-audience-type, when provided, must be one of the closed + // audience-shape vocabulary. The webhook inbound verifier binds to a different + // key set and claim shape per value, so a typo written verbatim into config.yaml + // would silently reject every inbound request. Reject early, mirroring the + // interactive select prompt. + if ( + opts.googlechatAudienceType !== undefined && + !["project-number", "app-url"].includes(opts.googlechatAudienceType) + ) { + throw new NonInteractiveError( + "--googlechat-audience-type must be one of: project-number, app-url", + "googlechatAudienceType", + ); + } + // Validate channel credentials if (opts.channels && opts.channels.length > 0) { for (const channel of opts.channels) { @@ -386,6 +421,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 @@ -469,6 +529,31 @@ export function buildNonInteractiveState( validated: false, }); break; + case "googlechat": + // --googlechat-sa-key may be a path to the key file OR the JSON + // content itself (e.g. --googlechat-sa-key "$(cat key.json)"). Resolve + // a path to its contents here, mirroring the interactive step, so a CI + // user who follows the flag's help gets the key persisted rather than + // the literal path (which would JSON.parse-fail at boot). write-config + // then swaps it for the ${GOOGLECHAT_SA_KEY} ref, compacts the (often + // multi-line) blob to a single line, and persists it. + channels.push({ + type: "googlechat", + serviceAccountKey: + opts.googlechatSaKey !== undefined + ? readServiceAccountKey(opts.googlechatSaKey) + : undefined, + subscriptionName: opts.googlechatSubscription, + audience: opts.googlechatAudience, + // Audience type is only meaningful in webhook mode; thread it through + // when supplied so write-config records it (pubsub omits it — the + // schema default is correct there). An absent flag leaves it unset, so + // the schema default (project-number) applies. + audienceType: opts.googlechatAudienceType, + mode: opts.googlechatMode, + validated: false, + }); + break; case "whatsapp": channels.push({ type: "whatsapp", validated: false }); break; diff --git a/packages/cli/src/wizard/service-account-key.ts b/packages/cli/src/wizard/service-account-key.ts new file mode 100644 index 000000000..55fe7c229 --- /dev/null +++ b/packages/cli/src/wizard/service-account-key.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Shared resolver for a Google Chat service-account key supplied as either a + * path to the JSON key file or the pasted/piped JSON itself. + * + * Reused by the interactive channel step and the non-interactive builder so a + * `--googlechat-sa-key ` flag and an interactive path entry behave + * identically — a single helper keeps the two collection paths from drifting. + * The value is never logged. + * + * @module + */ + +import { existsSync, readFileSync } from "node:fs"; + +/** + * 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. + */ +export function readServiceAccountKey(input: string): string { + const trimmed = input.trim(); + if (trimmed.length > 0 && existsSync(trimmed)) { + return readFileSync(trimmed, "utf-8"); + } + return input; +} diff --git a/packages/cli/src/wizard/steps/06-channels.test.ts b/packages/cli/src/wizard/steps/06-channels.test.ts index 7d05fc3b2..10cb67c6c 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"; @@ -433,7 +435,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 +452,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 +465,93 @@ describe("channelsStep", () => { expect(values).toContain("irc"); expect(values).toContain("line"); 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, project-number) 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 (numeric project number) + // Two selects in webhook mode: the transport, then the audience type. + vi.mocked(prompter.select) + .mockResolvedValueOnce("webhook") + .mockResolvedValueOnce("project-number"); + + 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", + audienceType: "project-number", + audience: "123456789012", + validated: false, + }); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); + + it("captures audienceType 'app-url' for a webhook channel with an endpoint-URL audience", async () => { + // The exact misconfig this closes: a webhook app whose token audience is the + // endpoint URL must record audienceType: "app-url" so the inbound verifier + // selects the OIDC key set + sender-binding claim rather than the Chat-system + // key set. Without capturing it, the config keeps the schema default + // ("project-number") and every inbound request is silently rejected. + const prompter = createMockPrompter({ confirm: false }); + vi.mocked(prompter.multiselect).mockResolvedValueOnce(["googlechat"]); + vi.mocked(prompter.text) + .mockResolvedValueOnce(GC_SA_KEY) // SA key (pasted JSON) + .mockResolvedValueOnce("https://chat.example.com/hook"); // audience (endpoint URL) + vi.mocked(prompter.select) + .mockResolvedValueOnce("webhook") + .mockResolvedValueOnce("app-url"); + + 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: "webhook", + audienceType: "app-url", + audience: "https://chat.example.com/hook", + validated: false, + }); }); }); diff --git a/packages/cli/src/wizard/steps/06-channels.ts b/packages/cli/src/wizard/steps/06-channels.ts index d7d8b3b31..1de488e72 100644 --- a/packages/cli/src/wizard/steps/06-channels.ts +++ b/packages/cli/src/wizard/steps/06-channels.ts @@ -2,10 +2,10 @@ /** * Channel setup step -- step 06 of the init wizard. * - * Presents a multiselect of all 8 supported channels with credential hints, + * Presents a multiselect of the supported chat channels with credential hints, * collects per-channel credentials inline with format pre-checks and live - * API validation, shows deferred guidance for WhatsApp/Signal, silently - * adds IRC, and stores ChannelConfig[] on wizard state. + * API validation, shows deferred guidance for WhatsApp/Signal, and stores + * ChannelConfig[] on wizard state. * * Live validation uses native fetch (Node 22+) with AbortController * timeouts, matching the pattern from 04-credentials.ts. @@ -23,6 +23,7 @@ import type { WizardPrompter } from "../prompter.js"; import { updateState } from "../state.js"; import { sectionSeparator, info } from "../theme.js"; import { validateChannelCredential } from "../validators/channel-creds.js"; +import { readServiceAccountKey } from "../service-account-key.js"; import { systemClearTimeout, systemSetTimeout } from "@comis/core"; // ---------- Live Validation Functions ---------- @@ -512,6 +513,95 @@ async function handleMsTeams( }; } +/** + * 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") { + // The inbound Bearer-JWT verifier binds to a DIFFERENT audience shape per + // type: "project-number" expects a self-signed Chat-system token whose aud is + // your Cloud project number; "app-url" expects a Google OIDC token whose aud + // is your endpoint URL (plus a sender-binding email claim). Capturing the type + // is load-bearing — a config whose audience shape contradicts the type + // selects the wrong key set and silently rejects every inbound request. + const audienceType = await prompter.select<"project-number" | "app-url">({ + message: "Inbound JWT audience type", + options: [ + { + value: "project-number", + label: "Project number", + hint: "Google mints the token with your Cloud project number as the audience (recommended)", + }, + { + value: "app-url", + label: "Endpoint URL", + hint: "Google mints an OIDC token with your endpoint URL as the audience", + }, + ], + initialValue: "project-number", + }); + const audience = await prompter.text({ + message: + audienceType === "app-url" + ? "Inbound JWT audience (endpoint URL)" + : "Inbound JWT audience (project number)", + validate: (v: string) => { + if (typeof v !== "string") return undefined; + const result = validateChannelCredential("googlechat", "audience", v); + return result?.message; + }, + }); + return { type: "googlechat", serviceAccountKey, mode, audienceType, 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 +719,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.env-roundtrip.test.ts b/packages/cli/src/wizard/steps/10-write-config.env-roundtrip.test.ts new file mode 100644 index 000000000..90d7e2d25 --- /dev/null +++ b/packages/cli/src/wizard/steps/10-write-config.env-roundtrip.test.ts @@ -0,0 +1,122 @@ +// SPDX-License-Identifier: Apache-2.0 +/** + * Real-filesystem round-trip test for the write-config step's plaintext .env. + * + * The sibling 10-write-config.test.ts mocks node:fs AND loadEnvFile, so it only + * ever inspects the in-memory string handed to writeFileSync. That hides a + * whole failure class: a secret that looks fine in the captured string can + * still be corrupted by the REAL line-based .env reader the daemon runs at + * boot. Google Chat's service-account key is the first channel secret that is a + * multi-line JSON blob, so it is exactly the value that reader mangles. + * + * This test uses NO mocks: it drives the actual writeConfigStep against a real + * temp home in "file" storage mode with a pretty-printed (multi-line) key, then + * reads the produced .env back through the REAL loadEnvFile and asserts the key + * survives as parseable JSON — the property the daemon depends on. + */ + +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync, existsSync } from "node:fs"; +import { tmpdir, homedir } from "node:os"; +import { join } from "node:path"; +import { loadEnvFile, safePath } from "@comis/core"; +import type { WizardPrompter, WizardState, Spinner } from "../index.js"; +import { writeConfigStep } from "./10-write-config.js"; + +// A pretty-printed service-account key, byte-for-byte the shape the Google +// Cloud console downloads (and what `--googlechat-sa-key "$(cat key.json)"` +// expands to). The multi-line structure is what breaks the line-based reader. +const SA_KEY_OBJECT = { + type: "service_account", + project_id: "example-project", + private_key: "-----BEGIN PRIVATE KEY-----\nMIIexampleBODY\n-----END PRIVATE KEY-----\n", + client_email: "bot@example-project.iam.gserviceaccount.com", + token_uri: "https://oauth2.googleapis.com/token", +}; +const MULTILINE_SA_KEY = JSON.stringify(SA_KEY_OBJECT, null, 2); + +/** Minimal prompter stub — the step only draws spinners + log lines. */ +function stubPrompter(): WizardPrompter { + const spinner: Spinner = { + start: () => {}, + update: () => {}, + stop: () => {}, + }; + const noop = () => {}; + return { + intro: noop, + outro: noop, + note: noop, + text: async () => "", + select: async () => "", + multiselect: async () => [], + password: async () => "", + confirm: async () => false, + spinner: () => spinner, + group: (async () => ({})) as WizardPrompter["group"], + log: { info: noop, warn: noop, error: noop, success: noop }, + }; +} + +function googlechatFileState(home: string): WizardState { + return { + completedSteps: [], + provider: { id: "anthropic", apiKey: "sk-test-key-123" }, + agentName: "test-agent", + model: "claude-sonnet-4-5-20250929", + storageMode: "file", + channels: [ + { + type: "googlechat", + serviceAccountKey: MULTILINE_SA_KEY, + subscriptionName: "projects/p/subscriptions/s", + mode: "pubsub", + validated: false, + }, + ], + gateway: { port: 4766, bindMode: "loopback", token: "test-token-value" }, + dataDir: safePath(home, ".comis", "data"), + }; +} + +describe("write-config .env round-trip (real loadEnvFile, file storage mode)", () => { + let tmpHome: string; + let originalHome: string | undefined; + + beforeEach(() => { + originalHome = process.env.HOME; + tmpHome = mkdtempSync(join(tmpdir(), "comis-writecfg-")); + // The step resolves paths via os.homedir(); on POSIX that honors $HOME. + process.env.HOME = tmpHome; + }); + + afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + rmSync(tmpHome, { recursive: true, force: true }); + }); + + it("writes a multi-line Google Chat SA key that survives loadEnvFile as parseable JSON", async () => { + // Sanity: os.homedir() must resolve to our temp home, or the step would + // write into the developer's real ~/.comis. + expect(homedir()).toBe(tmpHome); + + await writeConfigStep.execute(googlechatFileState(tmpHome), stubPrompter()); + + const envPath = join(tmpHome, ".comis", ".env"); + expect(existsSync(envPath)).toBe(true); + + // Read the produced .env exactly as the daemon does at boot. + const env: Record = {}; + loadEnvFile(envPath, env); + + // The daemon does JSON.parse(${GOOGLECHAT_SA_KEY}); a truncated "{" throws. + expect(env.GOOGLECHAT_SA_KEY).toBeDefined(); + const parsed = JSON.parse(env.GOOGLECHAT_SA_KEY as string) as { + client_email?: string; + private_key?: string; + }; + expect(parsed.client_email).toBe(SA_KEY_OBJECT.client_email); + expect(parsed.private_key).toBe(SA_KEY_OBJECT.private_key); + }); +}); 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 1bc0d4b76..f678d67dd 100644 --- a/packages/cli/src/wizard/steps/10-write-config.test.ts +++ b/packages/cli/src/wizard/steps/10-write-config.test.ts @@ -66,7 +66,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"; @@ -919,6 +919,135 @@ 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). + // A pretty-printed (multi-line) key -- the shape the Cloud console downloads + // and what `--googlechat-sa-key "$(cat key.json)"` expands to. Collection + // must compact it to a single .env line (SA_KEY_COMPACT), or the daemon's + // line-based .env reader truncates it to "{" and JSON.parse fails at boot. + const SA_KEY_OBJECT = { + type: "service_account", + client_email: "bot@example-project.iam.gserviceaccount.com", + private_key: "-----BEGIN PRIVATE KEY-----\nMIIexample\n-----END PRIVATE KEY-----\n", + }; + const SA_KEY_BLOB = JSON.stringify(SA_KEY_OBJECT, null, 2); + const SA_KEY_COMPACT = JSON.stringify(SA_KEY_OBJECT); + + 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: "https://chat.example.com/hook", + audienceType: "app-url", + 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 + audienceType 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("https://chat.example.com/hook"); + // audienceType must reach config.yaml so the inbound verifier binds to the + // right key set/claim shape — a captured app-url type that is dropped here + // would fall back to the schema default and silently reject every request. + expect(gc.audienceType).toBe("app-url"); + 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 SA key is the one multi-line channel secret. It MUST land on a + // single .env line, or the daemon's line-based reader truncates it to "{" + // and JSON.parse fails at boot. Assert the emitted line carries the whole, + // parseable key (compacted) -- not just its first "{" line. + const envLine = envContent + .split("\n") + .find((l) => l.startsWith("GOOGLECHAT_SA_KEY=")); + expect(envLine).toBeDefined(); + const rawValue = envLine!.slice("GOOGLECHAT_SA_KEY=".length); + expect(rawValue).toBe(SA_KEY_COMPACT); + const parsed = JSON.parse(rawValue) as { + client_email?: string; + private_key?: string; + }; + expect(parsed.client_email).toBe("bot@example-project.iam.gserviceaccount.com"); + expect(parsed.private_key).toContain("BEGIN PRIVATE KEY"); + }); + }); }); describe("config-audit provenance for the wizard write", () => { diff --git a/packages/cli/src/wizard/steps/10-write-config.ts b/packages/cli/src/wizard/steps/10-write-config.ts index 9513f21c2..5f3d5d531 100644 --- a/packages/cli/src/wizard/steps/10-write-config.ts +++ b/packages/cli/src/wizard/steps/10-write-config.ts @@ -191,6 +191,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}`; @@ -293,6 +308,33 @@ 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. + // + // It is also the only channel secret that is a multi-line value: a real + // key (downloaded from the console, or `--googlechat-sa-key "$(cat + // key.json)"`) is pretty-printed across many lines. The plaintext .env + // writer is one-line-per-key and the daemon's .env reader is line-based, + // so a multi-line value is silently truncated to "{" at boot and the + // JSON.parse then fails. Compact to single-line JSON here, at the single + // point both the .env writer and the encrypted store draw from — lossless + // for JSON.parse and for the encrypted store alike. A non-JSON value + // (a fat-fingered non-interactive flag) is left raw so the daemon reports + // an honest parse error instead of a silently corrupted key. + if (ch.serviceAccountKey && ch.type === "googlechat") { + const googlechatEnvKeys = CHANNEL_ENV_KEYS["googlechat"]; + if (googlechatEnvKeys?.[0]) { + let compact = ch.serviceAccountKey; + try { + compact = JSON.stringify(JSON.parse(ch.serviceAccountKey)); + } catch { + // Leave raw; the daemon surfaces the parse failure honestly. + } + managed.set(googlechatEnvKeys[0], compact); + } + } if (ch.appToken) managed.set(`${ch.type.toUpperCase()}_APP_TOKEN`, ch.appToken); } } diff --git a/packages/cli/src/wizard/types.ts b/packages/cli/src/wizard/types.ts index b40a1a568..2ff26c5c0 100644 --- a/packages/cli/src/wizard/types.ts +++ b/packages/cli/src/wizard/types.ts @@ -77,8 +77,15 @@ export type WizardError = { // ---------- Configuration Sub-types ---------- /** Per-channel collected credentials. */ +// @optional-field-count: 16 — the flat per-channel collected-credentials bag. Each +// channel contributes its own optional field group (telegram/discord/slack/whatsapp/ +// signal/irc/line tokens, msteams appId/appPassword/tenantId/authMode, googlechat +// serviceAccountKey/subscriptionName/mode/audienceType/audience) and only the fields +// for the selected `type` discriminant are populated. Splitting into a per-channel +// discriminated union would fragment every wizard step and config-write consumer; the +// count grows with the channel set, not with under-modeling. 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; @@ -92,6 +99,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. */ @@ -327,6 +342,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 ---------- @@ -565,4 +581,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": 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"); + }); +}); 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 058e50833..344b14c18 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 @@ -4065,6 +4065,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, @@ -9033,6 +9147,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, @@ -9610,6 +9732,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, @@ -21454,6 +21690,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": { @@ -23436,6 +23808,7 @@ exports[`section-registry parity > schema-serializer view > getConfigSchema("cha "irc", "email", "msteams", + "googlechat", "healthCheck" ], "type": "object" @@ -32460,6 +32833,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, @@ -33186,6 +33567,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": { @@ -35168,6 +35685,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.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(); + }); }); diff --git a/packages/core/src/config/schema-channel.ts b/packages/core/src/config/schema-channel.ts index 3c32bba78..f99f7af04 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 + 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 + 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; 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" }, diff --git a/packages/core/src/domain/channel-capability.ts b/packages/core/src/domain/channel-capability.ts index c1f57c827..f8a191d4a 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"), }); diff --git a/packages/core/src/security/secret-detection.test.ts b/packages/core/src/security/secret-detection.test.ts index 5987e7161..a50ac2f7f 100644 --- a/packages/core/src/security/secret-detection.test.ts +++ b/packages/core/src/security/secret-detection.test.ts @@ -112,6 +112,13 @@ describe("isSecretFieldName — superset", () => { } }); + it("flags serviceAccountKey — the Google Chat service-account JSON field", () => { + // A raw service-account JSON blob carries a private key but matches no + // generic suffix (*token/*secret/*password/*apiKey/…), so the exact name + // must be in the superset or the whole keystone is blind to it. + expect(isSecretFieldName("serviceAccountKey")).toBe(true); + }); + it("does not flag plainly non-secret field names", () => { expect(isSecretFieldName("name")).toBe(false); expect(isSecretFieldName("url")).toBe(false); diff --git a/packages/core/src/security/secret-detection.ts b/packages/core/src/security/secret-detection.ts index a4b54092a..0a8a2793a 100644 --- a/packages/core/src/security/secret-detection.ts +++ b/packages/core/src/security/secret-detection.ts @@ -214,7 +214,7 @@ export function looksLikeSecretValue(value: string): boolean { * Pattern matching field names that contain secrets. */ const SECRET_FIELD_PATTERN = - /^(.*token|.*secret|.*password|.*apiKey|.*api_key|.*credential|.*private_key|botToken|appSecret|hmacSecret|webhookSecret)$/i; + /^(.*token|.*secret|.*password|.*apiKey|.*api_key|.*credential|.*private_key|botToken|appSecret|hmacSecret|webhookSecret|serviceAccountKey)$/i; /** * Exact (lowercased) field names that imply a secret but are NOT matched by diff --git a/packages/core/src/security/secrets-audit.test.ts b/packages/core/src/security/secrets-audit.test.ts index 569a73c14..5cd2d4ba4 100644 --- a/packages/core/src/security/secrets-audit.test.ts +++ b/packages/core/src/security/secrets-audit.test.ts @@ -44,6 +44,43 @@ describe("scanConfigForSecrets", () => { }); }); + it("detects a plaintext googlechat serviceAccountKey (raw service-account JSON)", () => { + // The service-account JSON blob contains a private key but the field name + // matches no generic secret suffix — the audit must recognize it exactly, + // like botToken/appPassword, or a pasted key sits in config.yaml unflagged. + const config = { + channels: { + googlechat: { + enabled: true, + serviceAccountKey: + '{"type":"service_account","private_key":"-----BEGIN PRIVATE KEY-----\\ntest-key\\n-----END PRIVATE KEY-----\\n","client_email":"test-bot@example.iam.gserviceaccount.com"}', + }, + }, + }; + + const findings = scanConfigForSecrets("/etc/comis/config.yaml", config); + + expect(findings).toHaveLength(1); + expect(findings[0]).toEqual({ + code: "PLAINTEXT_SECRET", + severity: "error", + file: "/etc/comis/config.yaml", + jsonPath: "channels.googlechat.serviceAccountKey", + message: expect.stringContaining("Plaintext secret detected in field 'serviceAccountKey'"), + }); + }); + + it("skips the wizard-written ${GOOGLECHAT_SA_KEY} reference for serviceAccountKey", () => { + const config = { + channels: { + googlechat: { enabled: true, serviceAccountKey: "${GOOGLECHAT_SA_KEY}" }, + }, + }; + + const findings = scanConfigForSecrets("/etc/comis/config.yaml", config); + expect(findings).toHaveLength(0); + }); + it("skips SecretRef objects (properly configured)", () => { const config = { channels: { @@ -306,6 +343,22 @@ describe("scanEnvForSecrets", () => { expect(findings[0].message).toContain("msteams"); expect(findings[0].message).not.toContain("unknown"); }); + + it("names googlechat as the provider for GOOGLECHAT_SA_KEY in .env", () => { + // GOOGLECHAT_SA_KEY ends in _KEY — not _API_KEY/_SECRET/_TOKEN/_PASSWORD — + // so no generic fallthrough matches it; without an explicit provider entry + // the env audit is fully blind to the Google Chat service-account key. + const env = { + GOOGLECHAT_SA_KEY: '{"type":"service_account","private_key":"test-key"}', + }; + + const findings = scanEnvForSecrets("/home/user/.comis/.env", env); + + expect(findings).toHaveLength(1); + expect(findings[0].code).toBe("KNOWN_PROVIDER_ENV"); + expect(findings[0].jsonPath).toBe("GOOGLECHAT_SA_KEY"); + expect(findings[0].message).toContain("googlechat"); + }); }); // ── auditSecrets ─────────────────────────────────────────────────── diff --git a/packages/core/src/security/secrets-audit.ts b/packages/core/src/security/secrets-audit.ts index b08f78105..7f64bed33 100644 --- a/packages/core/src/security/secrets-audit.ts +++ b/packages/core/src/security/secrets-audit.ts @@ -56,6 +56,7 @@ const KNOWN_PROVIDER_PATTERNS: ReadonlyArray<{ pattern: RegExp; provider: string { pattern: /^SLACK_BOT_TOKEN$/, provider: "slack" }, { pattern: /^SLACK_SIGNING_SECRET$/, provider: "slack" }, { pattern: /^MSTEAMS_APP_PASSWORD$/, provider: "msteams" }, + { pattern: /^GOOGLECHAT_SA_KEY$/, provider: "googlechat" }, { pattern: /^GROQ_API_KEY$/, provider: "groq" }, { pattern: /^DEEPGRAM_API_KEY$/, provider: "deepgram" }, { pattern: /^ELEVENLABS_API_KEY$/, provider: "elevenlabs" }, 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 87551487a..949148c91 100644 --- a/packages/daemon/src/daemon.ts +++ b/packages/daemon/src/daemon.ts @@ -2166,7 +2166,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, @@ -2383,6 +2383,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, @@ -2444,6 +2445,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; @@ -2527,6 +2529,7 @@ async function bootGateway( instanceId, startupStartMs, interactiveCallbackWiring, msTeamsIngress, + googlechatIngress, obsExplainForMcpClient, obsFleetHealthForMcpClient, }); 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..4d3c95849 --- /dev/null +++ b/packages/daemon/src/wiring/googlechat-test-seams.test.ts @@ -0,0 +1,410 @@ +// 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 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, + 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"; +/** 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; + 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 }; +} + +/** + * 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 => { + 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); + }); +}); + +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); + }); +}); + +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"); + }); +}); + +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/googlechat-test-seams.ts b/packages/daemon/src/wiring/googlechat-test-seams.ts new file mode 100644 index 000000000..5f4cb45a2 --- /dev/null +++ b/packages/daemon/src/wiring/googlechat-test-seams.ts @@ -0,0 +1,183 @@ +// 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. + * - `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. + * + * @module + */ + +import { readFileSync } from "node:fs"; +import { + createGoogleChatInboundVerifier, + createLocalGoogleChatInboundVerifier, +} 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"; +/** 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 + * 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 } : {}), + }); + } + // 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")); + // 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, + audience: cfg.audience, + ...(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; +} + +/** + * 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/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"]]), 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); + }); }); diff --git a/packages/daemon/src/wiring/setup-channel-liveness-monitor.ts b/packages/daemon/src/wiring/setup-channel-liveness-monitor.ts index f4a26390a..159b2b646 100644 --- a/packages/daemon/src/wiring/setup-channel-liveness-monitor.ts +++ b/packages/daemon/src/wiring/setup-channel-liveness-monitor.ts @@ -30,6 +30,27 @@ 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 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; @@ -37,8 +58,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 +71,20 @@ 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; + // 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. + if (!WEBHOOK_CHANNEL_TYPES.some((t) => webhookChannelConfig(t)?.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 +123,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 (typed, total — no cast). + const thresholdMs = + webhookChannelConfig(channelType)?.missedInboundThresholdMs ?? + DEFAULT_MISSED_INBOUND_MS; + const lastInboundAt = status.lastInboundAt ?? null; const baselineMs = lastInboundAt ?? daemonStartMs; const silentForMs = currentMs - baselineMs; @@ -125,7 +161,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 = 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(); diff --git a/packages/daemon/src/wiring/setup-channels-adapters.test.ts b/packages/daemon/src/wiring/setup-channels-adapters.test.ts index f4c863810..39f61abae 100644 --- a/packages/daemon/src/wiring/setup-channels-adapters.test.ts +++ b/packages/daemon/src/wiring/setup-channels-adapters.test.ts @@ -40,6 +40,17 @@ const mockMsTeamsPlugin = { // caller-backed wiring can be asserted by identity. const mockMsTeamsIngress = { __msteamsIngress: true }; +// 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), createDiscordPlugin: vi.fn(() => mockDiscordPlugin), @@ -64,6 +75,14 @@ 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 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 @@ -71,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"; @@ -95,8 +115,10 @@ import { validateEmailCredentials, createMsTeamsPlugin, validateMsTeamsCredentials, + createGoogleChatPlugin, + validateGoogleChatCredentials, } from "@comis/channels"; -import { createMsTeamsIngress } from "@comis/gateway"; +import { createMsTeamsIngress, createGoogleChatIngress } from "@comis/gateway"; // --------------------------------------------------------------------------- // Helpers @@ -114,18 +136,26 @@ 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 }, }; } -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]; 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; } @@ -720,4 +750,372 @@ describe("bootstrapAdapters", () => { expect.stringContaining("Teams credential validation failed"), ); }); + + // ------------------------------------------------------------------------- + // 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. + // ------------------------------------------------------------------------- + + 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("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("threads audienceType into the boot-time validator so the audience-shape-mismatch WARN can fire", async () => { + const saKey = '{"private_key":"pk","client_email":"bot@proj.iam.gserviceaccount.com"}'; + // A webhook config whose audience is an endpoint URL but whose audienceType is + // the schema default (project-number): the validator owns the content-free + // mismatch WARN, but only if the daemon hands it audienceType alongside the + // audience. Without threading, the mismatch stays silent at boot. + const container = makeContainer({ + googlechat: { + enabled: true, + serviceAccountKey: saKey, + mode: "webhook", + audienceType: "project-number", + audience: "https://chat.example.com/hook", + }, + }); + await bootstrapAdapters({ container, channelsLogger }); + + expect(validateGoogleChatCredentials).toHaveBeenCalledWith( + expect.objectContaining({ + audienceType: "project-number", + audience: "https://chat.example.com/hook", + }), + ); + }); + + 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" } }, + { 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(); + // 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("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({ + 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(); + }); + + // ------------------------------------------------------------------------- + // 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. + // ------------------------------------------------------------------------- + + // 30s: the first vi.importActual("@comis/channels") is a cold import of the + // full real channels graph — well over the 5s default under coverage + // instrumentation. An abort here leaks the still-running bootstrapAdapters + // call into the next test's createGoogleChatIngress spy. + it("registers a documented webhook config (real validator, no subscriptionName) and mounts the ingress", { timeout: 30_000 }, 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", { timeout: 30_000 }, 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"), + ); + }); }); diff --git a/packages/daemon/src/wiring/setup-channels-adapters.ts b/packages/daemon/src/wiring/setup-channels-adapters.ts index 9dfd71bd5..e82642f84 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,20 +32,28 @@ import { validateIrcConnection, validateEmailCredentials, validateMsTeamsCredentials, + validateGoogleChatCredentials, type TelegramPluginHandle, type LinePluginHandle, type EmailAdapterDeps, 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, + resolveTestGoogleChatEgress, +} from "./googlechat-test-seams.js"; import os from "node:os"; import { safePath, systemNowMs } from "@comis/core"; +import { suppressError } from "@comis/shared"; // --------------------------------------------------------------------------- // Result type @@ -71,6 +80,17 @@ 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; + /** 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; } // --------------------------------------------------------------------------- @@ -110,6 +130,8 @@ export async function bootstrapAdapters(deps: { let linePlugin: LinePluginHandle | undefined; 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 => { @@ -498,6 +520,141 @@ export async function bootstrapAdapters(deps: { } } + // 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, + // 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, + // Thread audienceType alongside the audience so the validator's content-free + // audience-shape lint can fire: in webhook mode an audience whose shape + // contradicts audienceType selects the wrong inbound key set and silently + // rejects every request. Absent this, that mismatch stays invisible at boot. + audienceType: channelConfig.googlechat.audienceType, + 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)) { + // 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() + // never opens the pull loop, so the value is unused there. + subscriptionName: subscriptionName ?? "", + allowFrom: channelConfig.googlechat.allowFrom, + 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); + // 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; + // 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 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( + { + 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. 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) { + 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- + // 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 { + // 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"); + } + } + } + if (adaptersByType.size > 0) { channelsLogger.info({ channels: Array.from(adaptersByType.keys()), count: adaptersByType.size }, "Channel adapters initialized"); } else { @@ -505,5 +662,5 @@ export async function bootstrapAdapters(deps: { } } // end if (channelConfig) - return { adaptersByType, tgPlugin, linePlugin, channelPlugins, msTeamsIngress, msTeamsPlugin }; + return { adaptersByType, tgPlugin, linePlugin, channelPlugins, msTeamsIngress, msTeamsPlugin, googlechatPlugin, googlechatIngress }; } 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); diff --git a/packages/daemon/src/wiring/setup-channels-media.ts b/packages/daemon/src/wiring/setup-channels-media.ts index 9c902ef1f..68b01f16e 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, @@ -59,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; @@ -66,6 +71,7 @@ export interface MediaPipelineDeps { tgPlugin?: TelegramPluginHandle; linePlugin?: LinePluginHandle; msTeamsPlugin?: MsTeamsPluginHandle; + gcPlugin?: GoogleChatPluginHandle; ssrfFetcher: SsrfGuardedFetcher; linkRunner: LinkRunner; transcriber?: TranscriptionPort; @@ -102,6 +108,7 @@ export async function buildMediaPipeline(deps: MediaPipelineDeps): Promise { 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(); 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}. */ diff --git a/packages/daemon/src/wiring/setup-channels/setup-channels-registry-builder.test.ts b/packages/daemon/src/wiring/setup-channels/setup-channels-registry-builder.test.ts index 7d9a9b003..1d252bcfb 100644 --- a/packages/daemon/src/wiring/setup-channels/setup-channels-registry-builder.test.ts +++ b/packages/daemon/src/wiring/setup-channels/setup-channels-registry-builder.test.ts @@ -149,4 +149,18 @@ describe("buildChannelCredentialMap", () => { expect(buildChannelCredentialMap({ msteams: { enabled: false } }).has("MSTEAMS_APP_PASSWORD")).toBe(false); expect(buildChannelCredentialMap({}).has("MSTEAMS_APP_PASSWORD")).toBe(false); }); + + it("maps GOOGLECHAT_SA_KEY to googlechat when the googlechat channel is enabled", () => { + // The adapter reads the service-account key once at setup and mints JWTs + // from it in memory — without this entry a rotated key never triggers the + // targeted reconnect, and the adapter keeps signing with the stale key + // until a manual daemon restart. + const m = buildChannelCredentialMap({ googlechat: { enabled: true } }); + expect(m.get("GOOGLECHAT_SA_KEY")).toBe("googlechat"); + }); + + it("omits the googlechat credential entry when googlechat is disabled or absent", () => { + expect(buildChannelCredentialMap({ googlechat: { enabled: false } }).has("GOOGLECHAT_SA_KEY")).toBe(false); + expect(buildChannelCredentialMap({}).has("GOOGLECHAT_SA_KEY")).toBe(false); + }); }); diff --git a/packages/daemon/src/wiring/setup-channels/setup-channels-registry-builder.ts b/packages/daemon/src/wiring/setup-channels/setup-channels-registry-builder.ts index fcea03dd9..a4be82780 100644 --- a/packages/daemon/src/wiring/setup-channels/setup-channels-registry-builder.ts +++ b/packages/daemon/src/wiring/setup-channels/setup-channels-registry-builder.ts @@ -38,6 +38,7 @@ export function buildChannelCredentialMap(channels: unknown): Map ({ 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() })), @@ -1300,4 +1301,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); + }); + }); }); 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..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 } = await bootstrapAdapters({ + const { adaptersByType, tgPlugin, linePlugin, channelPlugins, msTeamsIngress, msTeamsPlugin, googlechatPlugin, googlechatIngress } = await bootstrapAdapters({ container, channelsLogger, msTeamsConversationStore: deps.msTeamsConversationStore, @@ -396,6 +402,7 @@ export async function setupChannels(deps: ChannelsDeps): Promise tgPlugin, linePlugin, msTeamsPlugin, + gcPlugin: googlechatPlugin, ssrfFetcher, linkRunner, transcriber, @@ -503,6 +510,7 @@ export async function setupChannels(deps: ChannelsDeps): Promise lifecycleReactors, channelPlugins, msTeamsIngress, + googlechatIngress, commandQueue, deliveryService, }; 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 // ----------------------------------------------------------------------- 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 // ------------------------------------------------------------------------- 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 }), + ); + }); }); 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(); diff --git a/packages/daemon/src/wiring/setup-memory.test.ts b/packages/daemon/src/wiring/setup-memory.test.ts index 2eefb339f..611b07ed6 100644 --- a/packages/daemon/src/wiring/setup-memory.test.ts +++ b/packages/daemon/src/wiring/setup-memory.test.ts @@ -301,7 +301,11 @@ describe("setupMemory", () => { // 1. Creates basic memory services without embedding // ------------------------------------------------------------------------- - it("creates memoryAdapter, sessionStore, memoryApi without embedding when disabled", async () => { + // 30s: getSetupMemory()'s first dynamic import pulls the cold setup-memory + // module graph (memory package + better-sqlite3) — over the 5s default under + // coverage instrumentation. An abort here leaks the still-running setupMemory + // call into the next test's buildProvenanceReadStore call count. + it("creates memoryAdapter, sessionStore, memoryApi without embedding when disabled", { timeout: 30_000 }, async () => { const container = createMinimalContainer({ embedding: { enabled: false }, }); @@ -333,7 +337,7 @@ describe("setupMemory", () => { // the prompt-assembly + setup-agents wiring guards (the last links). // ------------------------------------------------------------------------- - it("builds buildProvenanceReadStore on the shared db and returns it as provenanceStore", async () => { + it("builds buildProvenanceReadStore on the shared db and returns it as provenanceStore", { timeout: 30_000 }, async () => { const container = createMinimalContainer({ embedding: { enabled: false } }); const setupMemory = await getSetupMemory(); 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(); + }); +}); 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; +} 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/packages/orchestrator/src/cross-session/announcement-dead-letter.test.ts b/packages/orchestrator/src/cross-session/announcement-dead-letter.test.ts index 4824f50c1..d55f56331 100644 --- a/packages/orchestrator/src/cross-session/announcement-dead-letter.test.ts +++ b/packages/orchestrator/src/cross-session/announcement-dead-letter.test.ts @@ -535,6 +535,43 @@ describe("AnnouncementDeadLetterQueue", () => { ); expect(dlq.size()).toBe(0); }); + + it("accepts googlechat as a ChannelType and round-trips a dead-letter entry through enqueue and drain", async () => { + // Type-level: googlechat is a member of the closed ChannelType union. The + // type-check rejects both this assignment and the makeEntry call below + // until the union admits "googlechat". + const channelType: ChannelType = "googlechat"; + expect(channelType).toBe("googlechat"); + + const eventBus = createMockEventBus(); + const dlq = createAnnouncementDeadLetterQueue({ filePath, eventBus, retryIntervalMs: 0 }); + + dlq.enqueue( + makeEntry({ + runId: "run-googlechat-1", + channelType: "googlechat", + channelId: "spaces/AAAA1234", + }), + ); + // Wait for the fire-and-forget append so drain reloads it from disk. + await new Promise((r) => setTimeout(r, 50)); + + // Persisted with channelType "googlechat". + const content = await readFile(filePath, "utf-8"); + const parsed = JSON.parse(content.trim()) as DeadLetterEntry; + expect(parsed.channelType).toBe("googlechat"); + + // And it round-trips through drain to sendToChannel with the googlechat type. + const sendToChannel = vi.fn().mockResolvedValue(true); + await dlq.drain(sendToChannel); + expect(sendToChannel).toHaveBeenCalledWith( + "googlechat", + "spaces/AAAA1234", + "Task completed successfully", + undefined, + ); + expect(dlq.size()).toBe(0); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/orchestrator/src/cross-session/announcement-dead-letter.ts b/packages/orchestrator/src/cross-session/announcement-dead-letter.ts index 992272ca4..1ce54e5a2 100644 --- a/packages/orchestrator/src/cross-session/announcement-dead-letter.ts +++ b/packages/orchestrator/src/cross-session/announcement-dead-letter.ts @@ -34,7 +34,7 @@ export interface AnnouncementLogger { // --------------------------------------------------------------------------- /** - * Canonical 10-channel set covering production platform adapters. Used as the + * Canonical 11-channel set covering production platform adapters. Used as the * closed-union discriminator for sendToChannel(type, ...) instead of an open * `string`. Local definition (no @comis/core export currently aggregates the * platform-adapter channel types — the rest of the codebase carries this @@ -52,6 +52,7 @@ export type ChannelType = | "line" | "email" | "msteams" + | "googlechat" | "echo"; /** A single dead-letter queue entry representing a failed announcement. */ 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); }); 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/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-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-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); + }); +}); 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(), + }, + }; + }); +} 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"]); + }); +}); 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; 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); + }); +}); diff --git a/test/live/self-driving/scripts/README.md b/test/live/self-driving/scripts/README.md index ab1d157f0..3921fada3 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, targeting the **production installation** (the systemd + npm-global layout `website/public/install.sh` creates — the same thing users get). @@ -83,6 +83,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 the installed dist directly (`_rig.mjs` resolves either layout): `floor` (`validateExecCommand` destructive denylist), `ssrf` (`validateUrl` — ASYNC `Result{ok}`), `invisible` (`stripInvisible` zero-click). `gate-probe.mjs [floor\|ssrf\|invisible\|all]`, 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 · **systemd unit active** · 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 `