From b0fe42b0afee7d9d3ff9296e66a384deea761d23 Mon Sep 17 00:00:00 2001 From: Aqil Aziz Date: Wed, 20 May 2026 10:02:23 +0700 Subject: [PATCH] fix(composio): request Gmail read scope for triggers (#2191) --- .../e2e/specs/composio-triggers-flow.spec.ts | 38 +++++++++- docs/TEST-COVERAGE-MATRIX.md | 2 +- gitbooks/features/integrations/triggers.md | 6 ++ scripts/mock-api/routes/integrations.mjs | 23 ++++++ src/openhuman/composio/client.rs | 74 ++++++++++++++++++- src/openhuman/composio/client_tests.rs | 54 ++++++++++++-- src/openhuman/composio/schemas.rs | 4 +- 7 files changed, 190 insertions(+), 11 deletions(-) diff --git a/app/test/e2e/specs/composio-triggers-flow.spec.ts b/app/test/e2e/specs/composio-triggers-flow.spec.ts index 6e287b58e..1b21102c4 100644 --- a/app/test/e2e/specs/composio-triggers-flow.spec.ts +++ b/app/test/e2e/specs/composio-triggers-flow.spec.ts @@ -25,8 +25,18 @@ import { waitForWebView, waitForWindowVisible, } from '../helpers/element-helpers'; -import { completeOnboardingIfVisible, navigateToSkills } from '../helpers/shared-flows'; -import { clearRequestLog, setMockBehavior, startMockServer, stopMockServer } from '../mock-server'; +import { + completeOnboardingIfVisible, + navigateToSkills, + waitForRequest, +} from '../helpers/shared-flows'; +import { + clearRequestLog, + getRequestLog, + setMockBehavior, + startMockServer, + stopMockServer, +} from '../mock-server'; const LOG = '[ComposioTriggersE2E]'; @@ -82,6 +92,30 @@ describe('Composio trigger toggles (UI + core RPC)', () => { expect(slugs).toContain('SLACK_NEW_MESSAGE'); }); + it('authorize sends Gmail read scope before Gmail trigger setup', async () => { + clearRequestLog(); + + const out = await callOpenhumanRpc('openhuman.composio_authorize', { toolkit: 'gmail' }); + expect(out.ok).toBe(true); + + const authorizeReq = await waitForRequest( + getRequestLog, + 'POST', + '/agent-integrations/composio/authorize', + 10_000 + ); + if (!authorizeReq) { + throw new Error( + `Missing /agent-integrations/composio/authorize request.\n` + + `Request log:\n${JSON.stringify(getRequestLog(), null, 2)}` + ); + } + + const body = JSON.parse(authorizeReq?.body || '{}'); + expect(body.toolkit).toBe('gmail'); + expect(body.oauth_scopes).toContain('https://www.googleapis.com/auth/gmail.readonly'); + }); + it('list_triggers starts empty for the seeded user', async () => { const out = await callOpenhumanRpc('openhuman.composio_list_triggers', {}); expect(out.ok).toBe(true); diff --git a/docs/TEST-COVERAGE-MATRIX.md b/docs/TEST-COVERAGE-MATRIX.md index 90b85ae87..43bb6cd33 100644 --- a/docs/TEST-COVERAGE-MATRIX.md +++ b/docs/TEST-COVERAGE-MATRIX.md @@ -339,7 +339,7 @@ Canonical mapping of every product feature to its test source(s). Drives gap-fil | ID | Feature | Layer | Test path(s) | Status | Notes | | ------ | ------------------------------------- | ----- | --------------------------------------------------------- | ------ | --------------------------------- | | 10.2.1 | OAuth / API Token Handling | WD | `skill-oauth.spec.ts` | ✅ | | -| 10.2.2 | Scope Selection (Read/Write/Initiate) | WD | `gmail-flow.spec.ts`, `skill-oauth.spec.ts` | 🟡 | Multi-scope matrix not exhaustive | +| 10.2.2 | Scope Selection (Read/Write/Initiate) | WD | `gmail-flow.spec.ts`, `skill-oauth.spec.ts`, `composio-triggers-flow.spec.ts` | 🟡 | Multi-scope matrix not exhaustive; Gmail trigger OAuth read scope covered | | 10.2.3 | Token Storage & Encryption | RU | `src/openhuman/encryption/`, `src/openhuman/credentials/` | ✅ | | ### 10.3 Message Sync & Ingestion diff --git a/gitbooks/features/integrations/triggers.md b/gitbooks/features/integrations/triggers.md index 47143ed96..212d3c987 100644 --- a/gitbooks/features/integrations/triggers.md +++ b/gitbooks/features/integrations/triggers.md @@ -27,6 +27,12 @@ A trigger is an external event published by an integration you've connected. Com The full set comes from the [Composio](https://composio.dev) connector layer that powers [third-party integrations](README.md). When a connection is active, the relevant trigger subscriptions are wired up automatically. +### Gmail OAuth scopes + +Gmail trigger subscriptions require message-read access on the connected Google account. Fresh OpenHuman Gmail authorizations request `https://www.googleapis.com/auth/gmail.readonly` so `GMAIL_NEW_GMAIL_MESSAGE` can be enabled and the native Gmail sync path can read the new message metadata. + +If an older Gmail connection was created before this scope was requested, reconnect Gmail from Settings before enabling Gmail triggers. + ## Where triggers come from, end to end ``` diff --git a/scripts/mock-api/routes/integrations.mjs b/scripts/mock-api/routes/integrations.mjs index 985649f80..e58b0b9b7 100644 --- a/scripts/mock-api/routes/integrations.mjs +++ b/scripts/mock-api/routes/integrations.mjs @@ -165,6 +165,29 @@ export function handleIntegrations(ctx) { return true; } + if ( + method === "POST" && + /^\/agent-integrations\/composio\/authorize\/?$/.test(url) + ) { + const toolkit = + typeof parsedBody?.toolkit === "string" ? parsedBody.toolkit.trim() : ""; + if (!toolkit) { + json(res, 400, { + success: false, + error: "Missing required field: toolkit", + }); + return true; + } + json(res, 200, { + success: true, + data: { + connectUrl: `https://composio.example/${toolkit}/consent`, + connectionId: `conn-${toolkit}-pending`, + }, + }); + return true; + } + if ( method === "GET" && /^\/agent-integrations\/composio\/triggers\/available(\?.*)?$/.test(url) diff --git a/src/openhuman/composio/client.rs b/src/openhuman/composio/client.rs index 597c2d571..27d355067 100644 --- a/src/openhuman/composio/client.rs +++ b/src/openhuman/composio/client.rs @@ -13,7 +13,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::Result; -use serde_json::json; +use serde_json::{json, Value}; use crate::openhuman::integrations::IntegrationClient; @@ -30,6 +30,8 @@ const POST_OAUTH_ACTION_RETRY_DELAY: Duration = Duration::from_secs(10); /// trailing punctuation or wrapper text from the gateway does not silently /// disable the retry. const POST_OAUTH_AUTH_ERROR_STRINGS: &[&str] = &["connection error, try to authenticate"]; +const AUTHORIZE_OAUTH_SCOPES_FIELD: &str = "oauth_scopes"; +const GMAIL_REQUIRED_OAUTH_SCOPES: &[&str] = &["https://www.googleapis.com/auth/gmail.readonly"]; /// High-level client for all backend-proxied Composio operations. #[derive(Clone)] @@ -106,6 +108,7 @@ impl ComposioClient { obj.insert(k.clone(), v.clone()); } } + merge_required_oauth_scopes(&mut body, toolkit)?; self.inner .post::("/agent-integrations/composio/authorize", &body) .await @@ -542,6 +545,75 @@ fn is_post_oauth_auth_readiness_error(resp: &ComposioExecuteResponse) -> bool { .any(|needle| normalized.contains(needle)) } +fn required_oauth_scopes_for_toolkit(toolkit: &str) -> &'static [&'static str] { + match toolkit.trim().to_ascii_lowercase().as_str() { + // GMAIL_NEW_GMAIL_MESSAGE and the native Gmail sync path need read access + // to messages. Without this hint fresh OAuth handoffs can complete with a + // profile-only Google token and trigger enable fails with 403 insufficient + // authentication scopes (#2186). + "gmail" => GMAIL_REQUIRED_OAUTH_SCOPES, + _ => &[], + } +} + +fn merge_required_oauth_scopes(body: &mut Value, toolkit: &str) -> anyhow::Result<()> { + let required = required_oauth_scopes_for_toolkit(toolkit); + if required.is_empty() { + return Ok(()); + } + + let obj = body + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("composio.authorize: internal payload must be an object"))?; + match obj.get_mut(AUTHORIZE_OAUTH_SCOPES_FIELD) { + Some(existing) => append_missing_oauth_scopes(existing, required)?, + None => { + obj.insert(AUTHORIZE_OAUTH_SCOPES_FIELD.to_string(), json!(required)); + } + } + Ok(()) +} + +fn append_missing_oauth_scopes(value: &mut Value, required: &[&str]) -> anyhow::Result<()> { + let mut scopes = match value { + Value::Null => Vec::new(), + Value::String(raw) => raw + .split(|ch: char| ch == ',' || ch.is_whitespace()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToString::to_string) + .collect(), + Value::Array(items) => { + let mut out = Vec::with_capacity(items.len() + required.len()); + for item in items { + let Some(scope) = item.as_str() else { + anyhow::bail!( + "composio.authorize: {AUTHORIZE_OAUTH_SCOPES_FIELD} entries must be strings" + ); + }; + let scope = scope.trim(); + if !scope.is_empty() { + out.push(scope.to_string()); + } + } + out + } + _ => { + anyhow::bail!( + "composio.authorize: {AUTHORIZE_OAUTH_SCOPES_FIELD} must be a string or array" + ); + } + }; + + for scope in required { + if !scopes.iter().any(|existing| existing == scope) { + scopes.push((*scope).to_string()); + } + } + *value = json!(scopes); + Ok(()) +} + /// Backend-mode [`ComposioClient`] constructor. **Internal to the /// composio module** — external callers should use /// [`create_composio_client`] (factory) or diff --git a/src/openhuman/composio/client_tests.rs b/src/openhuman/composio/client_tests.rs index 12b644e58..0c52da5e2 100644 --- a/src/openhuman/composio/client_tests.rs +++ b/src/openhuman/composio/client_tests.rs @@ -213,25 +213,66 @@ async fn list_connections_parses_connection_array() { #[tokio::test] async fn authorize_posts_toolkit_and_returns_connect_url() { + let app = + Router::new().route( + "/agent-integrations/composio/authorize", + post(|Json(body): Json| async move { + // Echo toolkit back so we know our POST body made it. + let tk = body["toolkit"].as_str().unwrap_or("").to_string(); + let scopes = body["oauth_scopes"] + .as_array() + .expect("gmail authorize should include oauth_scopes"); + assert!(scopes.iter().any(|scope| scope.as_str() + == Some("https://www.googleapis.com/auth/gmail.readonly"))); + Json(json!({ + "success": true, + "data": { + "connectUrl": format!("https://composio.example/{tk}/consent"), + "connectionId": "conn-abc" + } + })) + }), + ); + let base = start_mock_backend(app).await; + let client = build_client_for(base); + let resp = client.authorize("gmail", None).await.unwrap(); + assert!(resp.connect_url.contains("gmail")); + assert_eq!(resp.connection_id, "conn-abc"); +} + +#[tokio::test] +async fn authorize_merges_gmail_required_oauth_scopes_with_extra_params() { let app = Router::new().route( "/agent-integrations/composio/authorize", post(|Json(body): Json| async move { - // Echo toolkit back so we know our POST body made it. - let tk = body["toolkit"].as_str().unwrap_or("").to_string(); + assert_eq!(body["toolkit"].as_str(), Some("gmail")); + assert_eq!(body["prompt"].as_str(), Some("consent")); + let scopes: Vec<&str> = body["oauth_scopes"] + .as_array() + .expect("oauth_scopes should be an array") + .iter() + .map(|item| item.as_str().expect("scope should be a string")) + .collect(); + assert!(scopes.contains(&"openid")); + assert!(scopes.contains(&"https://www.googleapis.com/auth/gmail.readonly")); Json(json!({ "success": true, "data": { - "connectUrl": format!("https://composio.example/{tk}/consent"), - "connectionId": "conn-abc" + "connectUrl": "https://composio.example/gmail/consent", + "connectionId": "conn-gmail" } })) }), ); let base = start_mock_backend(app).await; let client = build_client_for(base); - let resp = client.authorize("gmail", None).await.unwrap(); + let extra = serde_json::json!({ + "prompt": "consent", + "oauth_scopes": ["openid"] + }); + let resp = client.authorize("gmail", Some(extra)).await.unwrap(); assert!(resp.connect_url.contains("gmail")); - assert_eq!(resp.connection_id, "conn-abc"); + assert_eq!(resp.connection_id, "conn-gmail"); } #[tokio::test] @@ -242,6 +283,7 @@ async fn authorize_forwards_extra_params_and_returns_connect_url() { // Assert extra_params are forwarded alongside toolkit. assert_eq!(body["toolkit"].as_str(), Some("whatsapp")); assert_eq!(body["waba_id"].as_str(), Some("waba-123")); + assert!(body.get("oauth_scopes").is_none()); Json(json!({ "success": true, "data": { diff --git a/src/openhuman/composio/schemas.rs b/src/openhuman/composio/schemas.rs index 4f4ab9b7a..c77953ffc 100644 --- a/src/openhuman/composio/schemas.rs +++ b/src/openhuman/composio/schemas.rs @@ -233,7 +233,9 @@ pub fn schemas(function: &str) -> ControllerSchema { ty: TypeSchema::Json, comment: "Optional JSON object of additional auth fields forwarded to Composio \ (e.g. {\"waba_id\": \"...\") for toolkits that require them). \ - Reserved keys (toolkit, toolkit_version, auth, client_id) are rejected.", + The core may also add toolkit-specific OAuth scope hints such as \ + Gmail's oauth_scopes value. Reserved keys (toolkit, toolkit_version, \ + auth, client_id) are rejected.", required: false, }, ],