diff --git a/charts/openab/templates/gateway.yaml b/charts/openab/templates/gateway.yaml index 2a89dc79a..3482d4cc5 100644 --- a/charts/openab/templates/gateway.yaml +++ b/charts/openab/templates/gateway.yaml @@ -157,10 +157,16 @@ spec: value: "false" {{- end }} {{- end }} - {{- $hasGoogleChat := or (($cfg.gateway).googleChat).saKeyJson (($cfg.gateway).googleChat).accessToken (($cfg.gateway).googleChat).audience }} + {{- $hasGoogleChat := or (($cfg.gateway).googleChat).saKeyJson (($cfg.gateway).googleChat).accessToken (($cfg.gateway).googleChat).audience (($cfg.gateway).googleChat).useAdc }} {{- if $hasGoogleChat }} - name: GOOGLE_CHAT_ENABLED value: "true" + {{- if (($cfg.gateway).googleChat).useAdc }} + - name: GOOGLE_CHAT_USE_ADC + value: "true" + - name: GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT + value: {{ required "gateway.googleChat.adcTargetServiceAccount is required when useAdc=true" (($cfg.gateway).googleChat).adcTargetServiceAccount | quote }} + {{- end }} {{- if (($cfg.gateway).googleChat).audience }} - name: GOOGLE_CHAT_AUDIENCE value: {{ ($cfg.gateway).googleChat.audience | quote }} diff --git a/charts/openab/values.yaml b/charts/openab/values.yaml index fd37b023c..78c3e695f 100644 --- a/charts/openab/values.yaml +++ b/charts/openab/values.yaml @@ -480,6 +480,8 @@ agents: audience: "" # JWT audience → GOOGLE_CHAT_AUDIENCE (set to your webhook URL to enable JWT verification) saKeyJson: "" # Service account key JSON string → GOOGLE_CHAT_SA_KEY_JSON (recommended, auto-refresh) accessToken: "" # Static OAuth2 access token → GOOGLE_CHAT_ACCESS_TOKEN (fallback, 1-hour TTL) + useAdc: false # Keyless ADC → GOOGLE_CHAT_USE_ADC. Runtime SA impersonates the distinct adcTargetServiceAccount; no SA key file. Needs roles/iam.serviceAccountTokenCreator on the target + iamcredentials.googleapis.com. Self-impersonation is prohibited. + adcTargetServiceAccount: "" # Dedicated Chat-app SA email → GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT. Required when useAdc=true; MUST differ from the pod/runtime SA. webhookPath: "" # Gateway default: /webhook/googlechat → GOOGLE_CHAT_WEBHOOK_PATH # WeCom (企业微信) adapter config (gateway-side env vars) # See docs/wecom.md for full setup guide diff --git a/config.toml.example b/config.toml.example index 00add1a9b..e12ce4b9b 100644 --- a/config.toml.example +++ b/config.toml.example @@ -123,6 +123,8 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # sa_key_json = "${GOOGLE_CHAT_SA_KEY_JSON}" # inline SA key; wins over sa_key_file # sa_key_file = "/etc/openab/sa.json" # env fallback: GOOGLE_CHAT_SA_KEY_FILE # access_token = "${GOOGLE_CHAT_ACCESS_TOKEN}" # static token alternative +# use_adc = true # keyless ADC via GCE metadata + IAM Credentials; runtime SA impersonates a DISTINCT target (self-impersonation is prohibited). env: GOOGLE_CHAT_USE_ADC +# adc_target_service_account = "chat-bot@project.iam.gserviceaccount.com" # required with use_adc; env: GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT # audience = "projects//..." # enables webhook JWT verification (L1) # webhook_path = "/webhook/googlechat" # env fallback: GOOGLE_CHAT_WEBHOOK_PATH # allow_all_users = false # env fallback: GOOGLE_CHAT_ALLOW_ALL_USERS diff --git a/crates/openab-core/src/adapter.rs b/crates/openab-core/src/adapter.rs index fa7e95dba..2bf3a7c6d 100644 --- a/crates/openab-core/src/adapter.rs +++ b/crates/openab-core/src/adapter.rs @@ -35,6 +35,25 @@ fn reply_message_limit(platform: &str, adapter_limit: usize) -> usize { } } +/// Whether to use cosmetic streaming (placeholder + in-place edits) for +/// `platform`, given the adapter's own preference. Forces send-once for: +/// - `acp`: streams append-only `agent_message_chunk` deltas, not edits. +/// - platforms in `NON_STREAMING_PLATFORMS` — no message-edit API, or an edit +/// API that cannot be driven per-token (e.g. `googlechat`) — see +/// `NON_STREAMING_PLATFORMS` for the per-platform rationale and +/// `platform_supports_streaming`. +/// +/// This is the embedded/unified dispatch gate; the WebSocket +/// `run_gateway_adapter` path applies the same `platform_supports_streaming` +/// check but has no `acp` case (ACP is embedded-only). The two gates are +/// siblings: adding a platform to `NON_STREAMING_PLATFORMS` covers both +/// paths, while an embedded-only carve-out belongs here. +fn resolve_streaming(platform: &str, adapter_prefers_streaming: bool) -> bool { + platform != "acp" + && crate::gateway::platform_supports_streaming(platform) + && adapter_prefers_streaming +} + /// Parse `[[key:value]]` directives from the beginning of agent output. /// Returns parsed directives and the remaining content (directives stripped). pub fn parse_output_directives(content: &str) -> (OutputDirectives, String) { @@ -701,15 +720,14 @@ impl AdapterRouter { let adapter = adapter.clone(); let thread_channel = thread_channel.clone(); let message_limit = reply_message_limit(&thread_channel.platform, adapter.message_limit()); - // ACP must not inherit the unified adapter's Telegram streaming flag (wrong - // coupling): it streams append-only `agent_message_chunk` deltas built from the - // post+edit (`edit_message` snapshot) path, i.e. streaming=false. Decide it - // explicitly by platform rather than by whatever Telegram happens to be set to. - let streaming = if thread_channel.platform == "acp" { - false - } else { - adapter.use_streaming(other_bot_present) - }; + // Decide streaming explicitly by platform, not by whatever the unified + // adapter's Telegram flag happens to be. ACP streams append-only deltas + // (not cosmetic edits); Google Chat / LINE can't sustain in-place edits. + // See `resolve_streaming`. + let streaming = resolve_streaming( + &thread_channel.platform, + adapter.use_streaming(other_bot_present), + ); // Keep the full turn text (incl. inter-tool narration) when streaming // (it was already shown live) OR when `[reactions] narration_display` is // set. Otherwise a send-once turn delivers only the final answer block. @@ -1746,6 +1764,20 @@ mod tests { assert_eq!(crate::format::split_message(&long, reply_message_limit("acp", 4096)).len(), 1); } + #[test] + fn resolve_streaming_forces_send_once_for_acp_and_googlechat() { + // Editable platforms honor the adapter's own streaming preference. + assert!(resolve_streaming("discord", true)); + assert!(!resolve_streaming("discord", false)); + assert!(resolve_streaming("telegram", true)); + // ACP streams append-only deltas, not cosmetic edits → always send-once. + assert!(!resolve_streaming("acp", true)); + // Google Chat: synthetic id can't be patched (400 INVALID_ARGUMENT) → send-once regardless of pref. + assert!(!resolve_streaming("googlechat", true)); + // LINE: no edit API → send-once. + assert!(!resolve_streaming("line", true)); + } + #[test] fn select_delivery_text_send_once_keeps_only_final_block() { // Simulates: narration "n1" → tool (answer_start→2) → narration "n2" diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index a9bc26abd..0894e7ef1 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -1225,6 +1225,16 @@ pub struct GoogleChatConfig { /// checked when `allow_all_users` resolves to `false`. Env fallback: /// `GOOGLE_CHAT_ALLOWED_USERS` (comma-separated). pub allowed_users: Option>, + /// Use keyless ADC (GCE metadata server + IAM Credentials + /// `generateAccessToken`) to mint the `chat.bot` token for a distinct target + /// service account. Env fallback: `GOOGLE_CHAT_USE_ADC` (`true`/`1`; default + /// false). Ignored when a configured SA key loads successfully. + pub use_adc: Option, + /// Dedicated Google Chat service account impersonated by the attached + /// runtime service account. Required when `use_adc=true`; MUST differ from + /// the runtime identity because Google prohibits access-token + /// self-impersonation. Env: `GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT`. + pub adc_target_service_account: Option, } /// Fully resolved Google Chat settings (config → env → default applied). @@ -1234,6 +1244,8 @@ pub struct ResolvedGoogleChat { pub sa_key_json: Option, pub sa_key_file: Option, pub access_token: Option, + pub use_adc: bool, + pub adc_target_service_account: Option, pub audience: Option, pub webhook_path: String, pub allow_all_users: bool, @@ -1259,6 +1271,15 @@ impl GoogleChatConfig { sa_key_json: opt_str(&self.sa_key_json, "GOOGLE_CHAT_SA_KEY_JSON"), sa_key_file: opt_str(&self.sa_key_file, "GOOGLE_CHAT_SA_KEY_FILE"), access_token: opt_str(&self.access_token, "GOOGLE_CHAT_ACCESS_TOKEN"), + use_adc: self.use_adc.unwrap_or_else(|| { + std::env::var("GOOGLE_CHAT_USE_ADC") + .map(|v| v == "true" || v == "1") + .unwrap_or(false) + }), + adc_target_service_account: opt_str( + &self.adc_target_service_account, + "GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT", + ), audience: opt_str(&self.audience, "GOOGLE_CHAT_AUDIENCE"), webhook_path: opt_str(&self.webhook_path, "GOOGLE_CHAT_WEBHOOK_PATH") .unwrap_or_else(|| "/webhook/googlechat".into()), @@ -2966,6 +2987,8 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] "GOOGLE_CHAT_SA_KEY_JSON", "GOOGLE_CHAT_SA_KEY_FILE", "GOOGLE_CHAT_ACCESS_TOKEN", + "GOOGLE_CHAT_USE_ADC", + "GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT", "GOOGLE_CHAT_AUDIENCE", "GOOGLE_CHAT_WEBHOOK_PATH", ] { @@ -2974,9 +2997,25 @@ allowed_users = ["U1234567890abcdef0123456789abcdef"] // --- defaults --- let r = GoogleChatConfig::default().resolve(); assert!(!r.enabled); + assert!(!r.use_adc); assert!(r.audience.is_none()); assert_eq!(r.webhook_path, "/webhook/googlechat"); + // --- use_adc: config value resolves without touching env --- + let r = GoogleChatConfig { + use_adc: Some(true), + adc_target_service_account: Some( + "chat-bot@project.iam.gserviceaccount.com".into(), + ), + ..Default::default() + } + .resolve(); + assert!(r.use_adc); + assert_eq!( + r.adc_target_service_account.as_deref(), + Some("chat-bot@project.iam.gserviceaccount.com") + ); + // --- config wins over env --- std::env::set_var("GOOGLE_CHAT_ENABLED", "true"); std::env::set_var("GOOGLE_CHAT_AUDIENCE", "env-aud"); diff --git a/crates/openab-core/src/gateway.rs b/crates/openab-core/src/gateway.rs index a3b74adbd..bd704c5e0 100644 --- a/crates/openab-core/src/gateway.rs +++ b/crates/openab-core/src/gateway.rs @@ -10,8 +10,12 @@ use tokio::sync::Mutex; use tokio_tungstenite::tungstenite::Message; use tracing::{error, info, warn}; -/// Timeout for waiting on gateway reply acknowledgement. +/// Legacy timeout for streaming platforms that may not acknowledge normal sends. const GATEWAY_REPLY_TIMEOUT_SECS: u64 = 5; +/// Acknowledged send-once replies can include bounded auth refresh (up to three +/// 10-second requests), so allow that work to finish; unlike the legacy path, +/// timeout is an error because the adapter promised a delivery response. +const ACKED_GATEWAY_REPLY_TIMEOUT_SECS: u64 = 35; /// Platforms whose gateway adapter emits a `GatewayResponse` for `edit_message` /// so core can observe edit success or failure (used to gate the per-edit @@ -40,6 +44,22 @@ fn platform_acks_writes(platform: &str) -> bool { EDIT_RESPONSE_PLATFORMS.contains(&platform) } + +/// Platforms whose gateway adapters acknowledge normal send replies with a +/// `GatewayResponse`. This capability is independent of cosmetic streaming: +/// Google Chat is send-once, but its adapter reports API/auth failures and core +/// must retain the request id to observe them. +const REPLY_RESPONSE_PLATFORMS: &[&str] = &["googlechat"]; + +fn platform_acks_replies(platform: &str) -> bool { + REPLY_RESPONSE_PLATFORMS.contains(&platform) +} + +/// Preserve legacy request/response waits for streaming adapters while also +/// supporting send-once adapters that explicitly acknowledge delivery. +fn reply_requires_ack(platform: &str, streaming: bool) -> bool { + streaming || platform_acks_replies(platform) +} /// Gateway platforms whose messaging API cannot edit a message after it is sent. /// /// Cosmetic (typewriter) streaming works by posting a placeholder and then @@ -57,12 +77,29 @@ fn platform_acks_writes(platform: &str) -> bool { /// for a *capability*. The right long-term model is a capability handshake at /// gateway-connect time ("can this adapter edit messages?"); until that exists, /// any new gateway platform that lacks a message-edit API MUST be added here. -const NON_EDITABLE_PLATFORMS: &[&str] = &["line", "lineworks"]; +/// Platforms where cosmetic (typewriter) streaming — a placeholder message +/// then rapid in-place edits — is not viable, so replies are forced send-once: +/// - `line` / `lineworks`: no message-edit API at all. +/// - `googlechat`: has an edit API, but the unified adapter's synthetic +/// `unified_` message id is not a valid resource name, so `patch` +/// rejects it with 400 INVALID_ARGUMENT before any edit applies; the +/// documented 1 write/sec-per-space quota (create + patch + delete +/// combined) further constrains high-frequency editing. +/// See . +const NON_STREAMING_PLATFORMS: &[&str] = &["line", "lineworks", "googlechat"]; /// Whether cosmetic streaming (placeholder + in-place edits) is possible on -/// `platform`. See `NON_EDITABLE_PLATFORMS`. -fn platform_supports_streaming(platform: &str) -> bool { - !NON_EDITABLE_PLATFORMS.contains(&platform) +/// `platform`. See `NON_STREAMING_PLATFORMS`. `pub(crate)` so the shared +/// dispatch path (`AdapterRouter::stream_prompt_blocks`) can force send-once on +/// these platforms too, not just the WebSocket `run_gateway_adapter` path. +/// +/// Sibling gate: the embedded/unified dispatch path wraps this in +/// `adapter::resolve_streaming`, which additionally forces send-once for +/// `acp` (embedded-only, streams append-only deltas). An embedded-only +/// non-streaming platform must be handled there — adding it to the shared +/// list above covers both paths, but a platform-specific carve-out does not. +pub(crate) fn platform_supports_streaming(platform: &str) -> bool { + !NON_STREAMING_PLATFORMS.contains(&platform) } /// Shared filter parameters for gateway event gating. @@ -213,6 +250,18 @@ struct GatewayResponse { error: Option, } +fn gateway_delivery_result(resp: GatewayResponse) -> Result { + if resp.success { + Ok(resp.message_id.unwrap_or_else(|| "gw_sent".into())) + } else { + Err(anyhow::anyhow!( + "gateway reported failure: {}", + resp.error + .unwrap_or_else(|| "gateway reported failure".to_string()) + )) + } +} + // --- GatewayAdapter: ChatAdapter over WebSocket --- type PendingRequests = Arc>>>; @@ -262,7 +311,7 @@ impl GatewayAdapter { content: &str, quote_message_id: Option<&str>, ) -> Result { - let req_id = if self.streaming { + let req_id = if reply_requires_ack(self.platform_name, self.streaming) { Some(format!("req_{}", uuid::Uuid::new_v4())) } else { None @@ -298,33 +347,42 @@ impl GatewayAdapter { return Err(e.into()); } let msg_id = if let (Some(rx), Some(ref id)) = (pending_rx, &req_id) { - match tokio::time::timeout(std::time::Duration::from_secs(GATEWAY_REPLY_TIMEOUT_SECS), rx).await { - Ok(Ok(resp)) if resp.success => resp.message_id.unwrap_or_else(|| "gw_sent".into()), - Ok(Ok(resp)) => { - // Gateway explicitly reported failure (success=false). Surface - // as Err so dispatch sets ❌ instead of 🆗 over an incomplete - // delivery. Examples: Feishu edit cap reached after append-new - // fallback also failed; chunked send delivered N/M chunks. - let err_msg = resp.error.clone() - .unwrap_or_else(|| "gateway reported failure".to_string()); - tracing::warn!(request_id = %id, error = %err_msg, "gateway replied with failure"); - return Err(anyhow::anyhow!("gateway reported failure: {err_msg}")); - } + let ack_required = platform_acks_replies(self.platform_name); + let timeout_secs = if ack_required { + ACKED_GATEWAY_REPLY_TIMEOUT_SECS + } else { + GATEWAY_REPLY_TIMEOUT_SECS + }; + match tokio::time::timeout(std::time::Duration::from_secs(timeout_secs), rx).await { + Ok(Ok(resp)) => match gateway_delivery_result(resp) { + Ok(message_id) => message_id, + Err(e) => { + tracing::warn!(request_id = %id, error = %e, "gateway replied with failure"); + return Err(e); + } + }, Ok(Err(_)) => { - // Channel closed (gateway shutting down or pending dropped). - // Maintain legacy behavior — adapters that don't implement - // GatewayResponse for all reply types (LINE, Teams) rely on - // this for non-failure outcomes. + if ack_required { + return Err(anyhow::anyhow!( + "gateway acknowledgement channel closed for {}", + self.platform_name + )); + } + // Legacy streaming adapters may not acknowledge normal sends. tracing::warn!(request_id = %id, "gateway response channel closed"); "gw_sent".into() } Err(_) => { - // Timeout. Many adapters (LINE, Teams) intentionally do not - // emit GatewayResponse for replies, so timeout is the expected - // path for them. Maintain legacy behavior to avoid breaking - // platforms that have not yet wired request/response feedback. - tracing::warn!(request_id = %id, "gateway reply timed out"); self.pending.lock().await.remove(id); + if ack_required { + return Err(anyhow::anyhow!( + "gateway delivery acknowledgement timed out after {timeout_secs}s for {}", + self.platform_name + )); + } + // Preserve legacy behavior for adapters that do not promise + // a GatewayResponse for normal sends. + tracing::warn!(request_id = %id, "gateway reply timed out"); "gw_sent".into() } } @@ -1669,17 +1727,41 @@ mod tests { assert!(!platform_supports_streaming("line")); } + #[test] + fn googlechat_rate_limit_forces_send_once() { + // Google Chat has an edit API, but the unified adapter's synthetic + // message id is not a valid resource name, so patch returns 400 + // INVALID_ARGUMENT; the documented 1 write/sec-per-space quota further + // constrains high-frequency editing. Force send-once. + assert!(!platform_supports_streaming("googlechat")); + } + + #[test] + fn googlechat_send_once_still_requires_delivery_ack() { + assert!(platform_acks_replies("googlechat")); + assert!(reply_requires_ack("googlechat", false)); + assert!(!reply_requires_ack("line", false)); + // Preserve legacy behavior: streaming adapters still carry request IDs. + assert!(reply_requires_ack("discord", true)); + } + + #[test] + fn acknowledged_reply_failure_is_propagated() { + let err = gateway_delivery_result(GatewayResponse { + schema: "openab.gateway.response.v1".into(), + request_id: "req_test".into(), + success: false, + thread_id: None, + message_id: None, + error: Some("googlechat API returned 403".into()), + }) + .expect_err("success=false must reach core as Err"); + assert!(err.to_string().contains("googlechat API returned 403")); + } + #[test] fn editable_platforms_still_allow_streaming() { - for platform in [ - "telegram", - "slack", - "discord", - "feishu", - "teams", - "googlechat", - "wecom", - ] { + for platform in ["telegram", "slack", "discord", "feishu", "teams", "wecom"] { assert!( platform_supports_streaming(platform), "{platform} should still support streaming", diff --git a/crates/openab-gateway/src/adapters/googlechat.rs b/crates/openab-gateway/src/adapters/googlechat.rs index 12d274ee4..0e8f9c950 100644 --- a/crates/openab-gateway/src/adapters/googlechat.rs +++ b/crates/openab-gateway/src/adapters/googlechat.rs @@ -21,6 +21,12 @@ const AUDIO_MAX_DOWNLOAD: u64 = 25 * 1024 * 1024; // 25 MB /// Per-request timeout for Google Chat Media API downloads. Prevents a hung /// connection from blocking the spawned download task indefinitely. const MEDIA_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(30); + +/// Bound every token-mint request (SA-key exchange, metadata, IAM Credentials) +/// so a hung connection cannot stall senders queued behind the token cache's +/// write lock (the refresh runs while holding it) or prevent the ADC → static +/// token degradation path from engaging. +const TOKEN_REQUEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); /// Cap on text file attachments per message (matches Discord/Slack). const TEXT_FILE_COUNT_CAP: usize = 5; /// Cap on aggregate text file bytes per message (matches Discord/Slack 1 MB). @@ -259,27 +265,76 @@ impl GoogleChatJwtVerifier { } } +/// Accept an external token value only when it contains non-whitespace bytes. +/// All three OAuth boundaries (SA-key exchange, metadata base token, IAM +/// Credentials mint) share this validator so their failure semantics cannot +/// drift independently. +fn non_empty_token(value: &str) -> Option<&str> { + (!value.trim().is_empty()).then_some(value) +} + +/// Google Chat edit targets must be full `spaces/{space}/messages/{message}` +/// resource names. Reject synthetic ids and incomplete `spaces/` prefixes +/// before token resolution or network I/O. +fn is_google_chat_message_name(name: &str) -> bool { + let mut parts = name.split('/'); + matches!( + ( + parts.next(), + parts.next(), + parts.next(), + parts.next(), + parts.next(), + ), + (Some("spaces"), Some(space), Some("messages"), Some(message), None) + if !space.is_empty() && !message.is_empty() + ) +} + // --- Adapter (encapsulates all Google Chat state) --- pub struct GoogleChatAdapter { pub token_cache: Option, + pub metadata_source: Option, pub access_token: Option, pub jwt_verifier: Option, pub client: reqwest::Client, pub api_base: String, } +/// Named construction parts for [`GoogleChatAdapter::from_parts`], so call +/// sites name each field instead of counting five positional arguments. +#[derive(Default)] +pub(crate) struct GoogleChatParts { + pub sa_key_json: Option, + pub sa_key_file: Option, + pub access_token: Option, + pub audience: Option, + pub use_adc: bool, + /// Dedicated Google Chat service account impersonated by the attached + /// workload identity. It MUST differ from the metadata server's default SA; + /// Google prohibits access-token self-impersonation. + pub adc_target_service_account: Option, +} + impl GoogleChatAdapter { /// Build an adapter from resolved parts (#1379): SA key JSON (inline wins - /// over file path), optional static access token, optional JWT audience. + /// over file path), optional static access token, optional JWT audience, + /// and keyless ADC via GCE metadata + IAM Credentials for a distinct + /// `adc_target_service_account`. Auth precedence at send time: + /// SA key > ADC target > static token. /// Shared by env-derived construction and `apply_googlechat_config`. - pub(crate) fn from_parts( - sa_key_json: Option, - sa_key_file: Option, - access_token: Option, - audience: Option, - ) -> Self { + pub(crate) fn from_parts(parts: GoogleChatParts) -> Self { use tracing::{info, warn}; + let GoogleChatParts { + sa_key_json, + sa_key_file, + access_token, + audience, + use_adc, + adc_target_service_account, + } = parts; + let key_configured = sa_key_json.is_some() || sa_key_file.is_some(); let token_cache = sa_key_json .or_else(|| { sa_key_file.and_then(|path| { @@ -299,7 +354,43 @@ impl GoogleChatAdapter { info!("googlechat webhook JWT verification enabled (audience={aud})"); GoogleChatJwtVerifier::new(aud) }); - Self::new(token_cache, access_token, jwt_verifier) + // Precedence at send time (see `get_token`): SA key > ADC > static token. + let adc_target = adc_target_service_account + .as_deref() + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned); + if use_adc && token_cache.is_none() { + if key_configured { + // A key WAS configured but failed to load. Don't switch identity + // silently: name the distinct target identity selected instead. + match adc_target.as_deref() { + Some(target) => warn!( + target_service_account = %target, + "Google Chat SA key was configured but could not be loaded; \ + falling back to keyless ADC impersonation of the configured \ + target — this is NOT the failed key identity" + ), + None => error!( + "Google Chat SA key was configured but could not be loaded, and \ + use_adc=true has no adc_target_service_account; ADC is disabled" + ), + } + } else if let Some(target) = adc_target.as_deref() { + info!( + target_service_account = %target, + "googlechat keyless ADC enabled (distinct target, chat.bot via IAM Credentials)" + ); + } + } + let mut adapter = Self::new(token_cache, access_token, jwt_verifier); + // Install ADC only when it can actually be consulted and a distinct + // target is configured. A loaded SA key wins outright; use_adc without + // a target fails closed instead of attempting prohibited self-impersonation. + if use_adc && adapter.token_cache.is_none() { + adapter.metadata_source = adc_target.map(MetadataTokenSource::new); + } + adapter } pub fn new( @@ -309,6 +400,7 @@ impl GoogleChatAdapter { ) -> Self { Self { token_cache, + metadata_source: None, access_token, jwt_verifier, client: reqwest::Client::new(), @@ -316,6 +408,14 @@ impl GoogleChatAdapter { } } + /// Resolve the outbound bearer token. Precedence: SA key (`token_cache`) + /// > ADC (`metadata_source`) > static `access_token`. + /// + /// Failure behavior is asymmetric by design: an SA-key exchange error + /// hard-fails (`None` — the operator explicitly configured that identity), + /// while an ADC mint error can fall through to an explicitly configured + /// static token. The static token is opaque and may represent a different + /// principal, so that degradation is logged as a possible identity switch. async fn get_token(&self) -> Option { if let Some(ref cache) = self.token_cache { match cache.get_token(&self.client).await { @@ -326,6 +426,25 @@ impl GoogleChatAdapter { } } } + if let Some(ref src) = self.metadata_source { + match src.get_token().await { + Ok(t) => return Some(t), + Err(e) => { + // A configured static token is an explicit fallback, but it + // is opaque: the adapter cannot prove it represents the same + // principal as the ADC target. Name the possible identity + // switch rather than claiming equivalence. + if self.access_token.is_some() { + error!( + "googlechat ADC token mint failed ({e}); falling back to \ + configured static access_token (possible identity switch)" + ); + } else { + error!("googlechat ADC token mint failed: {e}"); + } + } + } + } self.access_token.clone() } @@ -364,8 +483,28 @@ impl GoogleChatAdapter { ) { // Command routing match reply.command.as_deref() { - Some("add_reaction") | Some("remove_reaction") | Some("create_topic") => return, + // Google Chat does not support these gateway commands. Return before + // token resolution or send-path logging/network I/O; in particular, + // `delete_message` must not fall through as an empty send. + Some("add_reaction") + | Some("remove_reaction") + | Some("create_topic") + | Some("delete_message") => return, Some("edit_message") => { + // Google Chat is send-once (see core's `NON_STREAMING_PLATFORMS`): + // the unified adapter's synthetic `unified_` id is not a + // valid `spaces/*/messages/*` resource name, and `patch` rejects + // it with 400 INVALID_ARGUMENT before any edit applies. Refuse + // non-resource-name ids here instead of sending a doomed request, + // so a future caller cannot silently reintroduce that failure. + if !is_google_chat_message_name(&reply.reply_to) { + tracing::warn!( + reply_to = %reply.reply_to, + "googlechat edit_message ignored: not a message resource name \ + (synthetic ids cannot be patched)" + ); + return; + } self.edit_message(&reply.reply_to, &reply.content.text).await; return; } @@ -806,27 +945,47 @@ impl GoogleChatTokenCache { { let guard = self.token.read().await; if let Some((ref tok, ref ts, ttl)) = *guard { - if ts.elapsed().as_secs() < ttl.saturating_sub(TOKEN_REFRESH_MARGIN_SECS) { + if ts.elapsed().as_secs() < refresh_threshold(ttl) { return Ok(tok.clone()); } } } let mut guard = self.token.write().await; if let Some((ref tok, ref ts, ttl)) = *guard { - if ts.elapsed().as_secs() < ttl.saturating_sub(TOKEN_REFRESH_MARGIN_SECS) { + if ts.elapsed().as_secs() < refresh_threshold(ttl) { return Ok(tok.clone()); } } - let (new_token, expire) = self.refresh(client).await?; - *guard = Some((new_token.clone(), Instant::now(), expire)); - info!("googlechat access token refreshed (expires in {expire}s)"); - Ok(new_token) + match self.refresh(client).await { + Ok((new_token, expire)) => { + *guard = Some((new_token.clone(), Instant::now(), expire)); + info!("googlechat access token refreshed (expires in {expire}s)"); + Ok(new_token) + } + Err(e) => { + // Serve the still-valid cached token on a transient exchange + // failure instead of dropping the reply. + if let Some((ref tok, ref ts, ttl)) = *guard { + let elapsed = ts.elapsed().as_secs(); + if elapsed < ttl { + warn!( + "googlechat token refresh failed ({e}); serving cached token \ + still valid for {}s", + ttl - elapsed + ); + return Ok(tok.clone()); + } + } + Err(e) + } + } } async fn refresh(&self, client: &reqwest::Client) -> Result<(String, u64), String> { let jwt = self.build_jwt().map_err(|e| format!("JWT build error: {e}"))?; let resp = client .post("https://oauth2.googleapis.com/token") + .timeout(TOKEN_REQUEST_TIMEOUT) .form(&[ ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer"), ("assertion", &jwt), @@ -843,12 +1002,15 @@ impl GoogleChatTokenCache { let token = body .get("access_token") .and_then(|v| v.as_str()) + // Boundary validation: an empty token would be cached as "valid" + // and fail every send with 401 until the refresh threshold. + .and_then(non_empty_token) .ok_or_else(|| { let err = body .get("error_description") .and_then(|v| v.as_str()) .unwrap_or("unknown error"); - format!("token exchange failed: {err}") + format!("token exchange returned missing/empty access_token: {err}") })? .to_string(); @@ -882,6 +1044,327 @@ impl GoogleChatTokenCache { } } +// --- Keyless ADC token source (GCE metadata → IAM Credentials) --- + +/// Google Chat's `chat.bot` scope for the minted token. +const ADC_CHAT_BOT_SCOPE: &str = "https://www.googleapis.com/auth/chat.bot"; +/// Lifetime we request from IAM Credentials for the impersonated token, and +/// the fallback TTL we cache it under. IAM caps impersonated tokens at 3600s. +const ADC_TOKEN_LIFETIME_SECS: u64 = 3600; +/// After a failed refresh while a cached token remains valid, reuse that token +/// for this cooldown instead of making each queued sender repeat all metadata/ +/// IAM calls serially under the write lock. +const ADC_REFRESH_RETRY_COOLDOWN_SECS: u64 = 30; + +/// Cache TTL (seconds) to use for a minted token, derived from the IAM +/// response's `expireTime`. Falls back to the full lifetime when the field is +/// missing/unparseable, and clamps to `[0, ADC_TOKEN_LIFETIME_SECS]` so an +/// org-policy-shortened token isn't cached past its real expiry (0 forces a +/// fresh mint next call rather than serving a dead token). +fn ttl_from_expire_time(expire_time: &str, now: chrono::DateTime) -> u64 { + match chrono::DateTime::parse_from_rfc3339(expire_time) { + Ok(exp) => (exp.with_timezone(&chrono::Utc) - now) + .num_seconds() + .clamp(0, ADC_TOKEN_LIFETIME_SECS as i64) as u64, + Err(_) => ADC_TOKEN_LIFETIME_SECS, + } +} + +/// Age (seconds) at which a cached token must be refreshed: its ttl minus a +/// margin, where the margin is capped at half the ttl. A fixed 300 s margin +/// would make `elapsed < ttl - 300` always false for any `ttl <= 300`, forcing +/// a re-mint on every single send; capping keeps short-lived tokens cacheable. +/// For `ttl = 0` the threshold is 0, so an expired token is never served from +/// the cache. +fn refresh_threshold(ttl: u64) -> u64 { + ttl.saturating_sub(TOKEN_REFRESH_MARGIN_SECS.min(ttl / 2)) +} + +/// Mints a `chat.bot`-scoped access token **without** a service-account key +/// file, using two distinct identities. Flow, per refresh: +/// 1. read the attached runtime SA's email + base token from GCE metadata +/// 2. call IAM Credentials `generateAccessToken` for the separately configured +/// Google Chat target SA and request the `chat.bot` scope +/// +/// The runtime SA requires `roles/iam.serviceAccountTokenCreator` on the target +/// SA. The identities MUST differ: Google prohibits using a service account's +/// short-lived access token to generate another access token for itself. +pub struct MetadataTokenSource { + token: RwLock>, + refresh_retry_after: RwLock>, + target_service_account: String, + // Private: only `new` (prod, fixed trusted hosts) or the in-module + // `with_bases` (tests, mock server) may set these. Unexported ⇒ no in-process + // caller can retarget the metadata bearer to an arbitrary host. + metadata_base: String, + iam_credentials_base: String, + // No-redirect client: a redirect from either endpoint must never carry + // the metadata bearer (`Authorization`) on to a third host. + client: reqwest::Client, +} + +impl MetadataTokenSource { + /// Production constructor: fixed trusted endpoints and a distinct target SA. + pub fn new(target_service_account: String) -> Self { + Self::with_bases( + target_service_account, + "http://metadata.google.internal".into(), + "https://iamcredentials.googleapis.com".into(), + ) + } + + /// Construct with explicit endpoint bases. Prod always goes through `new` + /// with HTTPS IAM; tests point these at a mock server. + fn with_bases( + target_service_account: String, + metadata_base: String, + iam_credentials_base: String, + ) -> Self { + Self { + token: RwLock::new(None), + refresh_retry_after: RwLock::new(None), + target_service_account, + metadata_base, + iam_credentials_base, + client: reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .unwrap_or_default(), + } + } + + /// Cached-or-refresh, mirroring [`GoogleChatTokenCache::get_token`]: + /// double-checked locking around the RwLock so only one refresh runs. + pub async fn get_token(&self) -> Result { + { + let guard = self.token.read().await; + if let Some((ref tok, ref ts, ttl)) = *guard { + if ts.elapsed().as_secs() < refresh_threshold(ttl) { + return Ok(tok.clone()); + } + } + } + let mut guard = self.token.write().await; + if let Some((ref tok, ref ts, ttl)) = *guard { + let elapsed = ts.elapsed().as_secs(); + if elapsed < refresh_threshold(ttl) { + return Ok(tok.clone()); + } + // A previous refresh failed but this token is still valid. While + // the cooldown is active, queued/subsequent callers reuse it + // immediately instead of repeating up to three HTTP timeouts. + if elapsed < ttl + && self + .refresh_retry_after + .read() + .await + .is_some_and(|deadline| deadline > Instant::now()) + { + return Ok(tok.clone()); + } + } + match self.refresh().await { + Ok(minted) => { + *self.refresh_retry_after.write().await = None; + let MintedToken { + token, + ttl, + runtime_service_account, + target_service_account, + } = minted; + if ttl == 0 { + // Freshly minted but already at/after its expireTime — almost + // always local clock skew (Google validates against its own + // clock, so the token may still work). Serve it once, but do + // not cache a dead token: the next call re-mints. + warn!( + runtime_service_account = %runtime_service_account, + target_service_account = %target_service_account, + "googlechat ADC minted a token with ttl=0 (clock skew?); \ + serving once without caching" + ); + return Ok(token); + } + *guard = Some((token.clone(), Instant::now(), ttl)); + info!( + runtime_service_account = %runtime_service_account, + target_service_account = %target_service_account, + "googlechat ADC token minted (distinct target, chat.bot, ttl {ttl}s)" + ); + Ok(token) + } + Err(e) => { + // During a transient metadata/IAM failure, serve the cached + // token while it is still valid rather than dropping the reply. + if let Some((ref tok, ref ts, ttl)) = *guard { + let elapsed = ts.elapsed().as_secs(); + if elapsed < ttl { + *self.refresh_retry_after.write().await = Some( + Instant::now() + + std::time::Duration::from_secs( + ADC_REFRESH_RETRY_COOLDOWN_SECS, + ), + ); + warn!( + "googlechat ADC refresh failed ({e}); serving cached token \ + still valid for {}s; retry suppressed for {}s", + ttl - elapsed, + ADC_REFRESH_RETRY_COOLDOWN_SECS + ); + return Ok(tok.clone()); + } + } + Err(e) + } + } + } + + async fn refresh(&self) -> Result { + // Use the source's own no-redirect client for every bearer-carrying call. + let client = &self.client; + // 1. Default SA email from the GCE metadata server. + let email = client + .get(format!( + "{}/computeMetadata/v1/instance/service-accounts/default/email", + self.metadata_base + )) + .header("Metadata-Flavor", "Google") + .timeout(TOKEN_REQUEST_TIMEOUT) + .send() + .await + .map_err(|e| format!("metadata email request failed: {e}"))? + .error_for_status() + .map_err(|e| format!("metadata email status: {e}"))? + .text() + .await + .map_err(|e| format!("metadata email read failed: {e}"))?; + let runtime_service_account = email.trim(); + if runtime_service_account.is_empty() { + return Err("metadata returned empty runtime SA email".into()); + } + if runtime_service_account.eq_ignore_ascii_case(&self.target_service_account) { + return Err(format!( + "ADC target service account must differ from runtime service account \ + (self-impersonation is prohibited): {runtime_service_account}" + )); + } + + // 2. Base access token for the default SA from the metadata server. + let base: serde_json::Value = client + .get(format!( + "{}/computeMetadata/v1/instance/service-accounts/default/token", + self.metadata_base + )) + .header("Metadata-Flavor", "Google") + .timeout(TOKEN_REQUEST_TIMEOUT) + .send() + .await + .map_err(|e| format!("metadata token request failed: {e}"))? + .error_for_status() + .map_err(|e| format!("metadata token status: {e}"))? + .json() + .await + .map_err(|e| format!("metadata token parse failed: {e}"))?; + let base_token = base + .get("access_token") + .and_then(|v| v.as_str()) + .and_then(non_empty_token) + .ok_or("metadata token response missing or empty access_token")?; + + // 3. Exchange the runtime SA's base token for a chat.bot-scoped token + // for the distinct configured Chat-app service account. + let url = format!( + "{}/v1/projects/-/serviceAccounts/{}:generateAccessToken", + self.iam_credentials_base, self.target_service_account + ); + let resp = client + .post(&url) + .bearer_auth(base_token) + .json(&serde_json::json!({ + "scope": [ADC_CHAT_BOT_SCOPE], + "lifetime": format!("{ADC_TOKEN_LIFETIME_SECS}s"), + })) + .timeout(TOKEN_REQUEST_TIMEOUT) + .send() + .await + .map_err(|e| format!("generateAccessToken request failed: {e}"))?; + let status = resp.status(); + if !status.is_success() { + // Classify the common GCP causes so on-call can act on the log + // line directly instead of decoding GCP error prose (mirrors the + // operator guidance in docs/google-chat.md Option C). + let body: String = resp + .text() + .await + .unwrap_or_default() + .chars() + .take(400) + .collect(); + let reason = classify_generate_access_token_error(status.as_u16(), &body); + return Err(format!( + "generateAccessToken failed (status {status}, reason={reason}): {body}" + )); + } + let resp: serde_json::Value = resp + .json() + .await + .map_err(|e| format!("generateAccessToken parse failed: {e}"))?; + let token = resp + .get("accessToken") + .and_then(|v| v.as_str()) + // Boundary validation: an empty token would be cached as "valid" + // for its full TTL and bypass the static-token degradation path. + .and_then(non_empty_token) + .ok_or("generateAccessToken response missing or empty accessToken")? + .to_string(); + // Cache under the server-granted lifetime (respects an org policy that + // shortens impersonated tokens below the requested 3600s), falling back + // to the full lifetime when expireTime is absent/unparseable. + let ttl = resp + .get("expireTime") + .and_then(|v| v.as_str()) + .map(|e| ttl_from_expire_time(e, chrono::Utc::now())) + .unwrap_or(ADC_TOKEN_LIFETIME_SECS); + Ok(MintedToken { + token, + ttl, + runtime_service_account: runtime_service_account.to_string(), + target_service_account: self.target_service_account.clone(), + }) + } +} + +/// A successfully minted ADC token plus the source and target identities for +/// audit logging of the supported two-service-account impersonation flow. +struct MintedToken { + token: String, + ttl: u64, + runtime_service_account: String, + target_service_account: String, +} + +/// Best-effort classification of common GCP `generateAccessToken` failures. +/// Ordering matters: the insufficient-scope 403 body says "scopes" (not +/// "permission"), which is the documented signal distinguishing it from a +/// missing `roles/iam.serviceAccountTokenCreator` binding. +fn classify_generate_access_token_error(status: u16, body: &str) -> &'static str { + let b = body.to_ascii_lowercase(); + if b.contains("failed_precondition") || b.contains("same service account") { + "self_impersonation_prohibited" + } else if b.contains("api has not been used") + || b.contains("service_disabled") + || b.contains("is disabled") + { + "api_not_enabled" + } else if status == 403 && b.contains("scopes") { + "insufficient_scope" + } else if status == 403 { + "missing_role" + } else { + "unclassified" + } +} + /// Convert markdown to Google Chat native formatting. /// /// Called by both `send_message` and `edit_message`. Assumes the caller passes @@ -1811,6 +2294,475 @@ mod tests { assert!(result.is_ok()); } + // --- Keyless ADC (MetadataTokenSource) tests --- + + #[test] + fn ttl_from_expire_time_derives_and_clamps() { + use chrono::{DateTime, Utc}; + let now: DateTime = "2026-08-25T00:00:00Z".parse().unwrap(); + // Normal: 30 min out → 1800s. + assert_eq!(ttl_from_expire_time("2026-08-25T00:30:00Z", now), 1800); + // Beyond the 3600s cap → clamped to 3600. + assert_eq!(ttl_from_expire_time("2026-08-25T05:00:00Z", now), 3600); + // Already expired → 0 (forces refresh next call, never caches a dead token). + assert_eq!(ttl_from_expire_time("2026-08-24T23:00:00Z", now), 0); + // Unparseable → safe fallback to the full lifetime. + assert_eq!(ttl_from_expire_time("not-a-timestamp", now), 3600); + } + + #[tokio::test] + async fn metadata_token_source_mints_chat_bot_token() { + use wiremock::matchers::{header, method, path, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + // GCE metadata: default SA email. + Mock::given(method("GET")) + .and(path( + "/computeMetadata/v1/instance/service-accounts/default/email", + )) + .and(header("Metadata-Flavor", "Google")) + .respond_with( + ResponseTemplate::new(200) + .set_body_string("openab-host@dev-seba.iam.gserviceaccount.com"), + ) + .mount(&server) + .await; + // GCE metadata: base access token. + Mock::given(method("GET")) + .and(path( + "/computeMetadata/v1/instance/service-accounts/default/token", + )) + .and(header("Metadata-Flavor", "Google")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "base-tok", + "expires_in": 3600, + "token_type": "Bearer" + }))) + .mount(&server) + .await; + // IAM Credentials: runtime SA impersonates distinct Chat target. + Mock::given(method("POST")) + .and(path_regex( + r"/v1/projects/-/serviceAccounts/.*:generateAccessToken", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "accessToken": "chat-bot-tok", + "expireTime": "2099-01-01T00:00:00Z" + }))) + .mount(&server) + .await; + + let src = MetadataTokenSource::with_bases( + "chat-bot@project.iam.gserviceaccount.com".into(), + server.uri(), + server.uri(), + ); + + let token = src + .get_token() + .await + .expect("should mint a chat.bot token"); + assert_eq!(token, "chat-bot-tok"); + } + + #[tokio::test] + async fn metadata_token_source_rejects_self_impersonation_before_token_request() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + let same_sa = "runtime@project.iam.gserviceaccount.com"; + Mock::given(method("GET")) + .and(path( + "/computeMetadata/v1/instance/service-accounts/default/email", + )) + .respond_with(ResponseTemplate::new(200).set_body_string(same_sa)) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/computeMetadata/v1/instance/service-accounts/default/token", + )) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&server) + .await; + + let src = MetadataTokenSource::with_bases( + same_sa.into(), + server.uri(), + server.uri(), + ); + let err = src + .get_token() + .await + .expect_err("same runtime/target SA must be rejected"); + assert!(err.contains("self-impersonation is prohibited"), "{err}"); + } + + #[tokio::test] + async fn adc_refresh_failure_cooldown_prevents_queued_retry_storm() { + use wiremock::matchers::{method, path}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/computeMetadata/v1/instance/service-accounts/default/email", + )) + .respond_with( + ResponseTemplate::new(200) + .set_body_string("runtime@project.iam.gserviceaccount.com"), + ) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/computeMetadata/v1/instance/service-accounts/default/token", + )) + .respond_with(ResponseTemplate::new(503)) + .expect(1) + .mount(&server) + .await; + + let src = MetadataTokenSource::with_bases( + "chat-bot@project.iam.gserviceaccount.com".into(), + server.uri(), + server.uri(), + ); + *src.token.write().await = Some(( + "cached-token".into(), + Instant::now() - std::time::Duration::from_secs(6), + 10, + )); + + // First call attempts refresh, gets 503, serves the still-valid token, + // and starts cooldown. Second call must reuse it without another GET. + assert_eq!(src.get_token().await.unwrap(), "cached-token"); + assert_eq!(src.get_token().await.unwrap(), "cached-token"); + } + + #[test] + fn from_parts_use_adc_toggles_metadata_source() { + let with = GoogleChatAdapter::from_parts(GoogleChatParts { + use_adc: true, + adc_target_service_account: Some("chat-bot@project.iam.gserviceaccount.com".into()), + ..Default::default() + }); + assert!(with.metadata_source.is_some(), "use_adc=true → ADC source"); + let without = GoogleChatAdapter::from_parts(GoogleChatParts::default()); + assert!( + without.metadata_source.is_none(), + "use_adc=false → no ADC source" + ); + } + + #[test] + fn from_parts_use_adc_without_target_fails_closed() { + let adapter = GoogleChatAdapter::from_parts(GoogleChatParts { + use_adc: true, + ..Default::default() + }); + assert!( + adapter.metadata_source.is_none(), + "use_adc without adc_target_service_account must not install ADC" + ); + } + + #[test] + fn from_parts_malformed_key_with_use_adc_installs_adc() { + // Regression: a configured-but-malformed SA key parses to + // token_cache=None; with use_adc=true the ADC source is still installed + // (from_parts logs a warning naming the identity switch). + let adapter = GoogleChatAdapter::from_parts(GoogleChatParts { + sa_key_json: Some("not valid json".into()), + use_adc: true, + adc_target_service_account: Some("chat-bot@project.iam.gserviceaccount.com".into()), + ..Default::default() + }); + assert!( + adapter.token_cache.is_none(), + "malformed SA key → no SA-key cache" + ); + assert!( + adapter.metadata_source.is_some(), + "use_adc=true → ADC source installed even when a key was configured but failed to load" + ); + } + + #[test] + fn from_parts_unreadable_key_file_with_use_adc_installs_adc() { + // Regression: an unreadable/absent key FILE also yields token_cache=None + // and must not suppress ADC. + let adapter = GoogleChatAdapter::from_parts(GoogleChatParts { + sa_key_file: Some("/nonexistent/path/sa-key.json".into()), + use_adc: true, + adc_target_service_account: Some("chat-bot@project.iam.gserviceaccount.com".into()), + ..Default::default() + }); + assert!(adapter.token_cache.is_none(), "unreadable key file → no cache"); + assert!( + adapter.metadata_source.is_some(), + "use_adc=true → ADC source installed when the key file could not be read" + ); + } + + #[test] + fn from_parts_loaded_key_suppresses_metadata_source() { + // A successfully loaded SA key wins outright at send time, so no ADC + // source is installed behind it (dead-at-runtime otherwise). + let key = serde_json::json!({ + "client_email": "sa@example.iam.gserviceaccount.com", + "private_key": "-----BEGIN PRIVATE KEY-----\nnot-a-real-key\n-----END PRIVATE KEY-----\n", + }) + .to_string(); + let adapter = GoogleChatAdapter::from_parts(GoogleChatParts { + sa_key_json: Some(key), + use_adc: true, + adc_target_service_account: Some("chat-bot@project.iam.gserviceaccount.com".into()), + ..Default::default() + }); + assert!(adapter.token_cache.is_some(), "valid key JSON → SA-key cache"); + assert!( + adapter.metadata_source.is_none(), + "loaded SA key → ADC source not installed" + ); + } + + #[test] + fn classify_generate_access_token_error_covers_documented_cases() { + assert_eq!( + classify_generate_access_token_error( + 400, + r#"{"error":{"status":"FAILED_PRECONDITION","message":"You can't create a token for the same service account that you used to authenticate the request."}}"#, + ), + "self_impersonation_prohibited" + ); + // Insufficient scope: the 403 body says "scopes" — the documented + // signal distinguishing it from a missing IAM role. + assert_eq!( + classify_generate_access_token_error( + 403, + r#"{"error":{"status":"PERMISSION_DENIED","message":"Request had insufficient authentication scopes."}}"# + ), + "insufficient_scope" + ); + // Missing serviceAccountTokenCreator: 403 without the scopes wording. + assert_eq!( + classify_generate_access_token_error( + 403, + r#"{"error":{"status":"IAM_PERMISSION_DENIED","message":"Permission 'iam.serviceAccounts.getAccessToken' denied on resource"}}"# + ), + "missing_role" + ); + // IAM Credentials API not enabled. + assert_eq!( + classify_generate_access_token_error( + 403, + r#"{"error":{"message":"IAM Service Account Credentials API has not been used in project 123 before or it is disabled."}}"# + ), + "api_not_enabled" + ); + // Anything else stays unclassified rather than guessing. + assert_eq!(classify_generate_access_token_error(500, "boom"), "unclassified"); + } + + #[test] + fn external_token_values_must_be_non_whitespace() { + assert_eq!(None::<&str>.and_then(non_empty_token), None); + assert_eq!(non_empty_token(""), None); + assert_eq!(non_empty_token(" \t\n"), None); + assert_eq!(non_empty_token(" token "), Some(" token ")); + } + + #[test] + fn edit_targets_require_full_message_resource_names() { + assert!(is_google_chat_message_name("spaces/SP/messages/msg1")); + assert!(!is_google_chat_message_name("unified_a1b2c3d4")); + assert!(!is_google_chat_message_name("spaces/")); + assert!(!is_google_chat_message_name("spaces/SP")); + assert!(!is_google_chat_message_name("spaces/SP/messages/")); + assert!(!is_google_chat_message_name("spaces/SP/messages/msg1/extra")); + } + + #[tokio::test] + async fn adc_takes_precedence_over_static_access_token() { + use wiremock::matchers::{method, path, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/computeMetadata/v1/instance/service-accounts/default/email", + )) + .respond_with( + ResponseTemplate::new(200) + .set_body_string("openab-host@dev-seba.iam.gserviceaccount.com"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/computeMetadata/v1/instance/service-accounts/default/token", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "base-tok", "expires_in": 3600 + }))) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path_regex( + r"/v1/projects/-/serviceAccounts/.*:generateAccessToken", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "accessToken": "chat-bot-tok" + }))) + .mount(&server) + .await; + + // Adapter has BOTH an ADC source and a static token; ADC must win. + let mut adapter = GoogleChatAdapter::from_parts(GoogleChatParts { + access_token: Some("static-tok".into()), + use_adc: true, + adc_target_service_account: Some("chat-bot@project.iam.gserviceaccount.com".into()), + ..Default::default() + }); + // Repoint the ADC source at the mock server (bases are private now). + adapter.metadata_source = Some(MetadataTokenSource::with_bases( + "chat-bot@project.iam.gserviceaccount.com".into(), + server.uri(), + server.uri(), + )); + let token = adapter.get_token().await.expect("a token"); + assert_eq!(token, "chat-bot-tok", "ADC should win over static token"); + } + + #[tokio::test] + async fn metadata_token_source_rejects_blank_minted_token() { + use wiremock::matchers::{method, path, path_regex}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path( + "/computeMetadata/v1/instance/service-accounts/default/email", + )) + .respond_with( + ResponseTemplate::new(200) + .set_body_string("openab-host@dev-seba.iam.gserviceaccount.com"), + ) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path( + "/computeMetadata/v1/instance/service-accounts/default/token", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "access_token": "base-tok", "expires_in": 3600 + }))) + .mount(&server) + .await; + // A malformed IAM response with an empty accessToken must surface as a + // mint error (and thus follow the static-token degradation path), not + // be cached as a "valid" credential. + Mock::given(method("POST")) + .and(path_regex( + r"/v1/projects/-/serviceAccounts/.*:generateAccessToken", + )) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "accessToken": "" + }))) + .mount(&server) + .await; + + let src = MetadataTokenSource::with_bases( + "chat-bot@project.iam.gserviceaccount.com".into(), + server.uri(), + server.uri(), + ); + let err = src.get_token().await.expect_err("blank token must be rejected"); + assert!( + err.contains("missing or empty accessToken"), + "unexpected error: {err}" + ); + } + + #[tokio::test] + async fn handle_reply_edit_message_ignores_synthetic_unified_id() { + use wiremock::matchers::method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + // Expect ZERO requests: a synthetic `unified_` id is not a valid + // message resource name, so the edit must be refused locally instead + // of being sent to the API (which would 400 INVALID_ARGUMENT). + let mock_server = MockServer::start().await; + Mock::given(method("PATCH")) + .respond_with(ResponseTemplate::new(200)) + .expect(0) + .mount(&mock_server) + .await; + + let (event_tx, _event_rx) = tokio::sync::broadcast::channel::(16); + let mut adapter = GoogleChatAdapter::new(None, Some("fake-token".into()), None); + adapter.api_base = mock_server.uri(); + + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "unified_a1b2c3d4e5f6".into(), + platform: "googlechat".into(), + channel: ReplyChannel { + id: "spaces/SP".into(), + thread_id: None, + }, + content: Content { + content_type: "text".into(), + attachments: Vec::new(), + text: "updated text".into(), + }, + command: Some("edit_message".into()), + request_id: None, + quote_message_id: None, + }; + + adapter.handle_reply(&reply, &event_tx).await; + // MockServer verifies the expect(0) on drop. + } + + #[tokio::test] + async fn handle_reply_delete_message_is_explicit_noop() { + let (event_tx, mut event_rx) = tokio::sync::broadcast::channel::(16); + let adapter = GoogleChatAdapter::new(None, Some("fake-token".into()), None); + let reply = GatewayReply { + schema: "openab.gateway.reply.v1".into(), + reply_to: "spaces/SP/messages/msg1".into(), + platform: "googlechat".into(), + channel: ReplyChannel { + id: "spaces/SP".into(), + thread_id: None, + }, + content: Content { + content_type: "text".into(), + attachments: Vec::new(), + text: String::new(), + }, + command: Some("delete_message".into()), + // Under the old fallthrough behavior this request id produced an + // "empty message" GatewayResponse. Explicit command routing emits + // no response and performs no token/network work. + request_id: Some("req_delete".into()), + quote_message_id: None, + }; + + adapter.handle_reply(&reply, &event_tx).await; + assert!( + event_rx.try_recv().is_err(), + "delete_message must return before the empty-send response path" + ); + } + // --- Bot filtering logic test --- #[test] @@ -2246,6 +3198,7 @@ mod tests { .respond_with(ResponseTemplate::new(200).set_body_json( serde_json::json!({"name": "spaces/SP/messages/msg1"}), )) + .expect(1) .mount(&mock_server) .await; diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index d2b257c7d..ef2aa86e1 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -195,10 +195,20 @@ impl AppState { .unwrap_or(false); if enabled { Some(adapters::googlechat::GoogleChatAdapter::from_parts( - std::env::var("GOOGLE_CHAT_SA_KEY_JSON").ok(), - std::env::var("GOOGLE_CHAT_SA_KEY_FILE").ok(), - std::env::var("GOOGLE_CHAT_ACCESS_TOKEN").ok(), - std::env::var("GOOGLE_CHAT_AUDIENCE").ok(), + adapters::googlechat::GoogleChatParts { + sa_key_json: std::env::var("GOOGLE_CHAT_SA_KEY_JSON").ok(), + sa_key_file: std::env::var("GOOGLE_CHAT_SA_KEY_FILE").ok(), + access_token: std::env::var("GOOGLE_CHAT_ACCESS_TOKEN").ok(), + audience: std::env::var("GOOGLE_CHAT_AUDIENCE").ok(), + use_adc: std::env::var("GOOGLE_CHAT_USE_ADC") + .map(|v| v == "true" || v == "1") + .unwrap_or(false), + adc_target_service_account: std::env::var( + "GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT", + ) + .ok() + .filter(|s| !s.trim().is_empty()), + }, )) } else { None @@ -450,10 +460,14 @@ impl AppState { self.googlechat_webhook_path = cfg.webhook_path; self.google_chat = if cfg.enabled { Some(adapters::googlechat::GoogleChatAdapter::from_parts( - cfg.sa_key_json, - cfg.sa_key_file, - cfg.access_token, - cfg.audience, + adapters::googlechat::GoogleChatParts { + sa_key_json: cfg.sa_key_json, + sa_key_file: cfg.sa_key_file, + access_token: cfg.access_token, + audience: cfg.audience, + use_adc: cfg.use_adc, + adc_target_service_account: cfg.adc_target_service_account, + }, )) } else { None @@ -554,6 +568,8 @@ pub struct GatewayGoogleChatConfig { pub sa_key_file: Option, pub access_token: Option, pub audience: Option, + pub use_adc: bool, + pub adc_target_service_account: Option, pub webhook_path: String, } @@ -748,10 +764,20 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { info!(path = %googlechat_webhook_path, "googlechat adapter enabled"); app = app.route(&googlechat_webhook_path, post(adapters::googlechat::webhook)); Some(adapters::googlechat::GoogleChatAdapter::from_parts( - std::env::var("GOOGLE_CHAT_SA_KEY_JSON").ok(), - std::env::var("GOOGLE_CHAT_SA_KEY_FILE").ok(), - std::env::var("GOOGLE_CHAT_ACCESS_TOKEN").ok(), - std::env::var("GOOGLE_CHAT_AUDIENCE").ok(), + adapters::googlechat::GoogleChatParts { + sa_key_json: std::env::var("GOOGLE_CHAT_SA_KEY_JSON").ok(), + sa_key_file: std::env::var("GOOGLE_CHAT_SA_KEY_FILE").ok(), + access_token: std::env::var("GOOGLE_CHAT_ACCESS_TOKEN").ok(), + audience: std::env::var("GOOGLE_CHAT_AUDIENCE").ok(), + use_adc: std::env::var("GOOGLE_CHAT_USE_ADC") + .map(|v| v == "true" || v == "1") + .unwrap_or(false), + adc_target_service_account: std::env::var( + "GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT", + ) + .ok() + .filter(|s| !s.trim().is_empty()), + }, )) } else { None @@ -1249,6 +1275,8 @@ mod l1_audit_tests { sa_key_file: None, access_token: Some("tok".into()), audience: None, + use_adc: false, + adc_target_service_account: None, webhook_path: "/hook/gc".into(), }); assert!(s.google_chat.is_some()); @@ -1262,6 +1290,8 @@ mod l1_audit_tests { sa_key_file: None, access_token: Some("tok".into()), audience: Some("aud".into()), + use_adc: false, + adc_target_service_account: None, webhook_path: "/hook/gc".into(), }); assert!(flagged(&s).is_empty()); @@ -1273,6 +1303,8 @@ mod l1_audit_tests { sa_key_file: None, access_token: None, audience: None, + use_adc: false, + adc_target_service_account: None, webhook_path: "/hook/gc".into(), }); assert!(s.google_chat.is_none()); diff --git a/crates/openab-gateway/tests/config_first_conformance.rs b/crates/openab-gateway/tests/config_first_conformance.rs index eeefc8d8c..447b2216f 100644 --- a/crates/openab-gateway/tests/config_first_conformance.rs +++ b/crates/openab-gateway/tests/config_first_conformance.rs @@ -92,6 +92,8 @@ const COVERED: &[&str] = &[ "GOOGLE_CHAT_SA_KEY_JSON", "GOOGLE_CHAT_SA_KEY_FILE", "GOOGLE_CHAT_ACCESS_TOKEN", + "GOOGLE_CHAT_USE_ADC", + "GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT", "GOOGLE_CHAT_AUDIENCE", "GOOGLE_CHAT_WEBHOOK_PATH", "GOOGLE_CHAT_ALLOW_ALL_USERS", diff --git a/docs/config-reference.md b/docs/config-reference.md index af30e0940..818003484 100644 --- a/docs/config-reference.md +++ b/docs/config-reference.md @@ -225,6 +225,8 @@ Full first-class Google Chat section (config-first parity, #1379) — credential | `sa_key_json` | string | — | Inline service-account key JSON (wins over `sa_key_file`). Env: `GOOGLE_CHAT_SA_KEY_JSON`. | | `sa_key_file` | string | — | Path to a service-account key file. Env: `GOOGLE_CHAT_SA_KEY_FILE`. | | `access_token` | string | — | Static access token alternative. Env: `GOOGLE_CHAT_ACCESS_TOKEN`. | +| `use_adc` | bool | `false` | Enable keyless ADC: the attached runtime SA impersonates a distinct Chat-app target via IAM Credentials. Requires `roles/iam.serviceAccountTokenCreator` on the target + `iamcredentials.googleapis.com`. Self-impersonation is prohibited. Env: `GOOGLE_CHAT_USE_ADC`. | +| `adc_target_service_account` | string | — | Dedicated Chat-app SA email to impersonate. Required with `use_adc=true`; MUST differ from the runtime SA. Env: `GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT`. | | `audience` | string | — | JWT audience — enables webhook JWT verification (L1). Env: `GOOGLE_CHAT_AUDIENCE`. | | `webhook_path` | string | `/webhook/googlechat` | Env: `GOOGLE_CHAT_WEBHOOK_PATH`. | | `allow_all_users` | bool \| omit | `false` (deny-all) | Env: `GOOGLE_CHAT_ALLOW_ALL_USERS`. | diff --git a/docs/google-chat.md b/docs/google-chat.md index dcee33d0e..bd707a31a 100644 --- a/docs/google-chat.md +++ b/docs/google-chat.md @@ -74,7 +74,7 @@ Google Chat uses a service account to authenticate outbound API calls (bot repli ## 3. Configure the Gateway -The gateway supports two authentication methods for sending replies: +The gateway supports three authentication methods for sending replies: ### Option A: Service Account Key (recommended — auto-refresh) @@ -112,6 +112,32 @@ docker run -d --name openab-gateway \ ghcr.io/openabdev/openab-gateway:latest ``` +### Option C: Keyless ADC (recommended on GCP — no key file) + +When the gateway runs on GCP (GKE / GCE / Cloud Run), its attached **runtime service account** can impersonate a separate **Google Chat service account** and mint a `chat.bot` token without any key file. The gateway reads the runtime identity and a base token from the GCE metadata server, then calls IAM Credentials `generateAccessToken` for the configured Chat-app target identity. + +The two identities **must be different**. Google prohibits using a service account's short-lived access token to generate another access token for that same service account (`FAILED_PRECONDITION`); see [Service account credentials — Self-impersonation](https://cloud.google.com/iam/docs/service-account-creds#self-impersonation). + +Prerequisites: + +- Attach a runtime SA to the workload (for example `openab-runtime@PROJECT.iam.gserviceaccount.com`). +- Use a distinct SA as the Google Chat app identity (for example `openab-chat@PROJECT.iam.gserviceaccount.com`); that target SA must be the app/space member that sends messages. +- Grant the runtime SA `roles/iam.serviceAccountTokenCreator` **on the target Chat SA**. +- Enable `iamcredentials.googleapis.com`. +- The metadata base token must carry `cloud-platform` (or `.../auth/iam`) scope. A default-scope GCE VM returns `403 PERMISSION_DENIED: "Request had insufficient authentication scopes."`; match *scopes* to distinguish it from a missing role. GCE access scopes cannot be changed while the VM is running: create the VM with `--scopes=cloud-platform`, or use `gcloud compute instances set-scopes` followed by a stop/start. + +`chat.bot` is a Workspace scope and is not a subset of `cloud-platform`, so the runtime metadata token cannot call Chat directly. The supported flow is runtime SA → distinct target Chat SA via `generateAccessToken`. + +```bash +export GOOGLE_CHAT_ENABLED=true +export GOOGLE_CHAT_USE_ADC=true +export GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT="openab-chat@PROJECT.iam.gserviceaccount.com" +``` + +Precedence: if a configured SA key loads successfully, it wins and ADC is ignored. If a key is configured but fails to load, the adapter uses the configured ADC target and logs the identity switch. `GOOGLE_CHAT_USE_ADC=true` without `GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT` fails closed (ADC is not installed). If ADC fails and a static token is explicitly configured, the adapter degrades to it with a warning that it may represent a different identity. + +> **Migrating an existing release from a SA key to ADC:** the chart renders the Google Chat Secret only when `saKeyJson` / `accessToken` is set, and that Secret carries `helm.sh/resource-policy: keep`. Switching to ADC-only stops Helm from managing it but **leaves the old key material in the cluster indefinitely**. Delete the orphaned Secret after the switch: it is the gateway Secret named by the chart's `openab.agentFullname` helper — `--gateway` by default (or the agent's `nameOverride`) — and it contains the `google-chat-sa-key-json` key. Find it with `kubectl get secrets -o name | grep gateway`, confirm with `kubectl get secret -o jsonpath='{.data}' | grep -o google-chat-sa-key-json`, then `kubectl delete secret `. Otherwise the "no key to mount or leak" benefit is undercut. + ### Local development ```bash @@ -199,7 +225,7 @@ Each field falls back to its `GOOGLE_CHAT_ALLOW_ALL_USERS` / `GOOGLE_CHAT_ALLOWE - Links: `[text](url)` → `` - Inline code, fenced code blocks: pass through unchanged - Tables and other unsupported syntax pass through as-is -- **Streaming (edit_message)** — when OAB streaming is enabled, the bot edits its initial reply in-place as tokens arrive (typewriter effect) +- **Send-once (no streaming)** — Google Chat is a request/response REST surface, so the adapter posts the full reply once with no in-place editing. It is in `NON_STREAMING_PLATFORMS`; see `docs/platforms/schema/googlechat.toml` for why (the unified adapter's synthetic message id is not a valid resource name → `patch` returns `400 INVALID_ARGUMENT`, and the API documents a 1 write/sec-per-space quota). - **Inbound attachments** — image, text file, and audio attachments are downloaded via Google Chat Media API and stored to `~/.openab/media/inbound/` (colocate filesystem store): - Images: resized to ≤1200px JPEG (q75); GIFs preserved. Max 10 MB. - Text files: only known text extensions (`.txt`, `.md`, `.json`, `.py`, `.rs`, etc.). Max 512 KB. @@ -221,6 +247,8 @@ Each field falls back to its `GOOGLE_CHAT_ALLOW_ALL_USERS` / `GOOGLE_CHAT_ALLOWE | `GOOGLE_CHAT_SA_KEY_JSON` | No | — | Service account key JSON string (enables auto-refresh) | | `GOOGLE_CHAT_SA_KEY_FILE` | No | — | Path to service account key JSON file (alternative to `SA_KEY_JSON`) | | `GOOGLE_CHAT_ACCESS_TOKEN` | No | — | Static OAuth2 access token (fallback, expires in 1 hour) | +| `GOOGLE_CHAT_USE_ADC` | No | `false` | Enable keyless ADC: attached runtime SA impersonates a distinct Chat-app target via IAM Credentials — see Option C | +| `GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT` | With ADC | — | Email of the dedicated Chat-app SA to impersonate. Required when `USE_ADC=true`; MUST differ from the runtime SA | | `GOOGLE_CHAT_WEBHOOK_PATH` | No | `/webhook/googlechat` | Webhook endpoint path | ## Security: Webhook Verification diff --git a/docs/platforms/schema/googlechat.toml b/docs/platforms/schema/googlechat.toml index 375e62663..c43489d18 100644 --- a/docs/platforms/schema/googlechat.toml +++ b/docs/platforms/schema/googlechat.toml @@ -131,9 +131,9 @@ pr = "" [[openab_features]] feature = "streaming" -status = "partial" -note = "No native streaming API. Gateway adapter leaves uses_native_streaming=false, so core streams via the post-then-edit_message loop; each edit sends the full accumulated text as a patch call." -source = ["crates/openab-gateway/src/adapters/googlechat.rs#edit_message", "crates/openab-core/src/adapter.rs#uses_native_streaming"] +status = "not_implemented" +note = "Send-once by design (no cosmetic streaming). The decisive reason is structural: the unified adapter returns a synthetic `unified_` message id that is not a valid resource name, so `spaces.messages.patch` rejects it with 400 INVALID_ARGUMENT ('Missing or malformed message resource name') before any content is applied — per-token post-then-edit cannot work at all. Separately, Google Chat documents a 1 write/sec-per-space quota (create+patch+delete combined; https://developers.google.com/workspace/chat/limits); treat that as a documented constraint on high-frequency editing rather than an observed hard failure, since enforcement is burst-tolerant in practice. googlechat is therefore in NON_STREAMING_PLATFORMS, and `resolve_streaming` forces send-once on BOTH the embedded dispatch and the WebSocket gateway paths — matching Google Chat's documented send-once default. (Previously 'partial': core attempted post-then-edit, which failed on every edit.)" +source = ["crates/openab-core/src/gateway.rs#NON_STREAMING_PLATFORMS", "crates/openab-core/src/adapter.rs#resolve_streaming", "crates/openab-core/src/adapter.rs#uses_native_streaming"] pr = "" [[openab_features]] @@ -153,7 +153,7 @@ pr = "" [[openab_features]] feature = "delete_message" status = "not_implemented" -note = "GatewayAdapter overrides delete_message to emit a fire-and-forget command:\"delete_message\" instead of the trait default (edit-to-zero-width). The googlechat adapter does not match delete_message in handle_reply (only add_reaction/remove_reaction/create_topic/edit_message), so it falls through to the send path with empty text → hits the empty-message short-circuit and sends nothing. Net: delete is a no-op on Google Chat." +note = "GatewayAdapter emits a fire-and-forget command:\"delete_message\" instead of the trait default (edit-to-zero-width). The googlechat adapter explicitly matches delete_message with the other unsupported commands and returns before token resolution, logging, or network I/O. Net: delete is an intentional no-op on Google Chat." source = ["crates/openab-gateway/src/adapters/googlechat.rs#handle_reply", "crates/openab-core/src/gateway.rs#delete_message", "crates/openab-core/src/adapter.rs#delete_message"] pr = "" @@ -245,6 +245,14 @@ kind = "openab_decision" source = "crates/openab-gateway/src/adapters/googlechat.rs#build_jwt" refs = [] +[[quirks]] +date = "2026-08-25" +title = "Keyless ADC outbound path (no SA key)" +note = "Outbound creds have a third option beside the SA-key JWT-bearer exchange and the static token: keyless ADC (use_adc / GOOGLE_CHAT_USE_ADC). MetadataTokenSource reads the attached runtime SA email + base token from GCE metadata, then calls IAM Credentials generateAccessToken for the distinct adc_target_service_account / GOOGLE_CHAT_ADC_TARGET_SERVICE_ACCOUNT and requests chat.bot. Google prohibits access-token self-impersonation, so runtime and target identities MUST differ; the code rejects equality before minting. The runtime SA needs roles/iam.serviceAccountTokenCreator on the target. Auth precedence: SA key > ADC target > explicitly configured static token (ADC-to-static degradation logs a possible identity switch)." +kind = "openab_decision" +source = "crates/openab-gateway/src/adapters/googlechat.rs#MetadataTokenSource" +refs = [] + [[quirks]] date = "2026-07-04" title = "Reactions are structurally impossible for the bot" @@ -287,8 +295,8 @@ refs = [] [[quirks]] date = "2026-07-04" -title = "Delete is a silent no-op (not even the edit fallback)" -note = "Unlike platforms where delete_message falls back to the trait's edit-to-zero-width, on Google Chat the delete_message command isn't matched in handle_reply, falls through to the send path with empty text, and hits the empty-message short-circuit — so nothing is sent and no edit occurs. Streaming-placeholder cleanup that relies on delete is therefore a no-op here." +title = "Delete is an explicit no-op (not even the edit fallback)" +note = "Unlike platforms where delete_message falls back to the trait's edit-to-zero-width, Google Chat matches the delete_message command in handle_reply and returns before token resolution, logging, or network I/O. Streaming-placeholder cleanup that relies on delete is therefore an intentional no-op here." kind = "openab_decision" source = "crates/openab-gateway/src/adapters/googlechat.rs#handle_reply" refs = [] diff --git a/docs/platforms/schema/lineworks.toml b/docs/platforms/schema/lineworks.toml index 066944446..9b6cd013e 100644 --- a/docs/platforms/schema/lineworks.toml +++ b/docs/platforms/schema/lineworks.toml @@ -132,8 +132,8 @@ pr = "" [[openab_features]] feature = "streaming" status = "n_a" -note = "No edit API to drive post+edit streaming. The platform is listed in NON_EDITABLE_PLATFORMS so the core forces streaming off and the cosmetic edit/delete commands are dropped by the dispatcher." -source = ["crates/openab-core/src/gateway.rs#NON_EDITABLE_PLATFORMS", "crates/openab-gateway/src/adapters/lineworks.rs#dispatch_lineworks_reply"] +note = "No edit API to drive post+edit streaming. The platform is listed in NON_STREAMING_PLATFORMS so the core forces streaming off and the cosmetic edit/delete commands are dropped by the dispatcher." +source = ["crates/openab-core/src/gateway.rs#NON_STREAMING_PLATFORMS", "crates/openab-gateway/src/adapters/lineworks.rs#dispatch_lineworks_reply"] pr = "" [[openab_features]] diff --git a/src/main.rs b/src/main.rs index a2ee786ac..f49879778 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1284,6 +1284,8 @@ async fn main() -> anyhow::Result<()> { sa_key_json: r.sa_key_json, sa_key_file: r.sa_key_file, access_token: r.access_token, + use_adc: r.use_adc, + adc_target_service_account: r.adc_target_service_account, audience: r.audience, webhook_path: r.webhook_path, });