mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
fix(composio): request Gmail read scope for triggers (#2191)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user