fix(composio): request Gmail read scope for triggers (#2191)

This commit is contained in:
Aqil Aziz
2026-05-19 20:02:23 -07:00
committed by GitHub
parent ad61b4cec7
commit b0fe42b0af
7 changed files with 190 additions and 11 deletions
@@ -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);
+1 -1
View File
@@ -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
@@ -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
```
+23
View File
@@ -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)
+73 -1
View File
@@ -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::<ComposioAuthorizeResponse>("/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
+48 -6
View File
@@ -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<Value>| 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<Value>| 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": {
+3 -1
View File
@@ -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,
},
],