From 8b2084afaab67500e89561d1431ec2b3c12b1464 Mon Sep 17 00:00:00 2001 From: Tomasz Iniewicz Date: Tue, 23 Jun 2026 10:02:45 -0400 Subject: [PATCH 1/8] feat(agent-email): flag-gated mailbox-connector routes for the playground MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add /v1/email/connectors + /{provider}/configure + /{provider}/complete that reuse GAIA's connector framework (gaia.connectors) — the same OAuth flow the Agent UI uses. Mounted only in playground mode (--playground or GAIA_EMAIL_PLAYGROUND=1); a default/production sidecar stays connector-free, keeping milestone 40's 'consuming app owns the connection' boundary intact. OAuth completes inside gaia.connectors.flow (its own loopback listener), so the sidecar hosts no callback route. On completion the mailbox is granted to installed:email so /v1/email/send can resolve it. --- .../gaia_agent_email/connector_routes.py | 145 ++++++++++++++++++ hub/agents/python/email/packaging/server.py | 34 +++- 2 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 hub/agents/python/email/gaia_agent_email/connector_routes.py diff --git a/hub/agents/python/email/gaia_agent_email/connector_routes.py b/hub/agents/python/email/gaia_agent_email/connector_routes.py new file mode 100644 index 000000000..34b3db967 --- /dev/null +++ b/hub/agents/python/email/gaia_agent_email/connector_routes.py @@ -0,0 +1,145 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Flag-gated mailbox-connector routes for the email playground. + +Mounted on the sidecar ONLY in playground mode (``--playground`` / +``GAIA_EMAIL_PLAYGROUND=1``). Reuses GAIA's connector framework +(``gaia.connectors``) — the same OAuth flow the Agent UI uses — so a developer +evaluating the agent can connect a Gmail/Outlook mailbox from the playground and +exercise live ``/v1/email/send``. + +Deliberately NOT part of the default sidecar surface: milestone 40 makes the +consuming application the owner of the mailbox connection, so a production +sidecar stays connector-free. ``gaia.connectors`` is already linked into the +binary (the send path resolves the mailbox through it), so these routes add a +surface, not a dependency. + +OAuth completes entirely inside ``gaia.connectors.flow`` — it stands up its own +loopback redirect listener and opens the browser — so this module hosts no +callback route. It only starts the flow and waits for it. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel, Field + +log = logging.getLogger("gaia_agent_email.connectors") + +# Connections are granted to the email agent's namespaced id so the send path +# (which resolves the mailbox under this agent) can use them. Mirrors +# ``gaia-agent.yaml`` (``id: email``) → ``installed:email``. +EMAIL_AGENT_ID = "installed:email" +SUPPORTED_PROVIDERS = ("google", "microsoft") + +router = APIRouter(prefix="/v1/email", tags=["email-connectors"]) + + +class ConfigureRequest(BaseModel): + client_id: str = Field( + ..., min_length=1, description="OAuth client id (user-supplied)." + ) + client_secret: str = Field( + default="", + description="OAuth client secret (Google requires it even for PKCE).", + ) + scopes: Optional[List[str]] = Field( + default=None, description="Override the provider's default scopes." + ) + + +class CompleteRequest(BaseModel): + flow_id: str = Field(..., min_length=1, description="flow_id returned by /configure.") + + +def _require_supported(provider: str) -> None: + if provider not in SUPPORTED_PROVIDERS: + raise HTTPException( + status_code=404, + detail=f"unknown provider {provider!r}; supported: {', '.join(SUPPORTED_PROVIDERS)}", + ) + + +@router.get("/connectors") +async def list_email_connectors() -> Dict[str, Any]: + """Status of the mailbox connectors the email agent can send from.""" + from gaia.connectors.api import connected_mailbox_providers, get_connection + + connected = set(connected_mailbox_providers()) + providers: List[Dict[str, Any]] = [] + for pid in SUPPORTED_PROVIDERS: + conn = get_connection(pid) + providers.append( + { + "provider": pid, + "connected": pid in connected, + "account_email": (conn or {}).get("account_email"), + "scopes": (conn or {}).get("scopes", []), + } + ) + return {"agent_id": EMAIL_AGENT_ID, "providers": providers} + + +@router.post("/connectors/{provider}/configure") +async def configure_email_connector( + provider: str, body: ConfigureRequest +) -> Dict[str, Any]: + """Persist the user's OAuth client creds and start the PKCE flow. + + Returns ``{flow_id, authorization_url}``. The connector framework opens the + browser and stands up its own loopback callback; call ``/complete`` next. + """ + _require_supported(provider) + from gaia.connectors.handler import configure + + config: Dict[str, Any] = { + "client_id": body.client_id, + "client_secret": body.client_secret, + } + if body.scopes: + config["scopes"] = body.scopes + try: + return await configure(provider, config) + except Exception as e: # surface the framework's actionable error to the page + raise HTTPException(status_code=400, detail=f"configure {provider}: {e}") from e + + +@router.post("/connectors/{provider}/complete") +async def complete_email_connector( + provider: str, body: CompleteRequest +) -> Dict[str, Any]: + """Wait for the OAuth redirect, then grant the mailbox to the email agent.""" + _require_supported(provider) + from gaia.connectors.api import complete_authorization, grant_agent + + try: + state = await complete_authorization(body.flow_id) + except Exception as e: + raise HTTPException(status_code=400, detail=f"OAuth completion: {e}") from e + # Without this grant the connection exists but the email agent can't use it + # at send time (token access is scoped per granted agent). + scopes = list(state.get("scopes") or []) + try: + grant_agent(provider, EMAIL_AGENT_ID, scopes) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"connected {provider} but granting to {EMAIL_AGENT_ID} failed: {e}", + ) from e + return {"connected": True, **state} + + +@router.delete("/connectors/{provider}") +async def disconnect_email_connector(provider: str) -> Dict[str, Any]: + """Remove the stored connection for ``provider``.""" + _require_supported(provider) + from gaia.connectors.api import revoke_connection + + revoke_connection(provider) + return {"provider": provider, "connected": False} + + +__all__ = ["router", "EMAIL_AGENT_ID", "SUPPORTED_PROVIDERS"] diff --git a/hub/agents/python/email/packaging/server.py b/hub/agents/python/email/packaging/server.py index a6ed1cf22..a7ec8e838 100644 --- a/hub/agents/python/email/packaging/server.py +++ b/hub/agents/python/email/packaging/server.py @@ -29,6 +29,7 @@ import argparse import json import logging +import os import sys logging.basicConfig( @@ -42,8 +43,14 @@ DEFAULT_HOST = "127.0.0.1" -def build_app(): - """Build the minimal FastAPI app hosting the email REST surface.""" +def build_app(with_connectors: bool = False): + """Build the minimal FastAPI app hosting the email REST surface. + + ``with_connectors`` (playground mode) also mounts the flag-gated mailbox- + connector routes so the playground can connect Gmail/Outlook and exercise + live send. Off by default — a production sidecar stays connector-free (the + consuming application owns the mailbox connection, milestone 40). + """ from fastapi import FastAPI from gaia_agent_email import __version__ as agent_version from gaia_agent_email.api_routes import router as email_router @@ -66,6 +73,15 @@ async def version() -> dict: return {"apiVersion": SCHEMA_VERSION, "agentVersion": agent_version} app.include_router(email_router) + if with_connectors: + # Playground mode only — reuse GAIA's connector framework (already + # linked in via the send path) so the page can connect a mailbox. + from gaia_agent_email.connector_routes import router as connector_router + + app.include_router(connector_router) + log.info( + "playground mode: mounted mailbox-connector routes at /v1/email/connectors" + ) return app @@ -80,9 +96,21 @@ def main(argv=None) -> int: action="store_true", help="Print the OpenAPI JSON to stdout and exit (no server).", ) + parser.add_argument( + "--playground", + action="store_true", + help=( + "Mount the playground's mailbox-connector routes (/v1/email/connectors). " + "Also enabled by GAIA_EMAIL_PLAYGROUND=1. Off by default — production " + "sidecars stay connector-free." + ), + ) args = parser.parse_args(argv) - app = build_app() + with_connectors = args.playground or os.environ.get( + "GAIA_EMAIL_PLAYGROUND", "" + ).strip().lower() in ("1", "true", "yes", "on") + app = build_app(with_connectors=with_connectors) if args.print_openapi: print(json.dumps(app.openapi())) From d99cb229a4ef1c4a43b01b4bedf077f7165f50ed Mon Sep 17 00:00:00 2001 From: Tomasz Iniewicz Date: Tue, 23 Jun 2026 10:08:48 -0400 Subject: [PATCH 2/8] feat(agent-email): add Connectors panel + live Send to the playground The playground page gains a collapsible Connectors section at the top that drives the new /v1/email/connectors routes: enter your own Google/Microsoft OAuth client creds, connect, and the Send panel goes live. On a plain (non-playground) sidecar the routes 404 and the panel degrades to an explainer ('--playground' / GAIA_EMAIL_PLAYGROUND=1) rather than breaking. All DOM writes stay textContent-only; fetches remain same-origin. Tests: connector-route delegation + grant-to-installed:email, flag gating (mounted only in playground mode), and the page's graceful degradation. --- .../email/gaia_agent_email/playground_html.py | 160 +++++++++++++++++- .../agents/email/test_connector_routes.py | 126 ++++++++++++++ tests/unit/agents/email/test_playground.py | 12 ++ 3 files changed, 292 insertions(+), 6 deletions(-) create mode 100644 tests/unit/agents/email/test_connector_routes.py diff --git a/hub/agents/python/email/gaia_agent_email/playground_html.py b/hub/agents/python/email/gaia_agent_email/playground_html.py index 55588e110..71483df78 100644 --- a/hub/agents/python/email/gaia_agent_email/playground_html.py +++ b/hub/agents/python/email/gaia_agent_email/playground_html.py @@ -126,6 +126,27 @@
Served by your local sidecar — code, data, and inference never leave this machine.
+ +
+ Connectors + connect Gmail / Outlook + checking… +
+ + +
+
+
Stack health @@ -180,16 +201,28 @@
- +
Send a reply - connector required + needs a connected mailbox +
-
+
-
Connector required
-
Sending touches a live mailbox, so it needs a connected Gmail/Outlook account (OAuth). - Not available in this local playground yet.
+
Connect a mailbox first
+
Sending touches a live mailbox — connect Gmail or Outlook in the Connectors section + above. (Triage and draft don't need a connection.)
+
+
@@ -477,12 +510,127 @@ btn.disabled = false; } } +// ---- Connectors (playground mode) ---------------------------------------- +// /v1/email/connectors is mounted only when the sidecar runs with --playground +// (or GAIA_EMAIL_PLAYGROUND=1). A 404 means a plain/production sidecar — we +// degrade to an explainer rather than break. The OAuth itself runs inside the +// connector framework (its own loopback callback), so the page only kicks it +// off and waits. +const CONN_PROVIDERS = [ + { id:"google", label:"Google · Gmail" }, + { id:"microsoft", label:"Microsoft · Outlook" }, +]; +function setConnStat(text, ok){ + const s = $("conn-stat"); if(!s) return; + s.textContent = text; + s.style.background = ok ? "var(--soft)" : "rgba(232,122,122,.14)"; + s.style.borderColor = ok ? "var(--line)" : "rgba(232,122,122,.4)"; + s.style.color = ok ? "var(--gold)" : "var(--bad)"; +} +function providerBlock(p){ + const wrap = document.createElement("div"); wrap.className = "row"; + wrap.style.flexDirection = "column"; wrap.style.alignItems = "stretch"; + const head = document.createElement("div"); + head.style.display = "flex"; head.style.alignItems = "center"; head.style.gap = "10px"; + const dot = document.createElement("div"); + dot.className = "dot " + (p.connected ? "ok" : "wait"); dot.textContent = p.connected ? "✓" : "…"; + const name = document.createElement("div"); name.className = "name"; name.textContent = p.label || p.provider; + const status = document.createElement("span"); status.className = "note"; status.style.marginLeft = "auto"; + status.textContent = p.connected ? ("connected · " + (p.account_email || "")) : "not connected"; + head.appendChild(dot); head.appendChild(name); head.appendChild(status); + wrap.appendChild(head); + if(!p.connected){ + const grid = document.createElement("div"); grid.className = "grid2"; grid.style.marginTop = "8px"; + function field(labelText, id, type){ + const d = document.createElement("div"); + const l = document.createElement("label"); l.textContent = labelText; + const inp = document.createElement("input"); inp.id = id; if(type) inp.type = type; + d.appendChild(l); d.appendChild(inp); return d; + } + grid.appendChild(field("Client ID", "ci-" + p.provider)); + grid.appendChild(field("Client secret", "cs-" + p.provider, "password")); + wrap.appendChild(grid); + const act = document.createElement("div"); act.className = "actions"; + const btn = document.createElement("button"); btn.textContent = "Save & Connect"; + btn.onclick = () => connectProvider(p.provider, btn); + act.appendChild(btn); wrap.appendChild(act); + const out = document.createElement("div"); out.className = "out"; out.id = "cout-" + p.provider; + wrap.appendChild(out); + } + return wrap; +} +function renderConnectors(providers){ + const host = $("conn-providers"); host.textContent = ""; + for(const p of providers) host.appendChild(providerBlock(p)); +} +async function loadConnectors(){ + let data; + try{ data = await getJSON("/v1/email/connectors"); } + catch(e){ + if(e.status === 404){ + $("conn-unavailable").style.display = "flex"; $("conn-live").style.display = "none"; + setConnStat("playground only", false); updateSendGate([]); return; + } + setConnStat("error", false); return; + } + $("conn-unavailable").style.display = "none"; $("conn-live").style.display = "block"; + const byId = {}; for(const p of (data.providers || [])) byId[p.provider] = p; + const merged = CONN_PROVIDERS.map((s) => + Object.assign({ provider:s.id, label:s.label, connected:false }, byId[s.id] || {})); + renderConnectors(merged); + const connected = merged.filter((p) => p.connected).map((p) => p.provider); + setConnStat(connected.length ? (connected.length + " connected") : "none connected", connected.length > 0); + updateSendGate(connected); +} +async function connectProvider(provider, btn){ + const out = $("cout-" + provider); out.className = "out show"; + const cid = ($("ci-" + provider).value || "").trim(); + const csec = ($("cs-" + provider).value || "").trim(); + if(!cid){ out.textContent = "✗ client_id is required"; return; } + btn.disabled = true; out.textContent = "Starting OAuth…"; + let res; + try{ res = await postJSON("/v1/email/connectors/" + provider + "/configure", { client_id:cid, client_secret:csec }); } + catch(e){ out.textContent = "✗ " + (e.body || e.message); btn.disabled = false; return; } + out.textContent = ""; + const a = document.createElement("div"); a.textContent = "A browser window should have opened for consent. If not, "; + const link = document.createElement("a"); + link.href = res.authorization_url; link.target = "_blank"; link.rel = "noopener"; link.textContent = "open it here"; + a.appendChild(link); out.appendChild(a); + const w = document.createElement("div"); w.className = "note"; + w.textContent = "Waiting for you to finish authorizing (up to 2 min)…"; out.appendChild(w); + try{ + const st = await postJSON("/v1/email/connectors/" + provider + "/complete", { flow_id: res.flow_id }); + out.textContent = "✓ connected: " + (st.account_email || provider); + loadConnectors(); + }catch(e){ out.textContent = "✗ " + (e.body || e.message); btn.disabled = false; } +} +// ---- Send (live once a mailbox is connected) ----------------------------- +function updateSendGate(connected){ + const has = connected.length > 0; + if($("send-gate")) $("send-gate").style.display = has ? "none" : "flex"; + if($("send-form")) $("send-form").style.display = has ? "block" : "none"; + if($("send-stat")) $("send-stat").style.display = has ? "inline-block" : "none"; + if(has && $("send-note")) $("send-note").textContent = "from " + connected.join(", "); +} +async function doSend(){ + const out = $("send-out"); out.className = "out show"; out.textContent = "Drafting…"; + const to = [{ email: $("send-to").value }]; + const subject = $("send-subject").value, body = $("send-body").value; + try{ + const d = await postJSON("/v1/email/draft", { to, subject, body }); + out.textContent = "Sending…"; + const s = await postJSON("/v1/email/send", { to, subject, body, confirmation_token: d.confirmation_token }); + out.textContent = "✓ sent · id=" + (s.sent_id || "(ok)"); + }catch(e){ out.textContent = "✗ HTTP " + e.status + ": " + (e.body || e.message); } +} $("recheck").onclick = healthCheck; $("do-init").onclick = doInit; $("do-provision").onclick = doProvision; $("do-triage").onclick = doTriage; $("do-draft").onclick = doDraft; +$("do-send").onclick = doSend; healthCheck(); +loadConnectors(); diff --git a/tests/unit/agents/email/test_connector_routes.py b/tests/unit/agents/email/test_connector_routes.py new file mode 100644 index 000000000..5702b354c --- /dev/null +++ b/tests/unit/agents/email/test_connector_routes.py @@ -0,0 +1,126 @@ +# Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. +# SPDX-License-Identifier: MIT +"""Tests for the flag-gated mailbox-connector routes (email playground). + +Two guarantees: + * the routes are mounted ONLY in playground mode (so a production sidecar + stays connector-free — milestone 40's "consuming app owns the connection"); + * each route delegates correctly to GAIA's connector framework, including the + grant to ``installed:email`` that makes a fresh connection usable by send. +""" + +from __future__ import annotations + +import importlib.util +import pathlib + +import pytest + +pytest.importorskip("fastapi") +pytest.importorskip("gaia_agent_email") + +from fastapi import FastAPI +from fastapi.testclient import TestClient + + +@pytest.fixture +def client() -> TestClient: + from gaia_agent_email.connector_routes import router + + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +class TestConnectorRoutes: + def test_list_reports_per_provider_status(self, client, monkeypatch): + import gaia.connectors.api as capi + + monkeypatch.setattr(capi, "connected_mailbox_providers", lambda: ["google"]) + monkeypatch.setattr( + capi, + "get_connection", + lambda p: ( + {"account_email": "me@gmail.com", "scopes": ["s1"]} + if p == "google" + else None + ), + ) + body = client.get("/v1/email/connectors").json() + assert body["agent_id"] == "installed:email" + by = {p["provider"]: p for p in body["providers"]} + assert by["google"]["connected"] is True + assert by["google"]["account_email"] == "me@gmail.com" + assert by["microsoft"]["connected"] is False + + def test_configure_starts_the_oauth_flow(self, client, monkeypatch): + import gaia.connectors.handler as handler + + async def fake_configure(connector_id, config): + assert connector_id == "google" + assert config["client_id"] == "cid" + return {"flow_id": "f1", "authorization_url": "https://accounts.google.com/x"} + + monkeypatch.setattr(handler, "configure", fake_configure) + r = client.post( + "/v1/email/connectors/google/configure", + json={"client_id": "cid", "client_secret": "sec"}, + ) + assert r.status_code == 200 + assert r.json()["flow_id"] == "f1" + + def test_complete_grants_connection_to_email_agent(self, client, monkeypatch): + import gaia.connectors.api as capi + + granted = {} + + async def fake_complete(flow_id): + assert flow_id == "f1" + return { + "provider": "google", + "account_email": "me@gmail.com", + "scopes": ["s1", "s2"], + } + + def fake_grant(connector_id, agent_id, scopes): + granted.update(connector_id=connector_id, agent_id=agent_id, scopes=scopes) + + monkeypatch.setattr(capi, "complete_authorization", fake_complete) + monkeypatch.setattr(capi, "grant_agent", fake_grant) + r = client.post("/v1/email/connectors/google/complete", json={"flow_id": "f1"}) + assert r.status_code == 200 + assert r.json()["connected"] is True + # The grant is what makes the connection usable by /v1/email/send. + assert granted == { + "connector_id": "google", + "agent_id": "installed:email", + "scopes": ["s1", "s2"], + } + + def test_unknown_provider_is_404(self, client): + r = client.post( + "/v1/email/connectors/yahoo/configure", json={"client_id": "x"} + ) + assert r.status_code == 404 + + +class TestSidecarGating: + def _build_app(self, with_connectors: bool): + # packaging/server.py is a frozen-binary script, not an importable + # package module — load it by path. + root = pathlib.Path(__file__).resolve() + while root.name and not (root / "hub").exists(): + root = root.parent + srv = root / "hub/agents/python/email/packaging/server.py" + spec = importlib.util.spec_from_file_location("email_sidecar_server", srv) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.build_app(with_connectors=with_connectors) + + def test_connectors_mounted_only_in_playground_mode(self): + on = self._build_app(with_connectors=True).openapi()["paths"] + off = self._build_app(with_connectors=False).openapi()["paths"] + assert "/v1/email/connectors" in on + assert "/v1/email/connectors" not in off + # The core email surface is present either way. + assert "/v1/email/triage" in off diff --git a/tests/unit/agents/email/test_playground.py b/tests/unit/agents/email/test_playground.py index 468b87677..3dc3a8ad1 100644 --- a/tests/unit/agents/email/test_playground.py +++ b/tests/unit/agents/email/test_playground.py @@ -57,6 +57,8 @@ def test_only_calls_relative_sidecar_paths(self): '"/v1/email/triage"', '"/v1/email/draft"', '"/v1/email/init"', + '"/v1/email/send"', + '"/v1/email/connectors"', ): assert path in html, f"expected the page to call {path}" @@ -66,6 +68,16 @@ def test_no_innerhtml_sink(self): # Pin it — the page must contain ZERO `.innerHTML` assignments. assert ".innerHTML" not in render_playground_html() + def test_connectors_section_degrades_gracefully(self): + # The Connectors section drives /v1/email/connectors, mounted ONLY in + # playground mode. On a plain sidecar it 404s — the page must explain how + # to enable it (--playground / env var) instead of breaking. + html = render_playground_html() + assert "Connectors" in html + assert "conn-providers" in html + assert "--playground" in html + assert "GAIA_EMAIL_PLAYGROUND" in html + class TestPlaygroundRoute: def test_serves_html_200(self, client): From b6cd933eb4641291e3bd3b453235be787bc87c4b Mon Sep 17 00:00:00 2001 From: Tomasz Iniewicz Date: Tue, 23 Jun 2026 10:13:33 -0400 Subject: [PATCH 3/8] docs(agent-email): document playground connectors + --playground flag Add a Playground 'connect a mailbox + live send' note to the email README and apply black formatting to the new connector module/tests. --- hub/agents/python/email/README.md | 8 ++++++++ .../python/email/gaia_agent_email/connector_routes.py | 4 +++- tests/unit/agents/email/test_connector_routes.py | 9 +++++---- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/hub/agents/python/email/README.md b/hub/agents/python/email/README.md index 3ce20b372..06cdadb6c 100644 --- a/hub/agents/python/email/README.md +++ b/hub/agents/python/email/README.md @@ -83,6 +83,14 @@ up? is the model downloaded?), live **triage** and **draft** against the running sidecar, a button that runs the `/v1/email/init` readiness check, and copy-paste install shortcuts. +**Connect a mailbox + live send (opt-in).** Start the sidecar with +`--playground` (or `GAIA_EMAIL_PLAYGROUND=1`) to add a **Connectors** panel: +paste your own Google/Microsoft OAuth client credentials, connect Gmail/Outlook +(the same flow `gaia connectors` uses), and the **Send** panel goes live. These +connector routes are mounted **only** in playground mode — a default/production +sidecar stays connector-free, since the consuming application owns the mailbox +connection. Without the flag the panel explains how to enable it. + ![GAIA Email Agent playground — stack health, live triage/draft, and a readiness check, all running against the local sidecar](https://hub.amd-gaia.ai/agents/email/0.2.0/playground.webp) It's served same-origin with a `Content-Security-Policy: connect-src 'self'` diff --git a/hub/agents/python/email/gaia_agent_email/connector_routes.py b/hub/agents/python/email/gaia_agent_email/connector_routes.py index 34b3db967..d4ce987be 100644 --- a/hub/agents/python/email/gaia_agent_email/connector_routes.py +++ b/hub/agents/python/email/gaia_agent_email/connector_routes.py @@ -52,7 +52,9 @@ class ConfigureRequest(BaseModel): class CompleteRequest(BaseModel): - flow_id: str = Field(..., min_length=1, description="flow_id returned by /configure.") + flow_id: str = Field( + ..., min_length=1, description="flow_id returned by /configure." + ) def _require_supported(provider: str) -> None: diff --git a/tests/unit/agents/email/test_connector_routes.py b/tests/unit/agents/email/test_connector_routes.py index 5702b354c..415e2d212 100644 --- a/tests/unit/agents/email/test_connector_routes.py +++ b/tests/unit/agents/email/test_connector_routes.py @@ -59,7 +59,10 @@ def test_configure_starts_the_oauth_flow(self, client, monkeypatch): async def fake_configure(connector_id, config): assert connector_id == "google" assert config["client_id"] == "cid" - return {"flow_id": "f1", "authorization_url": "https://accounts.google.com/x"} + return { + "flow_id": "f1", + "authorization_url": "https://accounts.google.com/x", + } monkeypatch.setattr(handler, "configure", fake_configure) r = client.post( @@ -98,9 +101,7 @@ def fake_grant(connector_id, agent_id, scopes): } def test_unknown_provider_is_404(self, client): - r = client.post( - "/v1/email/connectors/yahoo/configure", json={"client_id": "x"} - ) + r = client.post("/v1/email/connectors/yahoo/configure", json={"client_id": "x"}) assert r.status_code == 404 From bc807b8fe11d38035acec91a6dc4ee9aaab6c972 Mon Sep 17 00:00:00 2001 From: Tomasz Iniewicz Date: Tue, 23 Jun 2026 10:21:15 -0400 Subject: [PATCH 4/8] refactor(agent-email): mount playground connector routes unconditionally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the --playground / GAIA_EMAIL_PLAYGROUND gate. The playground page is always served by the sidecar, so gating only its connector routes left the page with a dead Connectors panel — same lifecycle for both is simpler and consistent. The connectors are compiled into the sidecar binary (not the thin npm fetch-and-spawn wrapper) and reused from gaia.connectors, so the capability is present regardless of any flag; always-on wins on simplicity. Routes are excluded from the OpenAPI contract (include_in_schema=False) — a playground convenience, not part of the frozen email REST contract. The connection lives in GAIA's machine-global store, so a consuming app can still establish it via its own surface. --- hub/agents/python/email/README.md | 14 +++---- .../gaia_agent_email/connector_routes.py | 27 +++++++------ .../email/gaia_agent_email/playground_html.py | 9 +++-- hub/agents/python/email/packaging/server.py | 38 +++++-------------- .../agents/email/test_connector_routes.py | 33 ++++++++++------ tests/unit/agents/email/test_playground.py | 10 ++--- 6 files changed, 65 insertions(+), 66 deletions(-) diff --git a/hub/agents/python/email/README.md b/hub/agents/python/email/README.md index 06cdadb6c..eda27dbcb 100644 --- a/hub/agents/python/email/README.md +++ b/hub/agents/python/email/README.md @@ -83,13 +83,13 @@ up? is the model downloaded?), live **triage** and **draft** against the running sidecar, a button that runs the `/v1/email/init` readiness check, and copy-paste install shortcuts. -**Connect a mailbox + live send (opt-in).** Start the sidecar with -`--playground` (or `GAIA_EMAIL_PLAYGROUND=1`) to add a **Connectors** panel: -paste your own Google/Microsoft OAuth client credentials, connect Gmail/Outlook -(the same flow `gaia connectors` uses), and the **Send** panel goes live. These -connector routes are mounted **only** in playground mode — a default/production -sidecar stays connector-free, since the consuming application owns the mailbox -connection. Without the flag the panel explains how to enable it. +**Connect a mailbox + live send.** The playground includes a **Connectors** +panel: paste your own Google/Microsoft OAuth client credentials, connect +Gmail/Outlook (the same flow `gaia connectors` and the Agent UI use), and the +**Send** panel goes live. The connection lives in GAIA's machine-global +connector store, so a mailbox connected anywhere (Agent UI, CLI) is usable here +too — and vice-versa. These connector routes are excluded from the OpenAPI +contract: a playground convenience, not part of the frozen email REST contract. ![GAIA Email Agent playground — stack health, live triage/draft, and a readiness check, all running against the local sidecar](https://hub.amd-gaia.ai/agents/email/0.2.0/playground.webp) diff --git a/hub/agents/python/email/gaia_agent_email/connector_routes.py b/hub/agents/python/email/gaia_agent_email/connector_routes.py index d4ce987be..01b7c8fea 100644 --- a/hub/agents/python/email/gaia_agent_email/connector_routes.py +++ b/hub/agents/python/email/gaia_agent_email/connector_routes.py @@ -1,18 +1,21 @@ # Copyright(C) 2025-2026 Advanced Micro Devices, Inc. All rights reserved. # SPDX-License-Identifier: MIT -"""Flag-gated mailbox-connector routes for the email playground. +"""Mailbox-connector routes for the email playground. -Mounted on the sidecar ONLY in playground mode (``--playground`` / -``GAIA_EMAIL_PLAYGROUND=1``). Reuses GAIA's connector framework -(``gaia.connectors``) — the same OAuth flow the Agent UI uses — so a developer -evaluating the agent can connect a Gmail/Outlook mailbox from the playground and -exercise live ``/v1/email/send``. +Always mounted on the sidecar. The playground page is itself always served, so +gating only its connector routes would leave the page with a dead Connectors +panel — same lifecycle for both is simpler and consistent. Reuses GAIA's +connector framework (``gaia.connectors``) — the same OAuth flow the Agent UI +uses — so a developer can connect a Gmail/Outlook mailbox from the playground +and exercise live ``/v1/email/send``. -Deliberately NOT part of the default sidecar surface: milestone 40 makes the -consuming application the owner of the mailbox connection, so a production -sidecar stays connector-free. ``gaia.connectors`` is already linked into the +Excluded from the OpenAPI schema: a playground convenience, not part of the +frozen email REST contract. ``gaia.connectors`` is already linked into the binary (the send path resolves the mailbox through it), so these routes add a -surface, not a dependency. +surface, not a dependency. The connection itself lives in a machine-global +keyring store shared by every GAIA surface (Agent UI, CLI, this playground), so +a real consuming app can establish it elsewhere and the sidecar's send just +reads it from that shared store. OAuth completes entirely inside ``gaia.connectors.flow`` — it stands up its own loopback redirect listener and opens the browser — so this module hosts no @@ -35,7 +38,9 @@ EMAIL_AGENT_ID = "installed:email" SUPPORTED_PROVIDERS = ("google", "microsoft") -router = APIRouter(prefix="/v1/email", tags=["email-connectors"]) +router = APIRouter( + prefix="/v1/email", tags=["email-connectors"], include_in_schema=False +) class ConfigureRequest(BaseModel): diff --git a/hub/agents/python/email/gaia_agent_email/playground_html.py b/hub/agents/python/email/gaia_agent_email/playground_html.py index 71483df78..d7cc3e17f 100644 --- a/hub/agents/python/email/gaia_agent_email/playground_html.py +++ b/hub/agents/python/email/gaia_agent_email/playground_html.py @@ -134,10 +134,11 @@