feat: Google Chat channel (11th channel) + merge main (1.0.47)#292
feat: Google Chat channel (11th channel) + merge main (1.0.47)#292anconina wants to merge 203 commits into
Conversation
- Per-status classification: undefined->network, 401/403->auth, 429->platform, >=500->platform, other 4xx->internal - Secret-safety: a secret-bearing cause never appears in the hint - RED: errors.ts does not exist yet
- Pure status->errorKind classifier: closed union auth|platform|network|precondition|internal + retryable + secret-free hint - Branch order: undefined->network, 401/403->auth, 429->platform, >=500->platform, else->internal - void cause: the originating error is never rendered into the hint - No throw for control flow; no @comis/infra import (logger injected elsewhere)
- per-case coverage: both DM encodings (spaceType + legacy type), space/group, message.space precedence, senderId fallbacks, argumentText-preferred, USER_MENTION -> wasMentioned, thread capture, non-MESSAGE null - pins the non-empty channelId sentinel (never "") and a parseMessage round-trip
- pure, transport-free Chat interaction event -> NormalizedMessage
- both DM encodings (spaceType DIRECT_MESSAGE and legacy type DM) -> chatType dm
- argumentText preferred over text; senderId from sender.name (users/{id})
- USER_MENTION annotation -> metadata.wasMentioned; thread name captured
- non-empty channelId sentinel keeps a space-name-less event schema-valid
- strict-object defaults (mode pubsub, allowMode allowlist, audienceType project-number, missedInboundThresholdMs 6h with 1-min floor) - serviceAccountKey accepts a plain string OR a SecretRef - rejects unknown keys incl. an ackReaction key - ChannelConfigSchema.googlechat self-defaulting wiring
- strict object mirroring MsTeamsChannelEntrySchema: pubsub-default mode, serviceAccountKey as SecretRefOrString, allowlist default-deny, 6h missed-inbound liveness window floored at 1 min; no ackReaction - wire googlechat into ChannelConfigSchema (self-defaulting) + export the inferred GoogleChatChannelEntry type - regenerate section-registry parity snapshots (config-metadata surface now carries the googlechat entry)
- Assert err names the MISSING field (serviceAccountKey, subscriptionName,
private_key, client_email), never the secret value
- Assert a missing-field error never echoes the passed key material
- Assert a parse failure carries no raw key text
- Assert email-shaped allowFrom emits an advisory WARN naming users/{id};
an immutable users/{id} or spaces/{id} entry does not
- Parse-only, secret-safe guard for the SA key JSON + Pub/Sub subscription
- Names the missing field (serviceAccountKey / subscriptionName / private_key
/ client_email), never the secret value; a parse failure carries no raw text
- JSON parse failure handled via try/catch returning a Result (no throw)
- Advisory email-shaped allowFrom lint: WARN with errorKind + hint steering to
an immutable users/{id}; exempts users/{id} and spaces/{id} entries
…ider - Assert getToken mints an RS256 SA-JWT assertion (iss=sub=client_email, aud=token endpoint, scope, exp-iat<=3600) and POSTs the jwt-bearer grant - Assert jwtVerify against the SA public key succeeds - Per-scope cache: reuse within skew, refresh past skew, chat.bot vs pubsub cache independently (two scopes => two mints) - Failure branches (401 status / transport reject) return err with a classified errorKind; the SA key, assertion, and minted token never reach a log field - Malformed PKCS#8 and unparseable/incomplete SA key JSON return precondition errors that name the requirement, never the key bytes
- createGoogleChatTokenProvider mints an RS256 service-account assertion via jose (importPKCS8 + SignJWT): iss=sub=client_email, aud=token endpoint, the requested scope, exp-iat capped at 1h, then exchanges it for an access token with the jwt-bearer grant at oauth2.googleapis.com/token - Per-scope expiry+skew cache (bounded 2-entry map): chat.bot and pubsub cache independently; reuse within skew, re-mint past it - Failure branches delegate to classifyGoogleChatError for errorKind+hint; the private key, assertion, and minted token never enter a log field - SA key parsed once; malformed PKCS#8 / unparseable / missing-field keys return precondition errors naming the requirement, never the key bytes - NaN/0 expiry guarded so it cannot poison the cache - Injected fetch / clock seams; jose is the crypto layer, no hand-rolled JWT; reuses the outbound token-provider cache/skew/logging scaffold Test refined during GREEN: jwtVerify uses currentDate matching the injected clock (the assertion is signed off the injected now, not wall-clock time).
…ject, dedup on message.name, standard base64 - pollOnce pull-request shape (Bearer, maxMessages, no returnImmediately, abort signal) - ack fires only after onEvent resolves; a rejecting onEvent skips the ack so Pub/Sub redelivers - redelivered duplicates dedup on the decoded event.message.name; mark-seen only on the ack path - standard base64 (not base64url) decode; unparseable payloads acked and skipped; bounded seen-set eviction - RED: ./pubsub-source.js does not exist yet
- pollOnce POSTs subscription:pull (pubsub Bearer, maxMessages, abort signal), standard base64 decode, JSON.parse - acks only after onEvent resolves; a rejecting onEvent skips the ack (WARN) so Pub/Sub redelivers - dedup on the decoded event.message.name; name marked seen only on the ack path; bounded oldest-first eviction - unparseable payloads acked and skipped; token/transport/status/body failures classified via the shared taxonomy - start()/stop() are thin lifecycle here; the self-rescheduling backoff loop follows
- bounded jittered exponential backoff (floor..cap) captured via an injected timer seam - backoff resets to the floor after a good pull - stop() aborts the in-flight long-poll (AbortController) and cancels a pending backoff timer - persistent failure logs a loud ERROR (errorKind + hint) and sets lastError; count resets after a good pull - RED: start() runs no self-rescheduling loop yet
…nt failure - start() runs a self-rescheduling loop; pull failures back off with jittered exponential delay (floor..cap), reset after a good pull - abortableSleep shares the AbortController signal with the pull fetch, so stop() aborts an in-flight long-poll and cancels a pending backoff timer - persistent failure (>= errorLogThreshold consecutive) logs a loud ERROR with errorKind + hint; the count resets after a good pull - sanctioned/injected systemSetTimeout/systemClearTimeout only — no raw timers or Date.now
…fecycle, status - default-deny allowlist gate drops non-allowlisted senders before fanout - open mode and space-id allow admit; non-MESSAGE resolves without a handler - rejecting/sync-throwing handler rejects (skip-ack) with sibling isolation - getStatus polling connectionMode + lastInboundAt inbound-only semantics - start boots the injected source wired to handleChatEvent; stop stops it - reconcileSend always unresolved; platformAction unsupported; INV omitted methods absent
…, status - default-deny allowlist gate drops non-allowlisted senders before fanout - handleChatEvent fans out under a fresh trace context; rethrows on handler failure as the pull loop's skip-ack signal, with per-handler isolation - start validates creds then opens the pull loop wired to handleChatEvent; stop stops it - getStatus reports polling connectionMode with inbound-only lastInboundAt and source lastError - reconcileSend always unresolved; platformAction reports unsupported actions - edit/delete/reaction/attachment methods omitted (capability honesty)
… bearer
- happy path POSTs {text} to the space messages endpoint with a Bearer header
- non-ok status returns err, logs errorKind+hint, and never logs the token
- transport reject classifies network
- an outbound send bumps lastMessageAt but not lastInboundAt
…ot bearer
- mint a chat.bot-scope token, POST {text} to the space messages endpoint
- return the created message resource name on success
- classify a non-ok status / transport reject with a secret-free errorKind+hint; never log the token
- bump lastMessageAt on a successful send (never lastInboundAt)
… CAPABILITIES - createGoogleChatPlugin identity (channel-googlechat, channelType googlechat) + adapter wrap - exact text-only feature matrix deep-equal (all false, buttons none) - maxMessageChars 4000 + replyToMetaKey googlechatMessageName - capability parity: every false/none flag omits its adapter method - activate/deactivate delegate to adapter.start/stop; register returns ok
…APABILITIES - createGoogleChatPlugin(deps) -> ChannelPluginPort (channelType googlechat) - interim matrix: reactions/editMessages/deleteMessages/fetchHistory/attachments/typing/threads false, buttons none - limits.maxMessageChars 4000; replyToMetaKey googlechatMessageName - every false/none flag has its adapter method omitted (capability gate blocks it) - activate/deactivate delegate to adapter.start/stop; register returns ok(undefined)
… 12) - add googlechat row (typing:false, threads:false, buttons:none) to EXPECTED - bump length sanity assert 11 -> 12 and the walk title - reads googlechat-plugin.ts source; confirms the three flags are own-properties
- adapter (createGoogleChatAdapter + Deps/Handle types) + plugin (createGoogleChatPlugin) - utilities: mapGoogleChatEventToNormalized/GoogleChatEvent, token provider + CHAT_SCOPE/PUBSUB_SCOPE + auth types - validateGoogleChatCredentials + classifyGoogleChatError - surfaces the plugin + validator the daemon wiring consumes
…ecretRef/GOOGLECHAT_SA_KEY resolution - happy path: config serviceAccountKey + subscriptionName registers the adapter/plugin - env fallback: GOOGLECHAT_SA_KEY resolves the key when config omits serviceAccountKey - missing subscription: no registration, secret-free WARN (errorKind + hint, no key)
- bootstrapAdapters registers the googlechat adapter/plugin on enabled+valid config - SA key resolves as config SecretRef (resolved upstream) or GOOGLECHAT_SA_KEY env fallback - missing key/subscription: no registration, secret-free WARN (errorKind + hint) - no gateway ingress (pull loop, not webhook); module doc note 10 -> 11 platforms - baseline the 12 intra-package-only googlechat exports in public-api-policy (createGoogleChatPlugin + validateGoogleChatCredentials now have the daemon consumer)
…SA_KEY - config-yaml.mdx: Google Chat (channels.googlechat) key table + shared YAML example entry - environment-variables.mdx: GOOGLECHAT_SA_KEY as the service-account-key fallback
…ull/non-object decodes A decoded Pub/Sub payload of the literal JSON null (base64 of "null" JSON.parses to null, typeof null === "object", null.type throws) reached the mapper un-guarded and crashed with a TypeError. The pull loop's onEvent try/catch — meant only for enqueue backpressure — swallowed it as a rejected enqueue and skip-acked, so Pub/Sub redelivered the poison message forever, wedging a maxMessages slot every cycle. Guard the contract boundary: null/non-object events return null (ACK-dropped) before any deref. Tests pin the map-then-drop contract at all three layers — mapper, adapter handleChatEvent, and the pull loop's ack-not-skip disposition.
The plugin advertised replyToMetaKey "googlechatMessageName" but the mapper never wrote it, so inbound-message-id-resolver read undefined and the native id was never recorded — the bot's replies silently never quoted the triggering message. Two layers disagreed while message.name was already in hand. Write metadata.googlechatMessageName = message.name, mirroring how msteams and discord populate the key they advertise. Tests reconcile all three layers: mapper output, advertised key, and resolver consumption.
…vent The rethrow on handler failure is correct behavior — a rejected onEvent promise is the pull loop's skip-ack (redeliver) signal. But packages/channels/ is not a raw-throw exempt zone, and this throw only slipped past the guard via its ternary shape (throw failed.reason ? ...), which the line-anchored regex does not match. Relying on that shape to evade the guard is convenient-layer evasion. Add the // @allow-throw: <reason> annotation documenting the contract, so the sanctioned boundary throw is explicit rather than accidentally-unmatched.
…ot a silent downgrade mode:"webhook" was accepted and silently ran the Pub/Sub pull loop with only a vague 'not available yet' WARN (errorKind precondition). The webhook transport is not wired, so this was both non-functional and misleading. Emit a config-classed WARN that names the knob (channels.googlechat.mode) and states plainly that webhook ingress is inactive and the Pub/Sub pull loop is in use. The schema keeps mode/audienceType/audience — intentionally schema-complete for the later webhook wiring — but the runtime no longer downgrades silently.
abortableSleep registered an { once: true } abort listener on the shared
controller.signal each cycle; { once: true } only self-removes if abort fires,
so on the normal timer path the listener was never removed. A persistently
failing loop (30s cap) accumulated hundreds of listeners toward Node's
MaxListenersExceededWarning.
Remove the listener when the timer completes normally. A getEventListeners
assertion pins that only the currently-parked sleep's single listener remains
after repeated failure cycles.
…text .env round-trips
A Google Chat service-account key is a JSON object whose real-world form is
pretty-printed across many lines. In file storage mode the wizard wrote it to
.env verbatim, but the .env writer is one-line-per-key and the daemon's .env
reader is line-based, so the value was truncated to "{" at boot -- JSON.parse
then threw and the channel never registered, while the wizard's post-write
guard falsely reported success.
Compact the key to single-line JSON in collectManagedSecrets, the single point
both the .env writer and the encrypted store draw from, so it round-trips
losslessly through either. A non-JSON value is left raw so the daemon reports
an honest parse error rather than a silently corrupted key. Strengthen the
mocked write-config test to a multi-line blob asserting a single compacted
.env line.
The --no-auth / --bad-token webhook probes send a forged Bearer, but the only exit logic was the generic "2xx ack -> 0, else 1", so a correct opaque 401 rejection exited 1 (looked like a failure) and an insecure 2xx accept exited 0 (reported success). The pass/fail was backwards for a security probe: it flagged the secure outcome as failure and the auth-gate bypass as success, so it could never catch the regression it exists for. Branch the webhook SEC probes before the generic check and pass only on status === 401.
…on-interactive The flag help advertises "or a path to the key file", and the interactive step resolves a path to its contents. This asserts the non-interactive builder does the same: given a path, state carries the parseable KEY, not the literal path string (which would JSON.parse-fail at daemon boot). Adds the inline-JSON counterpart (a non-path value passes through untouched). Fails today -- the builder stores the flag verbatim.
…e builder The flag help advertises "or a path to the key file", and the interactive step resolves a path to its contents, but the non-interactive builder stored the flag verbatim -- a path followed the help straight into a boot-time JSON.parse failure. Extract the interactive path/JSON resolver into a shared helper and use it in both paths so they cannot drift, giving the flag one honest contract. write-config's single-line compaction then handles the multi-line contents a read file carries.
The doc claimed a multiselect of "all 8 supported channels" that "silently adds IRC". The supported set has grown and IRC is a normal selectable multiselect option, so state the behavior without the stale count or the "silently adds IRC" clause.
…ve wizard In webhook mode the inbound Bearer-JWT verifier selects a different key set and claim shape per audience type (a Chat-system self-signed token for a project-number audience, a Google OIDC token bound to the endpoint URL for app-url). The channel step only prompted for the audience value, so a wizard-authored webhook config always kept the schema default audienceType: project-number — and an endpoint-URL audience then silently rejected every inbound request. Prompt for the audience type before the audience value, label the audience prompt per the chosen type, and thread audienceType onto the collected config (write-config already persists it).
…arity Mirror the interactive audience-type prompt in non-interactive mode: register --googlechat-audience-type (project-number|app-url), validate it against the closed vocabulary, and thread it onto the webhook channel config so a CI-authored webhook app whose audience is an endpoint URL records audienceType: app-url instead of keeping the schema default. pubsub omits it (the schema default is correct there).
The write-config step persists channels.googlechat.audienceType when present, but no test exercised that branch — the webhook fixture carried no audienceType. Now that the wizard captures it, drive an app-url webhook config through the step and assert audienceType reaches the emitted (schema-valid) config block.
…audienceType The inbound verifier binds to a different key set + claim shape per audienceType (a Chat-system self-signed token for a numeric project-number audience, a Google OIDC token for an endpoint-URL app-url audience). A config whose audience shape contradicts audienceType selects the wrong path and silently rejects every inbound request, and nothing surfaced it. Add a content-free advisory lint (webhook mode only, errorKind config) that WARNs when audienceType is app-url but audience is not URL-shaped, or audienceType is project-number but audience looks like an endpoint URL. The hint names both config keys and the fix; validation still passes (advisory only).
…ial validator The credential validator's audience-shape lint needs audienceType alongside the audience to fire, but the daemon — its only production caller — passed the audience without it, so the mismatch stayed silent at boot. Thread channelConfig.googlechat.audienceType (schema-defaulted, always concrete) into the validate call so a webhook config whose audience shape contradicts audienceType surfaces the WARN at startup and via the fleet lens.
…is doctor The Google Chat doctor check inlines its probes rather than calling the channel credential validator, so the new audience-shape lint does not reach it for free. Add a fifth, webhook-only probe that warns when the audience shape contradicts audienceType (a URL audience declared project-number, or a non-URL audience declared app-url) — the mismatch that silently rejects every inbound request. Omitted in pubsub mode; content-free (names the two config keys and the fix).
…chat-channel # Conflicts: # test/live/self-driving/scripts/README.md
PR description incompletePlease fill in all required sections before this PR can be reviewed. Required sections: Description, Related Issue, Type of Change, Checklist, RED Test Proof. For code changes in See CONTRIBUTING.md for the full contribution bar. |
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
| */ | ||
| function isEmailShaped(entry: string): boolean { | ||
| if (entry.startsWith("users/") || entry.startsWith("spaces/")) return false; | ||
| return /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(entry); |
Code reviewFound 1 issue:
New field added by this PR: comis/packages/core/src/config/schema-channel.ts Lines 233 to 237 in 7df266c Field-name pattern that misses it: comis/packages/core/src/security/secret-detection.ts Lines 213 to 218 in 7df266c Provider patterns missing a comis/packages/core/src/security/secrets-audit.ts Lines 51 to 69 in 7df266c 🤖 Generated with Claude Code - If this code review was useful, please react with 👍. Otherwise, react with 👎. |
…ntKey and GOOGLECHAT_SA_KEY RED on the pre-patch code: serviceAccountKey matches no generic secret suffix in SECRET_FIELD_PATTERN, and GOOGLECHAT_SA_KEY ends in _KEY so no KNOWN_PROVIDER_PATTERNS entry or generic env suffix catches it — a raw service-account JSON (carrying a private key) in config.yaml or .env produced zero audit findings while every sibling channel secret flags.
…tion keystone
Add serviceAccountKey to SECRET_FIELD_PATTERN and a GOOGLECHAT_SA_KEY ->
googlechat entry to KNOWN_PROVIDER_PATTERNS.
Threat note: a pasted GCP service-account JSON holds a private key; the
audit's field-name-only walk left it invisible to comis doctor / comis
secrets audit. Fixing the keystone also covers redactForDisplay (config
display), the persist-to-config write firewall, and last-known-good.
Sanctioned refs stay exempt: the wizard writes ${GOOGLECHAT_SA_KEY},
which the full-string env-ref exemption skips.
Verified against ground truth: comis secrets audit on a scratch config
returned [] pre-patch and both findings post-patch.
…tial map RED on the pre-patch code: buildChannelCredentialMap has an entry for every other credentialed channel but not GOOGLECHAT_SA_KEY -> googlechat, so rotating the service-account key never restarts the adapter and it keeps minting JWTs from the stale key until a manual daemon restart.
The channel-manager's secret:changed subscription looks the rotated env-var name up in this map to stop/restart the owning adapter; the googlechat adapter reads the key once at setup, so without the entry a rotation is silently ignored. Verified against the built dist: the map now returns googlechat when enabled and stays absent when disabled.
…on advisory for Google Chat RED on the pre-patch code: the boot-time credential validator WARNs that groupActivation "always" is inert on Google Chat (the platform only delivers mentioned/slash-command space messages), but the doctor check inlines its probes rather than calling the validator, so the advisory never reached the operator's first troubleshooting command — doctor reported the channel healthy while the daemon log held the only warning.
…or check Probe 6 warns when autoReplyEngine.groupActivation is "always", naming the exact knob and why it cannot broaden delivery on this platform; omitted for other modes. Corrects the module doc's stale probe count (said four, enumerated five, now six) and extends the runbook's doctor bullet, which had also drifted behind the audience-shape probe. Verified against ground truth: comis doctor on a scratch config with groupActivation always showed no finding pre-patch and the warn with suggestion post-patch; mention-gated stays clean.
The closed union gates sendToChannel's channel discriminator; googlechat was missing, so typed callers could not dead-letter or drain a Google Chat announcement. Adds the type-level + round-trip test mirroring the msteams membership test. RED and GREEN share this commit because the test does not compile against the pre-patch union: tsc rejects both googlechat sites with TS2322 before the patch and is clean after.
… policy list The entry claimed the handle type has no cross-package importer, but the daemon composition root (setup-channels-adapters.ts) imports and uses it — the same consumer relationship the sibling MsTeams block records by omitting MsTeamsAdapterHandle. Listing a consumed export also masks the policy's job: if the daemon consumer ever disappeared, the stale entry would keep the orphaned export invisible. public-export-consumers stays green on the shrink, proving the real consumer is found.
…nels table The non-interactive init flags for both channels were absent from the reference table that documents the analogous flags for Telegram, Discord, Slack, and LINE — a user scripting a headless setup from the CLI reference could not discover them. Every row is cross-checked against comis init --help and the schema defaults (authMode secret, mode pubsub, audienceType project-number).
…chat-channel # Conflicts: # packages/cli/src/commands/init.test.ts
…ressed runs cannot abort mid-flight and leak spy calls into siblings
Summary
Adds Google Chat as the 11th chat channel, end-to-end, and merges the latest
main(release 1.0.47 + autonomy/observability work) into the branch.Google Chat channel
packages/channels/src/googlechat/): hybrid transport — defaults to a pull model (OAuth-token mint + Pub/Sub pull + Chat REST) with an opt-in signed webhook. Includes the error taxonomy, event→normalized mapper, credential validator, SA-JWT per-scope token provider, bounded/jittered/abortable Pub/Sub pull loop, rich Cards v2 renderer, and send pacer.packages/core):channels.googlechat.*incl.mode(pubsub/webhook),audience/audienceType,allowMode/allowFrom.packages/cli): interactive prompt + write-path, non-interactive parity (--googlechat-*flags incl.--googlechat-audience-typeand SA-key path resolution), and a self-containedgooglechatcomis doctorcheck.packages/daemon): mode-aware credential validation, contained webhook dispatch,groupActivationthreading, boot-time audienceType cross-check.test/live): Google Chat wire emulator (token + Pub/Sub + Chat REST + JWKS),googlechat-drive.mjshybrid driver, offline scenario against the real adapter, deterministic ChannelPort double.Merge of
mainBrings in release 1.0.47, MCP-connect diagnosability + credential redaction, orchestrate auto-repair + durable-resume, and autonomy budget default changes. One conflict (
test/live/self-driving/scripts/README.md) was resolved to keep both sides — the newgooglechat-drive.mjs/emulator notes and main's systemd/phase0-check.sh/GWTOKENupdates.Test plan
pnpm build— clean.section-registry-parity+schema-agent-autonomy— 130 passed (snapshot carries both googlechat entries and main's autonomy budget defaults).pnpm validaterun: the only failures were 6 load-induced 5000ms timeouts inpackages/channels/src/email/imap-lifecycle*.test.ts— email code is untouched by this branch and those tests pass in isolation (19/19). Flaky under the full parallel coverage run, not a regression. CI will re-run the gates.🤖 Generated with Claude Code