diff --git a/Cargo.lock b/Cargo.lock
index a74f3d683..08914917b 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -1134,7 +1134,7 @@ dependencies = [
"core-foundation-sys 0.8.7",
"coreaudio-rs",
"dasp_sample",
- "jni",
+ "jni 0.21.1",
"js-sys",
"libc",
"mach2",
@@ -2409,6 +2409,34 @@ version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "62adaabb884c94955b19907d60019f4e145d091c75345379e70d1ee696f7854f"
+[[package]]
+name = "html2md"
+version = "0.2.15"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8cff9891f2e0d9048927fbdfc28b11bf378f6a93c7ba70b23d0fbee9af6071b4"
+dependencies = [
+ "html5ever 0.27.0",
+ "jni 0.19.0",
+ "lazy_static",
+ "markup5ever_rcdom",
+ "percent-encoding",
+ "regex",
+]
+
+[[package]]
+name = "html5ever"
+version = "0.27.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c13771afe0e6e846f1e67d038d4cb29998a6779f93c809212e4e9c32efd244d4"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever 0.12.1",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.117",
+]
+
[[package]]
name = "html5ever"
version = "0.35.0"
@@ -2416,7 +2444,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
dependencies = [
"log",
- "markup5ever",
+ "markup5ever 0.35.0",
"match_token",
]
@@ -2960,6 +2988,20 @@ dependencies = [
"syn 2.0.117",
]
+[[package]]
+name = "jni"
+version = "0.19.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c6df18c2e3db7e453d3c6ac5b3e9d5182664d28788126d39b91f2d1e22b017ec"
+dependencies = [
+ "cesu8",
+ "combine",
+ "jni-sys 0.3.1",
+ "log",
+ "thiserror 1.0.69",
+ "walkdir",
+]
+
[[package]]
name = "jni"
version = "0.21.1"
@@ -3318,6 +3360,20 @@ version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
+[[package]]
+name = "markup5ever"
+version = "0.12.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16ce3abbeba692c8b8441d036ef91aea6df8da2c6b6e21c7e14d3c18e526be45"
+dependencies = [
+ "log",
+ "phf 0.11.3",
+ "phf_codegen 0.11.3",
+ "string_cache",
+ "string_cache_codegen",
+ "tendril",
+]
+
[[package]]
name = "markup5ever"
version = "0.35.0"
@@ -3329,6 +3385,18 @@ dependencies = [
"web_atoms",
]
+[[package]]
+name = "markup5ever_rcdom"
+version = "0.3.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "edaa21ab3701bfee5099ade5f7e1f84553fd19228cf332f13cd6e964bf59be18"
+dependencies = [
+ "html5ever 0.27.0",
+ "markup5ever 0.12.1",
+ "tendril",
+ "xml5ever",
+]
+
[[package]]
name = "match_token"
version = "0.35.0"
@@ -4270,7 +4338,7 @@ version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e8b61bebd49e5d43f5f8cc7ee2891c16e0f41ec7954d36bcb6c14c5e0de867fb"
dependencies = [
- "jni",
+ "jni 0.21.1",
"ndk",
"ndk-context",
"num-derive",
@@ -4339,6 +4407,7 @@ dependencies = [
"hmac 0.12.1",
"hostname",
"hound",
+ "html2md",
"image",
"landlock",
"lettre",
@@ -5631,7 +5700,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a6dcd6e9823e177d15460d3cd3a413f38a2beea381f26aca1001c05cd6954ff"
dependencies = [
"as_variant",
- "html5ever",
+ "html5ever 0.35.0",
"tracing",
"wildmatch",
]
@@ -5773,7 +5842,7 @@ checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784"
dependencies = [
"core-foundation 0.10.1",
"core-foundation-sys 0.8.7",
- "jni",
+ "jni 0.21.1",
"log",
"once_cell",
"rustls",
@@ -8488,6 +8557,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56"
+[[package]]
+name = "xml5ever"
+version = "0.18.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9bbb26405d8e919bc1547a5aa9abc95cbfa438f04844f5fdd9dc7596b748bf69"
+dependencies = [
+ "log",
+ "mac",
+ "markup5ever 0.12.1",
+]
+
[[package]]
name = "xxhash-rust"
version = "0.8.15"
diff --git a/Cargo.toml b/Cargo.toml
index 9a319398e..b8f5463b0 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,6 +16,13 @@ crate-type = ["rlib"]
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
+# Used by composio post-processing to convert Gmail HTML bodies to
+# markdown. `html2md` is the canonical pure-Rust crate (no system deps);
+# `htmd` (>0.5) is more actively maintained but our usage is one
+# function call (`parse_html`) and the migration cost outweighs the
+# upside today. Re-evaluate if html2md goes unmaintained or we need
+# GFM/table fidelity beyond what html2md emits.
+html2md = "0.2"
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream", "http2", "multipart", "socks"] }
tokio = { version = "1", features = ["full", "sync"] }
once_cell = "1.19"
diff --git a/app/src/components/composio/ComposioConnectModal.tsx b/app/src/components/composio/ComposioConnectModal.tsx
index b8c229385..4a2edd2cd 100644
--- a/app/src/components/composio/ComposioConnectModal.tsx
+++ b/app/src/components/composio/ComposioConnectModal.tsx
@@ -16,8 +16,18 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
-import { authorize, deleteConnection, listConnections } from '../../lib/composio/composioApi';
-import { type ComposioConnection, deriveComposioState } from '../../lib/composio/types';
+import {
+ authorize,
+ deleteConnection,
+ getUserScopes,
+ listConnections,
+ setUserScopes,
+} from '../../lib/composio/composioApi';
+import {
+ type ComposioConnection,
+ type ComposioUserScopePref,
+ deriveComposioState,
+} from '../../lib/composio/types';
import { openUrl } from '../../utils/openUrl';
import type { ComposioToolkitMeta } from './toolkitMeta';
@@ -58,6 +68,16 @@ export default function ComposioConnectModal({
connection
);
+ // ── Scope preferences (read/write/admin) ────────────────────────
+ // The pref gates which curated Composio actions the agent may call.
+ // We load it lazily once the toolkit is connected, so the toggles in
+ // the success view always reflect what the core actually has stored.
+ const [scopes, setScopes] = useState(null);
+ const [scopeError, setScopeError] = useState(null);
+ // Per-key in-flight flag so spamming a single toggle disables only
+ // that row while the RPC round-trips.
+ const [savingScope, setSavingScope] = useState(null);
+
// Escape to close
useEffect(() => {
const handleEscape = (e: KeyboardEvent) => {
@@ -175,6 +195,78 @@ export default function ComposioConnectModal({
}
}, [startPolling, toolkit.slug]);
+ // Fetch the stored scope pref whenever the modal lands in the
+ // 'connected' phase. Re-fetching each time we transition (rather
+ // than once on mount) keeps the toggles correct after a fresh OAuth
+ // handoff completes inside this modal.
+ useEffect(() => {
+ if (phase !== 'connected') return;
+ let cancelled = false;
+ void (async () => {
+ try {
+ const pref = await getUserScopes(toolkit.slug);
+ if (!cancelled) setScopes(pref);
+ } catch (err) {
+ if (!cancelled) {
+ const msg = err instanceof Error ? err.message : String(err);
+ setScopeError(`Couldn't load scope preferences: ${msg}`);
+ }
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [phase, toolkit.slug]);
+
+ const handleToggleScope = useCallback(
+ async (key: keyof ComposioUserScopePref) => {
+ if (!scopes || savingScope) {
+ console.debug(
+ '[composio][scopes] toggle ignored toolkit=%s key=%s reason=%s',
+ toolkit.slug,
+ key,
+ !scopes ? 'pref-not-loaded' : 'another-save-in-flight'
+ );
+ return;
+ }
+ const optimistic: ComposioUserScopePref = { ...scopes, [key]: !scopes[key] };
+ console.debug(
+ '[composio][scopes] toggle toolkit=%s key=%s old=%s new=%s',
+ toolkit.slug,
+ key,
+ scopes[key],
+ optimistic[key]
+ );
+ setScopes(optimistic);
+ setSavingScope(key);
+ setScopeError(null);
+ try {
+ const persisted = await setUserScopes(toolkit.slug, optimistic);
+ console.debug(
+ '[composio][scopes] toggle persisted toolkit=%s key=%s pref=%o',
+ toolkit.slug,
+ key,
+ persisted
+ );
+ setScopes(persisted);
+ } catch (err) {
+ // Roll back on failure so the toggle reflects reality.
+ const msg = err instanceof Error ? err.message : String(err);
+ console.error(
+ '[composio][scopes] toggle failed toolkit=%s key=%s error=%o',
+ toolkit.slug,
+ key,
+ err
+ );
+ setScopes(scopes);
+ setScopeError(`Couldn't save ${key} scope: ${msg}`);
+ } finally {
+ setSavingScope(null);
+ }
+ },
+ [savingScope, scopes, toolkit.slug]
+ );
+
const handleDisconnect = useCallback(async () => {
if (!activeConnection) return;
setPhase('disconnecting');
@@ -299,6 +391,12 @@ export default function ComposioConnectModal({
id: {activeConnection.id}
)}
+