From d5a8f7258259004e0d4e93bf3c74d4c02ad04a86 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Wed, 30 Sep 2026 13:58:35 -0700 Subject: [PATCH 1/4] plugs: Microsoft 365 as the organization's own Entra application, each person signed in with Microsoft The plane is the hosted tree's, byte for byte (54193461): identity by config.mode, delegated by default (the calling person's refresh token, kept under rt- on the record, redeemed for a Graph token), or the application (client credentials); no sites or people lists, Microsoft's own permissions are the boundary and its refusal is surfaced as it is; list_sites; a person who never signed in is told to sign in on the Plugins page. The local registry checks the ids and the secret at Entra when the plug is connected, runs the sign-in (start with a signed state naming the org, workspace, person and return address; complete redeems the code, keeps the refresh token in the secret store and remembers who signed in; sign-out forgets both), and the console's Plugins page offers Sign in with Microsoft, shows who is signed in, and finishes the return. Guide text: the identity, list_sites, never another identity. Co-Authored-By: Claude Fable 5.1 --- docs/self-hosting-guide.md | 50 ++++ gateway/app.py | 222 +++++++++++++++- gateway/plugs_plane.py | 325 +++++++++++++++++++++++- gateway/tests/test_browser.py | 2 +- gateway/tests/test_plugs_local.py | 2 +- gateway/tests/test_plugs_m365.py | 126 +++++++++ gateway/tests/test_plugs_m365_signin.py | 137 ++++++++++ ui/src/app/(app)/plugins/page.tsx | 63 ++++- ui/src/lib/harness.ts | 12 + 9 files changed, 926 insertions(+), 13 deletions(-) create mode 100644 gateway/tests/test_plugs_m365.py create mode 100644 gateway/tests/test_plugs_m365_signin.py diff --git a/docs/self-hosting-guide.md b/docs/self-hosting-guide.md index 79c231f7..9a727d14 100644 --- a/docs/self-hosting-guide.md +++ b/docs/self-hosting-guide.md @@ -615,6 +615,7 @@ Disabled, Not connected), what it costs, and how many harnesses include it. | GitHub | The connected repository: files, branches, commits, pull requests. | An access token and the repository (`owner/name`). | | Vercel | The connected project: deployments and domains. | An access token, the project id and team id. | | InsForge | The connected backend: its tables and records. | The API key and the backend's address. | +| Microsoft 365 | SharePoint sites, OneDrive files, Outlook mail and calendar and the directory's people, read as each signed-in person or as your own application. Nine read tools. | Your organization's Microsoft Entra application (directory id, application id, client secret); then each person signs in with Microsoft once. | **Turning a plugin on.** Open **Plugins**, and on the row choose **Turn on** (the browser) or **Connect** (a plugin that needs a credential; the credential is kept in the instance's secret store @@ -635,6 +636,55 @@ curl -s -X POST "$HARNESSROUTER_BASE_URL/v1/harnesses/$HID/servers/plugs" \ -d '{"plugs":["browser"]}' ``` +### Microsoft 365 + +The plug runs through an application you register in your own Microsoft Entra directory, so the +organization decides what it may do, and Microsoft's own permissions are the boundary: a private +SharePoint site stays private, a mailbox is its owner's. Two identities, chosen when you connect: + +- **Each person signs in with Microsoft** (the default). Every call an agent makes on a person's + behalf runs as that person and reads exactly what they may read. Each person signs in once per + workspace, from the **Plugins** page; an agent working for someone who has not signed in is told + to ask them, and never borrows another identity. +- **The application itself**, for unattended work, with the application permissions an administrator + consented to; it must name the site or person it means. + +Register the application once (the delegated permissions are what a person consents to at sign-in; +add application permissions only for the application identity): + +```bash +GRAPH=00000003-0000-0000-c000-000000000000 +APP=$(az ad app create --display-name "HarnessRouter Microsoft 365" --sign-in-audience AzureADMyOrg \ + --web-redirect-uris "https:///plugins" --query appId -o tsv) +az ad sp create --id "$APP" >/dev/null +for perm in openid profile offline_access User.Read User.ReadBasic.All Sites.Read.All Files.Read.All Mail.Read Calendars.Read; do + id=$(az ad sp show --id $GRAPH --query "oauth2PermissionScopes[?value=='$perm'].id" -o tsv) + az ad app permission add --id "$APP" --api $GRAPH --api-permissions "$id=Scope" +done +az ad app permission admin-consent --id "$APP" +az ad app credential reset --id "$APP" --append --display-name harnessrouter --years 1 --query password -o tsv +``` + +The last line prints the client secret once. On the **Plugins** page choose **Connect** on the +Microsoft 365 row and enter the directory (tenant) id, the application (client) id, the client secret +and the identity. The ids and the secret are checked at Entra as you save. With the delegated +identity the row then reads **Needs auth** until you choose **Sign in with Microsoft**: Microsoft's +own sign-in page opens, you come back to the Plugins page, and the plug keeps your sign-in for this +workspace (its refresh token, in the instance's secret store under your own field) and shows who +you are. **Sign out** forgets it. Over the API the same steps are +`POST /v1/plugs/microsoft365/microsoft/start` (`{"redirect_uri": ...}` gives the address to open), +`POST /v1/plugs/microsoft/complete` (`{"code", "state"}` from the return) and +`POST /v1/plugs/microsoft365/microsoft/signout`. + +What the agent gets: `resources` (whose identity it runs as, how to address a site or a person), +`find_people`, `list_sites`, `list_files`, `search_files`, `read_file` (a SharePoint site as +`hostname:/sites/name`, a person's OneDrive, or its own), `list_mail`, `read_mail`, `list_events`. +Every call is one audit row on the session, naming the site, person, path or folder it addressed. +What Microsoft refuses is what that identity may not see, and the agent is told to report it rather +than work around it. The directory behind an Azure subscription alone has no Microsoft 365 licence: +sign-in, `resources` and `find_people` work there, while files, mail and calendar answer Microsoft's +own refusal until the tenant holds a licensed seat. + `GET /v1/plugs` lists the catalog with each plugin's state for your workspace; `GET /v1/plugs/browser/attachments` says how many harnesses include it. A package can ask for a plugin with `"requires": {"plugs": ["browser"]}` in its `plugin.json`; the harness it lands on diff --git a/gateway/app.py b/gateway/app.py index e95038b1..e71462b9 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -13664,11 +13664,25 @@ async def _media_session_purge(sid: str) -> None: "deferred) before touching the web any other way.") +_M365_GUIDE = ( + "## Microsoft 365\n" + "This task can read Microsoft 365 through the `plugs` server, as the person it runs for (their own " + "SharePoint, OneDrive, Outlook and directory access) or as the workspace's application: " + "microsoft365_resources (whose identity, how to address things; read it first), microsoft365_find_people, " + "microsoft365_list_sites, microsoft365_list_files, microsoft365_search_files, microsoft365_read_file (a " + "SharePoint site, a person's OneDrive, or your own), microsoft365_list_mail, microsoft365_read_mail, " + "microsoft365_list_events (a mailbox or calendar; your own when no person is named). What Microsoft " + "refuses is what this identity may not see: report a refusal, never work around it or try another " + "identity. These tools read; they write nothing.") + + def _agent_doc_with_plugs(agent_doc: str, plug_types: list[str]) -> str: """The harness's instructions plus a section for each included plugin that needs one.""" parts = [agent_doc.strip()] if agent_doc and agent_doc.strip() else [] if "browser" in plug_types: parts.append(_BROWSER_GUIDE) + if "microsoft365" in plug_types: + parts.append(_M365_GUIDE) return "\n\n".join(parts) @@ -13779,6 +13793,10 @@ async def _plug_fields(rec: dict) -> dict: "github": {"secrets": ["token"], "config": ["repo", "owner", "default_branch"], "source": "local"}, "vercel": {"secrets": ["token"], "config": ["project", "project_id", "team_id"], "source": "local"}, "insforge": {"secrets": ["api_key"], "config": ["project", "project_id", "url", "region"], "source": "local"}, + # An enterprise plug: the organization's own Microsoft Entra application. Identity by mode: + # "delegated" (each person signs in with Microsoft once; the record then holds one refresh + # token per person under rt-) or "application" (client credentials). + "microsoft365": {"secrets": ["client_secret"], "config": ["tenant_id", "client_id", "mode"], "source": "local"}, } @@ -13821,6 +13839,7 @@ def _plug_record_out(row: dict) -> dict: "type": str(row.get("type") or ""), "status": status, "effective_status": status, "source": str(row.get("source") or "local"), "vault_tenant": str(row.get("org") or ""), "key_refs": key_refs if isinstance(key_refs, list) else [], "config": config if isinstance(config, dict) else {}, + "attention": str(row.get("attention") or ""), "version": int(float(row.get("version") or 1)), "updated_at": row.get("updated_at") or ""} @@ -13853,6 +13872,7 @@ def _plug_public(org: str, workspace: str, plug_type: str, rec: dict | None, sta "official": True, "status": status, "config": cfg, "secrets_set": have, "secrets_needed": list(form["secrets"]), "config_fields": list(form["config"]), "version": int((rec or {}).get("version") or 0), "tools": len(plugs_plane.tools_of(plug_type)), + **({"attention": str((rec or {}).get("attention") or "")} if (rec or {}).get("attention") else {}), **({"pricing": browser_plane.pricing()} if plug_type == plugs_plane.BROWSER else {})} @@ -13910,12 +13930,16 @@ async def put_plug(plug_type: str, body: PlugBody, request: Request) -> dict: refs[field] = ref missing = [f for f in form["secrets"] if f not in refs] status = "disabled" if not body.enabled else ("needs_auth" if missing else "connected") + attention = "" + if plug_type == plugs_plane.MICROSOFT365 and body.enabled and not missing: + status, attention, config = await _m365_connect(org, config, refs, body.secrets or {}) version = int((prev or {}).get("version") or 0) + 1 now = int(time.time() * 1000) await _vg_upsert(_PLUG_LABEL, _plug_vid(workspace, plug_type), {"org": org, "workspace": workspace, "type": plug_type, "status": status, "source": form["source"], "config": json.dumps(config, separators=(",", ":")), "key_refs": json.dumps([{"field": f, "ref": r} for f, r in refs.items()], separators=(",", ":")), + "attention": attention, "version": str(version), "updated_at": str(now)}) _plug_fields_cache.pop(_plug_vid(workspace, plug_type), None) status2, rec = await _plug_lookup_local(org, workspace, plug_type) @@ -13923,6 +13947,189 @@ async def put_plug(plug_type: str, body: PlugBody, request: Request) -> dict: return _plug_public(org, workspace, plug_type, rec, status2) +# ── Microsoft 365 sign-in: the registry runs the authorization-code flow for each person ────── +# The workspace's Entra application is connected once (its ids and secret, checked at Entra +# then). With the delegated identity every person then signs in with Microsoft once for the +# workspace: the registry sends them to Entra with a signed state naming the org, workspace, +# person and return address, redeems the code it gets back for a refresh token, keeps that token +# under the person's own field on the record, and remembers who they are. Sign-out forgets both. +_M365_SIGNIN_ATTENTION = "Sign in with Microsoft so the agent can act as you. Each person signs in once for this workspace." +_M365_STATE_TTL_S = 15 * 60 + + +def _m365_state_sign(payload: dict) -> str: + body = json.dumps(payload, separators=(",", ":"), sort_keys=True) + sig = hmac.new((INTERNAL_KEY or "dev-insecure").encode(), b"m365|" + body.encode(), hashlib.sha256).hexdigest() + return "hrm_" + base64.urlsafe_b64encode(body.encode()).decode().rstrip("=") + "." + sig + + +def _m365_state_verify(tok: str) -> dict | None: + try: + raw, sig = str(tok or "")[len("hrm_"):].split(".", 1) + body = base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4)).decode() + want = hmac.new((INTERNAL_KEY or "dev-insecure").encode(), b"m365|" + body.encode(), hashlib.sha256).hexdigest() + if not hmac.compare_digest(want, sig): + return None + st = json.loads(body) + if not isinstance(st, dict) or int(st.get("exp") or 0) < time.time(): + return None + return st + except Exception: # noqa: BLE001 - malformed is invalid + return None + + +def _m365_redirect_ok(uri: str) -> str: + """The console's own address: https, or http on localhost for a developer's box.""" + u = str(uri or "").strip() + if not (u.startswith("https://") or u.startswith("http://localhost") or u.startswith("http://127.0.0.1")): + raise uhp_error(400, "invalid_input", "redirect_uri must be an https address (or localhost).", "redirect_uri") + return u + + +async def _m365_connect(org: str, config: dict, refs: dict, given: dict) -> tuple[str, str, dict]: + """Check the application's ids and secret at Entra when the plug is connected, and decide the + record's status by its identity: the application identity also reads the directory and is + connected; the delegated identity is connected once a person has signed in, and until then + reads needs_auth with the sign-in as its next step. (status, attention, config).""" + config = dict(config) + config["mode"] = plugs_plane.m365_mode({}, config) + accounts = config.get("accounts") if isinstance(config.get("accounts"), dict) else {} + config["accounts"] = accounts + secret = str(given.get("client_secret") or "").strip() or str(await _vault_get(org, refs.get("client_secret", "")) or "") + fields = {"client_secret": secret} + try: + async with plugs_plane.client() as c: + token = await plugs_plane._m365_token(c, fields, {**config, "mode": "application"}) + if config["mode"] == "application": + r = await c.get(f"{plugs_plane.GRAPH_API}/organization", params={"$select": "id,displayName"}, + headers={"Authorization": f"Bearer {token}"}) + if r.status_code >= 400: + raise plugs_plane.PlugToolError(f"Microsoft Graph answered {r.status_code}: {plugs_plane._m365_msg(r)}") + orgs = (r.json() or {}).get("value") or [] + if orgs: + config["directory"] = str(orgs[0].get("displayName") or "") + except plugs_plane.PlugToolError as e: + raise uhp_error(400, "invalid_credential", str(e), "secrets") + if config["mode"] == "application": + return "connected", "", config + return ("connected" if accounts else "needs_auth"), ("" if accounts else _M365_SIGNIN_ATTENTION), config + + +async def _m365_plug(org: str, workspace: str) -> dict: + status, rec = await _plug_lookup_local(org, workspace, plugs_plane.MICROSOFT365) + if not rec: + raise uhp_error(404, "plug_not_connected", "Connect the Microsoft 365 plugin for this workspace first.", "plug_type") + return rec + + +class MicrosoftStartBody(BaseModel): + redirect_uri: str + + +class MicrosoftCompleteBody(BaseModel): + code: str + state: str + + +@app.post("/v1/plugs/microsoft365/microsoft/start") +async def microsoft_start(body: MicrosoftStartBody, request: Request) -> dict: + """Where the person goes to sign in with Microsoft for this workspace's application.""" + org, member = await _pub_org_member(request) + if PLUGS_REGISTRY_URL: + raise uhp_error(409, "registry_elsewhere", "Plugins on this deployment are managed on the Plugins page of the workspace.", "plug_type") + workspace = _plug_workspace(request) + rec = await _m365_plug(org, workspace) + config = rec.get("config") or {} + if plugs_plane.m365_mode({}, config) != "delegated": + raise uhp_error(400, "invalid_input", "This plug runs as the application; it does not sign people in.", "mode") + if not member: + raise uhp_error(401, "invalid_credential", "Sign in to the console first.") + redirect_uri = _m365_redirect_ok(body.redirect_uri) + state = _m365_state_sign({"o": org, "w": workspace, "m": member, "r": redirect_uri, "exp": int(time.time()) + _M365_STATE_TTL_S}) + auth_url = f"{plugs_plane.ENTRA_LOGIN}/{config.get('tenant_id')}/oauth2/v2.0/authorize?" + urllib.parse.urlencode({ + "client_id": str(config.get("client_id") or ""), "response_type": "code", "redirect_uri": redirect_uri, + "response_mode": "query", "scope": " ".join(plugs_plane.M365_DELEGATED_SCOPES), "state": state, "prompt": "select_account"}) + return {"auth_url": auth_url, "state": state} + + +@app.post("/v1/plugs/microsoft/complete") +async def microsoft_complete(body: MicrosoftCompleteBody, request: Request) -> dict: + """Back from Microsoft: the code is redeemed for the person's refresh token, which the plug keeps + under their own field; the state proves this is the sign-in this person started.""" + org, member = await _pub_org_member(request) + st = _m365_state_verify(body.state) + if not st or st.get("o") != org or st.get("m") != member: + raise uhp_error(400, "invalid_state", "This sign-in was not started here, or it expired. Start it again from the Plugins page.", "state") + workspace = str(st.get("w") or "default") + rec = await _m365_plug(org, workspace) + config = dict(rec.get("config") or {}) + fields = await _plug_fields(rec) + tenant, client_id, secret = str(config.get("tenant_id") or ""), str(config.get("client_id") or ""), str(fields.get("client_secret") or "") + async with plugs_plane.client() as c: + r = await c.post(f"{plugs_plane.ENTRA_LOGIN}/{tenant}/oauth2/v2.0/token", + data={"client_id": client_id, "client_secret": secret, "grant_type": "authorization_code", + "code": body.code, "redirect_uri": str(st.get("r") or ""), "scope": " ".join(plugs_plane.M365_DELEGATED_SCOPES)}) + if r.status_code >= 400: + raise uhp_error(400, "invalid_credential", f"Microsoft did not complete the sign-in ({r.status_code}): {plugs_plane._m365_msg(r)}", "code") + d = r.json() + rt, at = str(d.get("refresh_token") or ""), str(d.get("access_token") or "") + if not rt: + raise uhp_error(400, "invalid_credential", "Microsoft answered without a refresh token; the application may lack the offline_access permission.", "code") + me = await c.get(f"{plugs_plane.GRAPH_API}/me", params={"$select": "id,displayName,userPrincipalName"}, + headers={"Authorization": f"Bearer {at}"}) + who = me.json() if me.status_code < 400 else {} + field = plugs_plane.m365_person_field(member) + ref = f"plug-{_plug_safe(workspace)}-{_plug_safe(plugs_plane.MICROSOFT365)}-{_plug_safe(field)}" + await _vault_put(org, ref, rt) + refs = {str(x.get("field")): str(x.get("ref")) for x in (rec.get("key_refs") or []) if isinstance(x, dict)} + refs[field] = ref + accounts = dict(config.get("accounts") or {}) + accounts[member] = {"upn": str(who.get("userPrincipalName") or ""), "name": str(who.get("displayName") or ""), + "id": str(who.get("id") or ""), "signed_in_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())} + config["accounts"] = accounts + version = int(rec.get("version") or 0) + 1 + await _vg_upsert(_PLUG_LABEL, _plug_vid(workspace, plugs_plane.MICROSOFT365), + {"org": org, "workspace": workspace, "type": plugs_plane.MICROSOFT365, "status": "connected", "source": "local", + "config": json.dumps(config, separators=(",", ":")), + "key_refs": json.dumps([{"field": f, "ref": r} for f, r in refs.items()], separators=(",", ":")), + "attention": "", "version": str(version), "updated_at": str(int(time.time() * 1000))}) + _plug_fields_cache.pop(_plug_vid(workspace, plugs_plane.MICROSOFT365), None) + print(f"[plugs] {workspace}: microsoft365 sign-in for {member} ({accounts[member]['upn']})", flush=True) + status2, rec2 = await _plug_lookup_local(org, workspace, plugs_plane.MICROSOFT365) + return _plug_public(org, workspace, plugs_plane.MICROSOFT365, rec2, status2) + + +@app.post("/v1/plugs/microsoft365/microsoft/signout") +async def microsoft_signout(request: Request) -> dict: + """Forget this person's sign-in: their refresh token and their account on the record. With + nobody signed in a delegated plug waits for a sign-in again.""" + org, member = await _pub_org_member(request) + workspace = _plug_workspace(request) + rec = await _m365_plug(org, workspace) + config = dict(rec.get("config") or {}) + field = plugs_plane.m365_person_field(member) + refs = {str(x.get("field")): str(x.get("ref")) for x in (rec.get("key_refs") or []) if isinstance(x, dict)} + ref = refs.pop(field, "") + if ref: + await _vault_put(org, ref, "") # the token is gone from the store, not only unreferenced + accounts = {k: v for k, v in (config.get("accounts") or {}).items() if k != member} + config["accounts"] = accounts + delegated = plugs_plane.m365_mode({}, config) == "delegated" + status = str(rec.get("status") or "connected") + attention = "" + if delegated and not accounts and status == "connected": + status, attention = "needs_auth", _M365_SIGNIN_ATTENTION + version = int(rec.get("version") or 0) + 1 + await _vg_upsert(_PLUG_LABEL, _plug_vid(workspace, plugs_plane.MICROSOFT365), + {"org": org, "workspace": workspace, "type": plugs_plane.MICROSOFT365, "status": status, "source": "local", + "config": json.dumps(config, separators=(",", ":")), + "key_refs": json.dumps([{"field": f, "ref": r} for f, r in refs.items()], separators=(",", ":")), + "attention": attention, "version": str(version), "updated_at": str(int(time.time() * 1000))}) + _plug_fields_cache.pop(_plug_vid(workspace, plugs_plane.MICROSOFT365), None) + status2, rec2 = await _plug_lookup_local(org, workspace, plugs_plane.MICROSOFT365) + return _plug_public(org, workspace, plugs_plane.MICROSOFT365, rec2, status2) + + @app.get("/v1/plugs/{plug_type}/attachments") async def plug_attachments_public(plug_type: str, request: Request) -> dict: """How many of the caller's workspace's harnesses include this plugin, from the bindings.""" @@ -14229,16 +14436,25 @@ async def plugs_mcp(request: Request): return _jsonrpc_result(rid, _tool_text( f"The workspace's {label} plug was not granted {need} access, so {tool} cannot run. The person can " "grant it on the app installation and reconnect the plug.", True)) + # The audit row names the resource the call addressed (a site, a person, a path, a folder, a + # repository), so access is accounted for by resource and not only by tool. + resource = {k: str(args[k])[:200] for k in ("site", "user", "path", "folder", "repo", "query", "id") + if isinstance(args, dict) and args.get(k) not in (None, "")} or None + if plug == plugs_plane.MICROSOFT365: + # A delegated identity is the person this session runs for: the plane reads their own + # sign-in from the record's fields and never another person's. + sv = await _vertex_get(sid) if sid else None + config = {**config, "member": str((sv or {}).get("member_id") or (sv or {}).get("member") or "")} try: text = await plugs_plane.call(plug, tool, args if isinstance(args, dict) else {}, fields, config) except plugs_plane.PlugToolError as e: - await _plug_call_record(hid, sid, org, workspace, plug, tool, spec["risk"], started, "error", str(e)) + await _plug_call_record(hid, sid, org, workspace, plug, tool, spec["risk"], started, "error", str(e), detail=resource) return _jsonrpc_result(rid, _tool_text(str(e), True)) except Exception as e: # noqa: BLE001 await _plug_call_record(hid, sid, org, workspace, plug, tool, spec["risk"], started, "error", - f"{type(e).__name__}: {e}") + f"{type(e).__name__}: {e}", detail=resource) return _jsonrpc_result(rid, _tool_text(f"The call failed ({type(e).__name__}). Try again.", True)) - await _plug_call_record(hid, sid, org, workspace, plug, tool, spec["risk"], started, "ok") + await _plug_call_record(hid, sid, org, workspace, plug, tool, spec["risk"], started, "ok", detail=resource) return _jsonrpc_result(rid, _tool_text(text)) diff --git a/gateway/plugs_plane.py b/gateway/plugs_plane.py index 89e8e78d..4042e6a5 100644 --- a/gateway/plugs_plane.py +++ b/gateway/plugs_plane.py @@ -23,6 +23,7 @@ import asyncio import base64 import json +import re import time import httpx @@ -50,7 +51,7 @@ class PlugToolError(RuntimeError): # One plug type answers under its own name; an alias shares another's tools (the OAuth-consent # GitHub plug carries the same `token` field as the provisioned one). TYPES: dict[str, str] = {"github": "GitHub", "github_app": "GitHub", "vercel": "Vercel", "insforge": "InsForge", - "browser": "Browser"} + "browser": "Browser", "microsoft365": "Microsoft 365"} BROWSER = "browser" # a platform plug: no customer credential; served by the gateway's browser plane _ALIAS = {"github_app": "github"} RISKS = ("read", "write", "destructive") @@ -173,6 +174,328 @@ def _need(args: dict, key: str): return v +# ── Microsoft 365: the organization's own Entra application, as the person or as the application ── +# The workspace registers ITS OWN Microsoft Entra application (directory id, application id, client +# secret). Two identities, chosen on the record (`config.mode`): +# delegated each person signs in with Microsoft once (the authorization-code flow, run by the +# registry); the plug keeps that person's refresh token, and every call an agent +# makes on their behalf runs as them, so SharePoint, OneDrive, Outlook and the +# directory answer exactly what that person may see. No list of sites or people +# here: Microsoft's own permissions are the boundary (a private site is private). +# application the application's own identity (client credentials), for unattended work, with the +# permissions an administrator consented to (Sites.Selected narrows SharePoint to the +# sites the administrator granted). It names the site or person it means. +# Reads only in this slice: files (SharePoint and OneDrive), mail, calendar, sites and people. Each +# call is one audit row (the plugs server writes it) naming the resource it addressed. +MICROSOFT365 = "microsoft365" +GRAPH_API = "https://graph.microsoft.com/v1.0" +ENTRA_LOGIN = "https://login.microsoftonline.com" +GRAPH_SCOPE = "https://graph.microsoft.com/.default" +# What a person is asked to consent to when they sign in (delegated); reads only. +M365_DELEGATED_SCOPES = ("openid", "profile", "offline_access", "User.Read", "User.ReadBasic.All", + "Sites.Read.All", "Files.Read.All", "Mail.Read", "Calendars.Read") +M365_TEXT_TYPES = ("text/", "application/json", "application/xml", "application/x-yaml", "application/javascript") +M365_TEXT_EXT = (".txt", ".md", ".csv", ".json", ".yaml", ".yml", ".xml", ".html", ".htm", ".log", ".py", ".js", ".ts") +M365_MAX_CHARS = 60_000 +_m365_tokens: dict[str, tuple[str, float]] = {} # identity key -> (access token, expires_at) + + +def member_slug(member: str) -> str: + """A person's id as a vault-safe name: lowercase, [a-z0-9-], no runs, at most 60 characters.""" + return re.sub(r"[^a-z0-9]+", "-", str(member or "").lower()).strip("-")[:60] + + +def m365_person_field(member: str) -> str: + """The credential field that holds one person's Microsoft refresh token on the plug.""" + return f"rt-{member_slug(member)}" + + +def m365_mode(fields: dict, config: dict) -> str: + mode = str(config.get("mode") or "").strip().lower() + return mode if mode in ("delegated", "application") else "delegated" + + +def _m365_msg(r: httpx.Response) -> str: + try: + d = r.json() + e = d.get("error") if isinstance(d, dict) else None + if isinstance(e, dict): + return str(e.get("message") or e.get("code") or r.text[:200]) + if isinstance(d, dict) and d.get("error_description"): + return str(d["error_description"]).splitlines()[0][:300] + if isinstance(d, dict) and d.get("error"): + return str(d["error"])[:200] + except ValueError: + pass + return r.text[:200] + + +async def _m365_token(c: httpx.AsyncClient, fields: dict, config: dict) -> str: + """An access token for Microsoft Graph as the identity the record names, kept until it expires. + + delegated: the calling person's refresh token (the registry stored it when they signed in; the + plugs server hands the person in `config["member"]`) is redeemed for an access token. A person + who never signed in, or whose sign-in Microsoft has revoked, is told to sign in on the Plugins + page: the agent cannot do it for them and must not borrow anyone else's identity. + application: client credentials, the application's own identity.""" + tenant, client_id, secret = str(config.get("tenant_id") or ""), str(config.get("client_id") or ""), str(fields.get("client_secret") or "") + if not (tenant and client_id and secret): + raise PlugToolError("The Microsoft 365 plug needs a directory (tenant) id, an application (client) id and its client secret.") + if m365_mode(fields, config) == "delegated": + member = str(config.get("member") or "") + rt = str(fields.get(m365_person_field(member)) or "") if member else "" + if not rt: + raise PlugToolError("The person running this task has not signed in with Microsoft for this workspace. " + "Ask them to open Plugins, Microsoft 365, and sign in; the agent then acts as them. " + "Do not try another identity.") + key = f"rt:{tenant}:{client_id}:{rt[-16:]}" + data = {"client_id": client_id, "client_secret": secret, "grant_type": "refresh_token", "refresh_token": rt, "scope": GRAPH_SCOPE} + refused = "Microsoft did not accept the person's sign-in ({status}): {why}. They can sign in again on the Plugins page." + else: + key = f"app:{tenant}:{client_id}:{secret[-6:]}" + data = {"client_id": client_id, "client_secret": secret, "grant_type": "client_credentials", "scope": GRAPH_SCOPE} + refused = "Microsoft Entra refused the application's sign-in ({status}): {why}" + hit = _m365_tokens.get(key) + if hit and hit[1] - time.time() > 60: + return hit[0] + r = await c.post(f"{ENTRA_LOGIN}/{tenant}/oauth2/v2.0/token", data=data) + if r.status_code >= 400: + raise PlugToolError(refused.format(status=r.status_code, why=_m365_msg(r))) + d = r.json() + token, ttl = str(d.get("access_token") or ""), int(d.get("expires_in") or 3600) + if not token: + raise PlugToolError("Microsoft Entra answered without an access token.") + _m365_tokens[key] = (token, time.time() + ttl) + return token + + +async def _graph(c: httpx.AsyncClient, fields: dict, config: dict, method: str, path: str, *, params=None, + headers: dict | None = None, raw: bool = False): + token = await _m365_token(c, fields, config) + r = await c.request(method, GRAPH_API + path, params=params, + headers={"Authorization": f"Bearer {token}", "Accept": "application/json", **(headers or {})}) + if r.status_code >= 400: + raise PlugToolError(f"Microsoft Graph answered {r.status_code}: {_m365_msg(r)}") + if raw: + return r + return r.json() if r.content else {} + + +def _m365_site_ok(site: str) -> str: + site = site.strip().rstrip("/") + if not re.fullmatch(r"[a-z0-9.-]+:/[A-Za-z0-9._/-]+", site, re.I): + raise PlugToolError("site must be hostname:/sites/name, as list_sites shows it.") + return site + + +def _m365_person(config: dict, args: dict) -> tuple[str, str]: + """(Graph prefix, label) for the person a call addresses: `user` when named; otherwise the signed-in + person (delegated). The application identity must name the person.""" + user = str(args.get("user") or "").strip() + if user and user.lower() != "me": + if "/" in user or "?" in user or "$" in user: + raise PlugToolError("user must be a person's sign-in address.") + return f"/users/{user}", user + if m365_mode({}, config) == "delegated": + return "/me", "you" + raise PlugToolError("Name the person (user: their sign-in address); the application identity has no self.") + + +def _m365_drive(config: dict, args: dict) -> tuple[str, str]: + """The drive root a files call addresses: a SharePoint site's document library (`site`) or a + person's OneDrive (`user`, or the signed-in person).""" + if args.get("site"): + site = _m365_site_ok(str(args["site"])) + return f"/sites/{site}/drive", f"site {site}" + prefix, who = _m365_person(config, args) + return f"{prefix}/drive", f"OneDrive of {who}" + + +def _m365_path(args: dict) -> str: + p = str(args.get("path") or "").strip().strip("/") + if ".." in p.split("/"): + raise PlugToolError("path must not contain '..'.") + return p + + +_ITEM = ("name", "id", "size", "lastModifiedDateTime", "webUrl") + + +def _item_out(d: dict) -> dict: + out = _pick(d, _ITEM) + out["kind"] = "folder" if "folder" in d else "file" + if isinstance(d.get("folder"), dict): + out["children"] = d["folder"].get("childCount") + if isinstance(d.get("file"), dict): + out["mimeType"] = d["file"].get("mimeType") + if isinstance(d.get("parentReference"), dict) and d["parentReference"].get("path"): + out["folder"] = str(d["parentReference"]["path"]).split("root:", 1)[-1] or "/" + return out + + +def _site_ref(s: dict) -> str: + """hostname:/sites/name for a site Graph returned, from its webUrl.""" + url = str(s.get("webUrl") or "") + m = re.match(r"https?://([^/]+)(/.*)?$", url) + return f"{m.group(1)}:{m.group(2) or '/'}" if m else str(s.get("id") or "") + + +@_tool("microsoft365", "resources", "read", + "Whose identity this plug runs as and how to address things: read it first. Files live in a SharePoint " + "site (site: hostname:/sites/name, see list_sites) or a person's OneDrive; mail and calendar belong to " + "a person. As a signed-in person, calls with no site or user are your own.", _obj({})) +async def _(c, f, cfg, a): + mode = m365_mode(f, cfg) + out = {"tenant_id": cfg.get("tenant_id"), "identity": "the application (client credentials); reads only", + "tools": {"sites": "list_sites", "files": "list_files, search_files, read_file (site or user)", + "mail": "list_mail, read_mail (user)", "calendar": "list_events (user)", "directory": "find_people"}} + if mode == "delegated": + me = await _graph(c, f, cfg, "GET", "/me", params={"$select": "displayName,userPrincipalName,mail,jobTitle"}) + out["identity"] = f"you, signed in as {me.get('userPrincipalName') or me.get('mail') or ''} ({me.get('displayName') or ''}); reads only" + out["you"] = _pick(me, ("displayName", "userPrincipalName", "mail", "jobTitle")) + out["note"] = "What you may read on SharePoint, OneDrive and Outlook is what your account may read." + return out + + +@_tool("microsoft365", "find_people", "read", + "People in the organization's directory matching a name or address: display name, sign-in address, mail, title, department.", + _obj({"query": _s("A name, part of a name, or an address"), "top": _i("How many, at most 50")}, ("query",))) +async def _(c, f, cfg, a): + q = str(_need(a, "query")).replace('"', "") + top = min(int(a.get("top") or 10), 50) + d = await _graph(c, f, cfg, "GET", "/users", + params={"$search": f'"displayName:{q}" OR "mail:{q}" OR "userPrincipalName:{q}"', + "$select": "id,displayName,userPrincipalName,mail,jobTitle,department", "$top": top}, + headers={"ConsistencyLevel": "eventual"}) + return [_pick(u, ("id", "displayName", "userPrincipalName", "mail", "jobTitle", "department")) for u in d.get("value") or []] + + +@_tool("microsoft365", "list_sites", "read", + "SharePoint sites this identity can see, by a search word (or all of them): name, address, and the " + "`site` form the files tools take.", + _obj({"query": _s("A word in the site's name; * for every site"), "top": _i("How many, at most 100")})) +async def _(c, f, cfg, a): + q = str(a.get("query") or "*").replace("'", "") + d = await _graph(c, f, cfg, "GET", "/sites", params={"search": q, "$select": "id,name,displayName,webUrl", + "$top": min(int(a.get("top") or 25), 100)}) + return [{"site": _site_ref(s), **_pick(s, ("name", "displayName", "webUrl", "id"))} for s in d.get("value") or []] + + +@_tool("microsoft365", "list_files", "read", + "Files and folders at a path of a SharePoint site's document library, a person's OneDrive, or your own.", + _obj({"site": _s("SharePoint site as hostname:/sites/name (from list_sites)"), + "user": _s("A person's sign-in address, for their OneDrive; omit for your own"), + "path": _s("Folder path under the root; empty for the root"), "top": _i("How many, at most 200")})) +async def _(c, f, cfg, a): + root, where = _m365_drive(cfg, a) + path = _m365_path(a) + url = f"{root}/root/children" if not path else f"{root}/root:/{path}:/children" + d = await _graph(c, f, cfg, "GET", url, params={"$select": "name,id,size,lastModifiedDateTime,webUrl,folder,file,parentReference", + "$top": min(int(a.get("top") or 50), 200)}) + return {"resource": where, "path": "/" + path, "items": [_item_out(x) for x in d.get("value") or []]} + + +@_tool("microsoft365", "search_files", "read", + "Search a site's document library, a person's OneDrive, or your own, by name or content.", + _obj({"site": _s("SharePoint site as hostname:/sites/name"), "user": _s("A person's sign-in address; omit for your own"), + "query": _s("Words to search for"), "top": _i("How many, at most 100")}, ("query",))) +async def _(c, f, cfg, a): + root, where = _m365_drive(cfg, a) + q = str(_need(a, "query")).replace("'", "''") + d = await _graph(c, f, cfg, "GET", f"{root}/root/search(q='{q}')", + params={"$select": "name,id,size,lastModifiedDateTime,webUrl,folder,file,parentReference", "$top": min(int(a.get("top") or 25), 100)}) + return {"resource": where, "query": a.get("query"), "items": [_item_out(x) for x in d.get("value") or []]} + + +@_tool("microsoft365", "read_file", "read", + "The content of a text file (txt, md, csv, json, yaml, xml, html, code) at a path of a site, a person's " + "OneDrive, or your own; for another file type, its facts and address, not its bytes.", + _obj({"site": _s("SharePoint site as hostname:/sites/name"), "user": _s("A person's sign-in address; omit for your own"), + "path": _s("File path under the root"), "max_chars": _i("At most this many characters of text, up to 60000")}, ("path",))) +async def _(c, f, cfg, a): + root, where = _m365_drive(cfg, a) + path = _m365_path(a) + if not path: + raise PlugToolError("path names the file to read.") + meta = await _graph(c, f, cfg, "GET", f"{root}/root:/{path}", params={"$select": "name,id,size,lastModifiedDateTime,webUrl,file,folder"}) + if "folder" in meta: + raise PlugToolError(f"{path} is a folder; list_files reads its entries.") + mime = str((meta.get("file") or {}).get("mimeType") or "") + name = str(meta.get("name") or path) + texty = any(mime.startswith(t) for t in M365_TEXT_TYPES) or name.lower().endswith(M365_TEXT_EXT) + out = {"resource": where, "path": "/" + path, **_pick(meta, ("name", "size", "lastModifiedDateTime", "webUrl")), "mimeType": mime} + if not texty: + out["note"] = "Not a text file: its bytes are not read here. The person can open it at webUrl." + return out + cap = min(int(a.get("max_chars") or 20_000), M365_MAX_CHARS) + r = await _graph(c, f, cfg, "GET", f"{root}/root:/{path}:/content", raw=True) + text = r.content.decode("utf-8", "replace") + out["content"] = text[:cap] + if len(text) > cap: + out["truncated"] = f"{len(text) - cap} more characters not shown" + return out + + +_MAIL = ("id", "subject", "receivedDateTime", "isRead", "hasAttachments", "bodyPreview", "webLink") + + +def _addr(x) -> str: + e = x.get("emailAddress") if isinstance(x, dict) else None + return f"{e.get('name') or ''} <{e.get('address') or ''}>".strip() if isinstance(e, dict) else "" + + +@_tool("microsoft365", "list_mail", "read", + "The newest messages in a folder of a mailbox (yours, or a person's): subject, sender, date, a preview.", + _obj({"user": _s("The person's sign-in address; omit for your own mailbox"), + "folder": _s("inbox (default), sentitems, drafts, archive, or a folder id"), + "search": _s("Words to search for in the folder"), "top": _i("How many, at most 50")})) +async def _(c, f, cfg, a): + prefix, who = _m365_person(cfg, a) + folder = str(a.get("folder") or "inbox").strip() + params = {"$select": "id,subject,from,receivedDateTime,isRead,hasAttachments,bodyPreview,webLink", "$top": min(int(a.get("top") or 20), 50)} + if a.get("search"): + params["$search"] = '"' + str(a["search"]).replace('"', "") + '"' + else: + params["$orderby"] = "receivedDateTime desc" + d = await _graph(c, f, cfg, "GET", f"{prefix}/mailFolders/{folder}/messages", params=params) + return {"resource": f"mailbox of {who}", "folder": folder, + "messages": [{**_pick(m, _MAIL), "from": _addr(m.get("from"))} for m in d.get("value") or []]} + + +@_tool("microsoft365", "read_mail", "read", + "One message of a mailbox (yours, or a person's), as text: subject, sender, recipients, date, body.", + _obj({"user": _s("The person's sign-in address; omit for your own mailbox"), "id": _s("The message id from list_mail")}, ("id",))) +async def _(c, f, cfg, a): + prefix, who = _m365_person(cfg, a) + mid = str(_need(a, "id")) + m = await _graph(c, f, cfg, "GET", f"{prefix}/messages/{mid}", + params={"$select": "id,subject,from,toRecipients,ccRecipients,receivedDateTime,hasAttachments,body,webLink"}, + headers={"Prefer": 'outlook.body-content-type="text"'}) + body = str((m.get("body") or {}).get("content") or "") + return {"resource": f"mailbox of {who}", **_pick(m, ("id", "subject", "receivedDateTime", "hasAttachments", "webLink")), + "from": _addr(m.get("from")), "to": [_addr(x) for x in m.get("toRecipients") or []], "cc": [_addr(x) for x in m.get("ccRecipients") or []], + "body": body[:M365_MAX_CHARS]} + + +@_tool("microsoft365", "list_events", "read", + "A calendar (yours, or a person's) between two moments: subject, start, end, location, organizer, attendees.", + _obj({"user": _s("The person's sign-in address; omit for your own calendar"), "start": _s("ISO 8601 start, e.g. 2026-09-29T00:00:00Z"), + "end": _s("ISO 8601 end"), "top": _i("How many, at most 100")}, ("start", "end"))) +async def _(c, f, cfg, a): + prefix, who = _m365_person(cfg, a) + d = await _graph(c, f, cfg, "GET", f"{prefix}/calendarView", + params={"startDateTime": str(_need(a, "start")), "endDateTime": str(_need(a, "end")), + "$select": "id,subject,start,end,location,organizer,attendees,isAllDay,webLink", + "$orderby": "start/dateTime", "$top": min(int(a.get("top") or 50), 100)}) + out = [] + for e in d.get("value") or []: + out.append({**_pick(e, ("id", "subject", "isAllDay", "webLink")), + "start": (e.get("start") or {}).get("dateTime"), "end": (e.get("end") or {}).get("dateTime"), + "location": (e.get("location") or {}).get("displayName"), "organizer": _addr(e.get("organizer")), + "attendees": [_addr(x) for x in e.get("attendees") or []]}) + return {"resource": f"calendar of {who}", "events": out} + + # ── GitHub: the company repository, through an installation token scoped to it ─────────────── def _gh_msg(r: httpx.Response) -> str: try: diff --git a/gateway/tests/test_browser.py b/gateway/tests/test_browser.py index 540dfcdd..42bcbdc5 100644 --- a/gateway/tests/test_browser.py +++ b/gateway/tests/test_browser.py @@ -692,7 +692,7 @@ def test_the_page_reads_the_price_and_the_tools_from_here(client, world): "rounding": "up to the minute, one-minute minimum", "session_cap_minutes": 20, "session_estimate_usd": 0.006667} rows = client.get("/internal/plugs/pricing", headers=hdr).json()["pricing"] - assert [x["type"] for x in rows] == ["browser", "github", "vercel", "insforge"] and rows[1]["usd_per_unit"] == 0.0 + assert [x["type"] for x in rows] == ["browser", "github", "vercel", "insforge", "microsoft365"] and rows[1]["usd_per_unit"] == 0.0 r = client.get("/internal/plugs/tools", params={"type": "browser"}, headers=hdr) assert r.status_code == 200 and r.json()["label"] == "Browser" and len(r.json()["tools"]) == 13 assert r.json()["tools"][0] == {"name": "navigate", "description": "Open a web address in the current tab and read what is on the page.", "risk": "write"} diff --git a/gateway/tests/test_plugs_local.py b/gateway/tests/test_plugs_local.py index bd72f556..cd1bae0c 100644 --- a/gateway/tests/test_plugs_local.py +++ b/gateway/tests/test_plugs_local.py @@ -81,7 +81,7 @@ def test_the_catalog_and_the_browser_connected_here(client, world): ven, posted = world cat = _req(client, "GET", "/v1/plugs").json() assert cat["workspace"] == WS - assert [p["type"] for p in cat["plugs"]] == ["browser", "github", "vercel", "insforge"] + assert [p["type"] for p in cat["plugs"]] == ["browser", "github", "vercel", "insforge", "microsoft365"] b = cat["plugs"][0] assert b["status"] == "missing" and b["source"] == "platform" and b["official"] is True and b["tools"] == 13 assert b["pricing"]["usd_per_unit"] == 0.02 and b["pricing"]["markup"] == 0.0 and b["secrets_needed"] == [] diff --git a/gateway/tests/test_plugs_m365.py b/gateway/tests/test_plugs_m365.py new file mode 100644 index 00000000..38dc8c56 --- /dev/null +++ b/gateway/tests/test_plugs_m365.py @@ -0,0 +1,126 @@ +"""The Microsoft 365 plane: the organization's own Entra application, run as the signed-in person +(their refresh token, redeemed for an access token; calls with no site or person are their own) +or as the application (client credentials; it must name the site or person). No list of sites or +people: Microsoft's own permissions are the boundary, and its refusal is surfaced as it is.""" +from __future__ import annotations + +import asyncio +import sys +from pathlib import Path + +import httpx +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import plugs_plane # noqa: E402 + +TENANT = "11111111-2222-3333-4444-555555555555" +CLIENT = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +SECRET = "m365~SENTINEL_never_returned" +SITE = "contoso.sharepoint.com:/sites/Engineering" + + +class Graph: + """Entra's token endpoint (both grants) and the Graph paths the tools use, with requests kept.""" + + def __init__(self): + self.calls: list[httpx.Request] = [] + self.grants: list[str] = [] + + def handle(self, r: httpx.Request) -> httpx.Response: + self.calls.append(r) + host, path = r.url.host, r.url.path + if host == "login.microsoftonline.com": + form = dict(x.split("=", 1) for x in r.content.decode().split("&")) + assert form["client_id"] == CLIENT and form["client_secret"] == SECRET + self.grants.append(form["grant_type"]) + if form["grant_type"] == "refresh_token": + if form["refresh_token"] == "rt-revoked": + return httpx.Response(400, json={"error": "invalid_grant", "error_description": "AADSTS50173: The provided grant has expired due to it being revoked."}) + return httpx.Response(200, json={"access_token": f"tok-{form['refresh_token']}", "expires_in": 3600}) + return httpx.Response(200, json={"access_token": "tok-app", "expires_in": 3600}) + assert host == "graph.microsoft.com" + auth = r.headers.get("Authorization", "") + p = path.removeprefix("/v1.0") + if p == "/me": + assert auth == "Bearer tok-rt-ada" + return httpx.Response(200, json={"displayName": "Ada Example", "userPrincipalName": "ada@contoso.com", "jobTitle": "CTO"}) + if p == "/me/drive/root/children": + assert auth == "Bearer tok-rt-ada" + return httpx.Response(200, json={"value": [{"name": "notes.md", "id": "i1", "size": 12, "webUrl": "https://x/notes.md", "file": {"mimeType": "text/markdown"}}]}) + if p == "/sites": + return httpx.Response(200, json={"value": [{"id": "s1", "name": "Engineering", "displayName": "Engineering", "webUrl": "https://contoso.sharepoint.com/sites/Engineering"}]}) + if p == f"/sites/{SITE}/drive/root/children": + return httpx.Response(200, json={"value": [{"name": "Reports", "id": "i2", "folder": {"childCount": 3}, "webUrl": "https://x/Reports"}]}) + if p == "/users/bob@contoso.com/drive/root/children": + return httpx.Response(403, json={"error": {"code": "accessDenied", "message": "Access denied"}}) + if p == "/me/mailFolders/inbox/messages": + return httpx.Response(200, json={"value": [{"id": "m1", "subject": "Hello", "receivedDateTime": "2026-09-30T00:00:00Z", "from": {"emailAddress": {"name": "Bob", "address": "bob@contoso.com"}}, "bodyPreview": "hi"}]}) + if p == "/users/bob@contoso.com/mailFolders/inbox/messages": + assert auth == "Bearer tok-app" + return httpx.Response(200, json={"value": []}) + return httpx.Response(404, json={"error": {"code": "itemNotFound", "message": "not found"}}) + + +@pytest.fixture +def graph(monkeypatch): + g = Graph() + monkeypatch.setattr(plugs_plane, "transport", httpx.MockTransport(g.handle)) + plugs_plane._m365_tokens.clear() + return g + + +ADA = {"client_secret": SECRET, plugs_plane.m365_person_field("member.ada"): "rt-ada"} +DELEGATED = {"tenant_id": TENANT, "client_id": CLIENT, "mode": "delegated", "member": "member.ada"} +APP = {"tenant_id": TENANT, "client_id": CLIENT, "mode": "application"} + + +def _call(name, args, fields, config): + return asyncio.run(plugs_plane.call("microsoft365", name, args, fields, config)) + + +def test_a_signed_in_person_runs_as_themselves(graph): + out = _call("resources", {}, ADA, DELEGATED) + assert "signed in as ada@contoso.com" in out and "Ada Example" in out + out = _call("list_files", {}, ADA, DELEGATED) # no site, no user: their own OneDrive + assert "OneDrive of you" in out and "notes.md" in out + out = _call("list_mail", {}, ADA, DELEGATED) + assert "mailbox of you" in out and "Hello" in out + assert graph.grants == ["refresh_token"] # one redemption, then the cached token + assert all("SENTINEL" not in (r.headers.get("Authorization") or "") for r in graph.calls if r.url.host == "graph.microsoft.com") + + +def test_a_person_who_never_signed_in_or_whose_sign_in_was_revoked_is_told_so(graph): + with pytest.raises(plugs_plane.PlugToolError, match="has not signed in with Microsoft"): + _call("list_files", {}, {"client_secret": SECRET}, {**DELEGATED, "member": "member.bob"}) + with pytest.raises(plugs_plane.PlugToolError, match="did not accept the person's sign-in"): + _call("list_files", {}, {"client_secret": SECRET, plugs_plane.m365_person_field("member.eve"): "rt-revoked"}, {**DELEGATED, "member": "member.eve"}) + assert not [r for r in graph.calls if r.url.host == "graph.microsoft.com"] + + +def test_sites_are_addressed_as_microsoft_answers_them_and_a_refusal_is_microsofts(graph): + out = _call("list_sites", {"query": "Eng"}, ADA, DELEGATED) + assert f'"site": "{SITE}"' in out + out = _call("list_files", {"site": SITE}, ADA, DELEGATED) + assert f"site {SITE}" in out and "Reports" in out + with pytest.raises(plugs_plane.PlugToolError, match="Microsoft Graph answered 403: Access denied"): + _call("list_files", {"user": "bob@contoso.com"}, ADA, DELEGATED) # another person's OneDrive: SharePoint decides, no list here + with pytest.raises(plugs_plane.PlugToolError, match="hostname:/sites/name"): + _call("list_files", {"site": "not a site"}, ADA, DELEGATED) + + +def test_the_application_identity_must_name_the_person(graph): + with pytest.raises(plugs_plane.PlugToolError, match="has no self"): + _call("list_mail", {}, {"client_secret": SECRET}, APP) + out = _call("list_mail", {"user": "bob@contoso.com"}, {"client_secret": SECRET}, APP) + assert "mailbox of bob@contoso.com" in out and graph.grants == ["client_credentials"] + out = _call("resources", {}, {"client_secret": SECRET}, APP) + assert "the application" in out + + +def test_tool_names_and_the_type_are_served(): + names = [t["name"] for t in plugs_plane.tools_of("microsoft365")] + assert names == ["resources", "find_people", "list_sites", "list_files", "search_files", "read_file", "list_mail", "read_mail", "list_events"] + assert plugs_plane.TYPES["microsoft365"] == "Microsoft 365" + assert all(t["risk"] == "read" for t in plugs_plane.tools_of("microsoft365")) + assert plugs_plane.member_slug("Member.Ada@Acme") == "member-ada-acme" and plugs_plane.m365_person_field("x y") == "rt-x-y" diff --git a/gateway/tests/test_plugs_m365_signin.py b/gateway/tests/test_plugs_m365_signin.py new file mode 100644 index 00000000..0e12e6a2 --- /dev/null +++ b/gateway/tests/test_plugs_m365_signin.py @@ -0,0 +1,137 @@ +"""Microsoft 365 on the self-hosted registry: the application is checked at Entra when the plug is +connected; a delegated plug waits for each person's sign-in with Microsoft (start -> Entra -> +complete keeps their refresh token under their own field and remembers who they are; sign-out +forgets both); the application identity is connected at once.""" +from __future__ import annotations + +import sys +from pathlib import Path +from urllib.parse import parse_qs, urlparse + +import httpx +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +import app as gw # noqa: E402 +import plugs_plane # noqa: E402 +from fastapi.testclient import TestClient # noqa: E402 + +ORG = "localorg" +WS = "default" +MEMBER = "ada@local" +HEADERS = {"x-harness-internal": "test-internal-key", "x-harness-org": ORG, + "x-harness-member": MEMBER, "x-harness-workspace": WS} +TENANT = "11111111-2222-3333-4444-555555555555" +CLIENT = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +SECRET = "m365~SENTINEL_never_returned" + + +class Entra: + """Entra's token endpoint (client credentials, the authorization code, a refresh) and Graph's /me and /organization.""" + + def __init__(self): + self.calls: list[httpx.Request] = [] + self.grants: list[str] = [] + + def handle(self, r: httpx.Request) -> httpx.Response: + self.calls.append(r) + if r.url.host == "login.microsoftonline.com": + form = {k: v[0] for k, v in parse_qs(r.content.decode()).items()} + assert form["client_id"] == CLIENT and form["client_secret"] == SECRET + self.grants.append(form["grant_type"]) + if form["grant_type"] == "authorization_code": + assert form["code"] == "code-1" and form["redirect_uri"] == "https://console.example/plugins" + assert "offline_access" in form["scope"] + return httpx.Response(200, json={"access_token": "at-ada", "refresh_token": "rt-ada-secret", "expires_in": 3600}) + return httpx.Response(200, json={"access_token": "tok-app", "expires_in": 3600}) + assert r.url.host == "graph.microsoft.com" + if r.url.path.endswith("/me"): + assert r.headers["Authorization"] == "Bearer at-ada" + return httpx.Response(200, json={"id": "u1", "displayName": "Ada Example", "userPrincipalName": "ada@contoso.com"}) + if r.url.path.endswith("/organization"): + return httpx.Response(200, json={"value": [{"id": TENANT, "displayName": "Contoso"}]}) + return httpx.Response(404, json={"error": {"code": "itemNotFound", "message": "not found"}}) + + +@pytest.fixture(scope="module") +def client(): + with TestClient(gw.app) as c: + yield c + + +@pytest.fixture +def entra(monkeypatch): + e = Entra() + monkeypatch.setattr(gw, "PLUGS_REGISTRY_URL", "") + monkeypatch.setattr(plugs_plane, "transport", httpx.MockTransport(e.handle)) + plugs_plane._m365_tokens.clear() + gw._plug_fields_cache.clear() + return e + + +def _req(client, method, path, body=None, headers=None): + return client.request(method, path, json=body, headers={**HEADERS, **(headers or {})}) + + +def test_a_delegated_plug_is_checked_at_entra_then_waits_for_each_persons_sign_in(client, entra): + r = _req(client, "PUT", "/v1/plugs/microsoft365", {"enabled": True, "config": {"tenant_id": TENANT, "client_id": CLIENT}, + "secrets": {"client_secret": SECRET}}) + assert r.status_code == 200, r.text + p = r.json() + assert p["status"] == "needs_auth" and "Sign in with Microsoft" in p["attention"] + assert p["config"]["mode"] == "delegated" and p["config"]["accounts"] == {} and "secrets_set" in p and SECRET not in r.text + assert entra.grants == ["client_credentials"] # the ids and the secret were checked at Entra, nothing else + + r = _req(client, "POST", "/v1/plugs/microsoft365/microsoft/start", {"redirect_uri": "http://evil.example/plugins"}) + assert r.status_code == 400 and "https" in r.text # the return address is the console's own + r = _req(client, "POST", "/v1/plugs/microsoft365/microsoft/start", {"redirect_uri": "https://console.example/plugins"}) + assert r.status_code == 200, r.text + url, state = r.json()["auth_url"], r.json()["state"] + q = parse_qs(urlparse(url).query) + assert urlparse(url).path == f"/{TENANT}/oauth2/v2.0/authorize" + assert q["client_id"] == [CLIENT] and q["response_type"] == ["code"] and q["prompt"] == ["select_account"] + assert q["redirect_uri"] == ["https://console.example/plugins"] and q["state"] == [state] + assert set(q["scope"][0].split()) == set(plugs_plane.M365_DELEGATED_SCOPES) + + # another person cannot finish this sign-in: the state names who started it + r = _req(client, "POST", "/v1/plugs/microsoft/complete", {"code": "code-1", "state": state}, {"x-harness-member": "eve@local"}) + assert r.status_code == 400 and r.json()["error"]["code"] == "invalid_state" + + r = _req(client, "POST", "/v1/plugs/microsoft/complete", {"code": "code-1", "state": state}) + assert r.status_code == 200, r.text + p = r.json() + assert p["status"] == "connected" and "attention" not in p + assert p["config"]["accounts"][MEMBER]["upn"] == "ada@contoso.com" and p["config"]["accounts"][MEMBER]["name"] == "Ada Example" + assert plugs_plane.m365_person_field(MEMBER) in p["secrets_set"] and "rt-ada-secret" not in r.text + assert entra.grants == ["client_credentials", "authorization_code"] + + # the plane is handed that person's token under their field, and nobody else's + import asyncio + _status, rec = asyncio.run(gw._plug_lookup_local(ORG, WS, "microsoft365")) + fields = asyncio.run(gw._plug_fields(rec)) + assert fields[plugs_plane.m365_person_field(MEMBER)] == "rt-ada-secret" and fields["client_secret"] == SECRET + + r = _req(client, "POST", "/v1/plugs/microsoft365/microsoft/signout", {}) + assert r.status_code == 200, r.text + p = r.json() + assert p["status"] == "needs_auth" and p["config"]["accounts"] == {} and plugs_plane.m365_person_field(MEMBER) not in p["secrets_set"] + + +def test_the_application_identity_connects_at_once_and_does_not_sign_people_in(client, entra): + r = _req(client, "PUT", "/v1/plugs/microsoft365", {"enabled": True, "config": {"tenant_id": TENANT, "client_id": CLIENT, "mode": "application"}, + "secrets": {"client_secret": SECRET}}) + assert r.status_code == 200, r.text + p = r.json() + assert p["status"] == "connected" and p["config"]["directory"] == "Contoso" and "attention" not in p + assert entra.grants == ["client_credentials"] and any(c.url.path.endswith("/organization") for c in entra.calls) + r = _req(client, "POST", "/v1/plugs/microsoft365/microsoft/start", {"redirect_uri": "https://console.example/plugins"}) + assert r.status_code == 400 and "does not sign people in" in r.text + + +def test_a_wrong_secret_is_refused_where_it_is_typed(client, entra, monkeypatch): + def refuse(r: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"error": "invalid_client", "error_description": "AADSTS7000215: Invalid client secret provided."}) + monkeypatch.setattr(plugs_plane, "transport", httpx.MockTransport(refuse)) + r = _req(client, "PUT", "/v1/plugs/microsoft365", {"enabled": True, "config": {"tenant_id": TENANT, "client_id": CLIENT}, + "secrets": {"client_secret": "wrong"}}) + assert r.status_code == 400 and "AADSTS7000215" in r.text and r.json()["error"]["code"] == "invalid_credential" diff --git a/ui/src/app/(app)/plugins/page.tsx b/ui/src/app/(app)/plugins/page.tsx index b0081359..e127ea49 100644 --- a/ui/src/app/(app)/plugins/page.tsx +++ b/ui/src/app/(app)/plugins/page.tsx @@ -5,22 +5,28 @@ // include it. Nothing on this page is a copy: the catalog, the tool counts and the price come // from the server that serves and charges them. import { useCallback, useEffect, useState } from 'react'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { SkelRows } from '@/components/Skel'; -import { listPlugs, setPlug, plugAttachments, type Plug } from '@/lib/harness'; +import { listPlugs, setPlug, plugAttachments, microsoftStart, microsoftComplete, microsoftSignout, type Plug } from '@/lib/harness'; -const ICON: Record = { browser: 'tabler:world', github: 'tabler:brand-github', vercel: 'tabler:triangle', insforge: 'tabler:database' }; +const ICON: Record = { browser: 'tabler:world', github: 'tabler:brand-github', vercel: 'tabler:triangle', insforge: 'tabler:database', microsoft365: 'tabler:brand-office' }; const BLURB: Record = { browser: 'A real web browser the agent can open, read, click through and screenshot.', github: 'The repository this workspace connects: files, branches and pull requests.', vercel: 'The project this workspace connects: deployments and domains.', insforge: 'The backend this workspace connects: its tables and records.', + microsoft365: 'Your own SharePoint, OneDrive, Outlook and directory, read as each signed-in person through your organization\u2019s Microsoft Entra application, or as the application itself.', }; const FIELD_LABEL: Record = { allow_domains: 'Only these sites', deny_domains: 'Never these sites', token: 'Access token', api_key: 'API key', repo: 'Repository (owner/name)', owner: 'Owner', default_branch: 'Default branch', project: 'Project', project_id: 'Project id', team_id: 'Team id', url: 'Address', region: 'Region', + tenant_id: 'Directory (tenant) id', client_id: 'Application (client) id', client_secret: 'Client secret', mode: 'Identity', }; +const PLACEHOLDER: Record = { tenant_id: 'e89476d8-\u2026', client_id: 'the application (client) id from the app registration' }; +/** A Microsoft 365 plug runs as each signed-in person unless it was connected as the application. */ +const delegated = (p: Plug) => p.type === 'microsoft365' && String(p.config.mode || 'delegated') === 'delegated'; +const accountsOf = (p: Plug) => (p.config.accounts || {}) as Record; const STATUS_LABEL: Record = { connected: 'Connected', disabled: 'Disabled', needs_auth: 'Needs auth', missing: 'Not connected' }; const listOf = (v: unknown) => (Array.isArray(v) ? v.map(String).join(', ') : ''); const domainsOf = (text: string) => text.split(/[\s,]+/).map((d) => d.trim()).filter(Boolean); @@ -33,6 +39,7 @@ export default function PluginsPage() { const [busy, setBusy] = useState(''); const [editing, setEditing] = useState(null); const [form, setForm] = useState>({}); + const params = useSearchParams(); const reload = useCallback(async () => { try { @@ -47,6 +54,30 @@ export default function PluginsPage() { } catch (e) { setErr(e instanceof Error ? e.message : 'The plugins could not be read.'); } }, []); useEffect(() => { void reload(); }, [reload]); + // back from Microsoft's sign-in: the query carries the code and our state; the console finishes it + useEffect(() => { + const code = params.get('code'); const state = params.get('state'); + const denied = params.get('error_description') || params.get('error'); + if (denied && state) { setErr(`Microsoft did not complete the sign-in: ${denied}`); router.replace('/plugins'); return; } + if (!code || !state) return; + void (async () => { + setBusy('microsoft365'); + try { await microsoftComplete(code, state); await reload(); } + catch (e) { setErr(e instanceof Error ? e.message : 'The sign-in did not finish. Try again.'); } + finally { setBusy(''); router.replace('/plugins'); } + })(); + }, [params, reload, router]); + const signIn = async () => { + setBusy('microsoft365'); setErr(''); + try { const { auth_url } = await microsoftStart(`${window.location.origin}/plugins`); window.location.assign(auth_url); } + catch (e) { setErr(e instanceof Error ? e.message : 'Microsoft could not be reached. Try again.'); setBusy(''); } + }; + const signOut = async () => { + setBusy('microsoft365'); setErr(''); + try { await microsoftSignout(); setEditing(null); await reload(); } + catch (e) { setErr(e instanceof Error ? e.message : 'The sign-out did not finish. Try again.'); } + finally { setBusy(''); } + }; const open = (p: Plug) => { const f: Record = {}; @@ -92,12 +123,15 @@ export default function PluginsPage() {
{p.label} {p.official && Official} {STATUS_LABEL[p.status]} {BLURB[p.type] || ''} {p.tools} tools · {price}{c ? ` · ${c.attached} of ${c.harnesses} Harnesses include it` : ''} + {p.attention && {p.attention}}
{p.status === 'missing' && (p.secrets_needed.length === 0 ? : )} - {p.status === 'needs_auth' && } + {p.status === 'needs_auth' && (delegated(p) + ? + : )} {p.status === 'disabled' && } {p.status !== 'missing' && } {on && } @@ -127,10 +161,25 @@ export default function PluginsPage() { ))} {editing.config_fields.map((k) => (
- setForm({ ...form, [k]: e.target.value })} />
+ {k === 'mode' ? ( + + ) : ( + setForm({ ...form, [k]: e.target.value })} /> + )}
))} + {delegated(editing) && editing.status !== 'missing' && (() => { + const accounts = Object.values(accountsOf(editing)); + return accounts.length + ? Signed in as {accounts.map((a) => a.upn || a.name).join(', ')}. Agents working for a signed-in person read what that person may read. + {' '} + : {editing.attention || 'Sign in with Microsoft so the agent can act as you.'} + {' '}; + })()} {editing.secrets_needed.length > 0 && This plugin runs on the credential you enter here, kept in this instance and never shown again. Every call an agent makes lands in the account that credential belongs to.} {editing.pricing && {`Up to ${editing.pricing.session_cap_minutes} minutes of browsing per task, at most $${editing.pricing.session_estimate_usd.toFixed(4)} a task. Private and local addresses are never reachable.`}} diff --git a/ui/src/lib/harness.ts b/ui/src/lib/harness.ts index 76786952..e1125c6b 100644 --- a/ui/src/lib/harness.ts +++ b/ui/src/lib/harness.ts @@ -564,6 +564,8 @@ export interface Plug { status: 'connected' | 'disabled' | 'needs_auth' | 'missing'; config: Record; secrets_set: string[]; secrets_needed: string[]; config_fields: string[]; version: number; tools: number; pricing?: PlugPricing; + /** What the plugin waits for before it can serve (a Microsoft 365 plug waiting for a person's sign-in). */ + attention?: string; } export const PLUGS_ENTRY_ID = 'mcp.plugs'; @@ -575,6 +577,16 @@ export function listPlugs(): Promise<{ workspace: string; plugs: Plug[] }> { export function setPlug(type: string, body: { enabled: boolean; config?: Record; secrets?: Record }): Promise { return gw('PUT', `/v1/plugs/${encodeURIComponent(type)}`, body); } +/** Microsoft 365 as each person: the member's own sign-in with Microsoft on the workspace's application. */ +export function microsoftStart(redirect_uri: string): Promise<{ auth_url: string; state: string }> { + return gw<{ auth_url: string; state: string }>('POST', '/v1/plugs/microsoft365/microsoft/start', { redirect_uri }); +} +export function microsoftComplete(code: string, state: string): Promise { + return gw('POST', '/v1/plugs/microsoft/complete', { code, state }); +} +export function microsoftSignout(): Promise { + return gw('POST', '/v1/plugs/microsoft365/microsoft/signout', {}); +} export function plugAttachments(type: string): Promise<{ harnesses: number; attached: number; harness_list: { id: string; name: string }[] }> { return gw<{ harnesses: number; attached: number; harness_list: { id: string; name: string }[] }>('GET', `/v1/plugs/${encodeURIComponent(type)}/attachments`); } From 4ac2ff13af1e5779ebbbe864fd26c2c0a76d3af2 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Wed, 30 Sep 2026 13:58:40 -0700 Subject: [PATCH 2/4] ci: start checks From de7df825c88dc6d5037a274d562c734932c98396 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Wed, 30 Sep 2026 14:40:34 -0700 Subject: [PATCH 3/4] plugs: a person's Microsoft sign-in field is a short hash of the member id (hosted af17a309, plane byte for byte) The first hosted sign-in wrote an 84-character vault key name and the vault refused it, so the field a person's refresh token is kept under is now rt- plus sixteen hex characters of the member id's hash, the same derivation on both registries: a record written by either reads on either gateway. The local registry already derives the field from the plane. Co-Authored-By: Claude Fable 5.1 --- gateway/plugs_plane.py | 11 ++++------- gateway/tests/test_plugs_m365.py | 3 ++- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/gateway/plugs_plane.py b/gateway/plugs_plane.py index 4042e6a5..aedf63a7 100644 --- a/gateway/plugs_plane.py +++ b/gateway/plugs_plane.py @@ -22,6 +22,7 @@ import asyncio import base64 +import hashlib import json import re import time @@ -200,14 +201,10 @@ def _need(args: dict, key: str): _m365_tokens: dict[str, tuple[str, float]] = {} # identity key -> (access token, expires_at) -def member_slug(member: str) -> str: - """A person's id as a vault-safe name: lowercase, [a-z0-9-], no runs, at most 60 characters.""" - return re.sub(r"[^a-z0-9]+", "-", str(member or "").lower()).strip("-")[:60] - - def m365_person_field(member: str) -> str: - """The credential field that holds one person's Microsoft refresh token on the plug.""" - return f"rt-{member_slug(member)}" + """The credential field that holds one person's Microsoft refresh token on the plug: short and + opaque (a hash of the member id), the same derivation the registry writes with.""" + return "rt-" + hashlib.sha256(str(member or "").strip().lower().encode()).hexdigest()[:16] def m365_mode(fields: dict, config: dict) -> str: diff --git a/gateway/tests/test_plugs_m365.py b/gateway/tests/test_plugs_m365.py index 38dc8c56..bd254ae2 100644 --- a/gateway/tests/test_plugs_m365.py +++ b/gateway/tests/test_plugs_m365.py @@ -123,4 +123,5 @@ def test_tool_names_and_the_type_are_served(): assert names == ["resources", "find_people", "list_sites", "list_files", "search_files", "read_file", "list_mail", "read_mail", "list_events"] assert plugs_plane.TYPES["microsoft365"] == "Microsoft 365" assert all(t["risk"] == "read" for t in plugs_plane.tools_of("microsoft365")) - assert plugs_plane.member_slug("Member.Ada@Acme") == "member-ada-acme" and plugs_plane.m365_person_field("x y") == "rt-x-y" + f = plugs_plane.m365_person_field("Member.Ada@Acme") + assert len(f) == 19 and f.startswith("rt-") and f == plugs_plane.m365_person_field(" member.ada@acme ") From 254a467b63a42f4bca6665bdf91a950d20af55d2 Mon Sep 17 00:00:00 2001 From: richard-epsilla Date: Wed, 30 Sep 2026 15:01:34 -0700 Subject: [PATCH 4/4] plugs: a Microsoft sign-out also drops a sign-in field that belongs to nobody on the record Co-Authored-By: Claude Fable 5.1 --- gateway/app.py | 5 +++++ gateway/tests/test_plugs_m365_signin.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/gateway/app.py b/gateway/app.py index e71462b9..aaf19072 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -14114,6 +14114,11 @@ async def microsoft_signout(request: Request) -> dict: await _vault_put(org, ref, "") # the token is gone from the store, not only unreferenced accounts = {k: v for k, v in (config.get("accounts") or {}).items() if k != member} config["accounts"] = accounts + # A sign-in field that belongs to nobody on the record (a person who signed out, or a field + # written under an earlier derivation of the name) goes too: the record names only what reads. + keep = {plugs_plane.m365_person_field(m) for m in accounts} + for stale in [f for f in refs if f.startswith("rt-") and f not in keep]: + await _vault_put(org, refs.pop(stale), "") delegated = plugs_plane.m365_mode({}, config) == "delegated" status = str(rec.get("status") or "connected") attention = "" diff --git a/gateway/tests/test_plugs_m365_signin.py b/gateway/tests/test_plugs_m365_signin.py index 0e12e6a2..902a027e 100644 --- a/gateway/tests/test_plugs_m365_signin.py +++ b/gateway/tests/test_plugs_m365_signin.py @@ -114,7 +114,7 @@ def test_a_delegated_plug_is_checked_at_entra_then_waits_for_each_persons_sign_i r = _req(client, "POST", "/v1/plugs/microsoft365/microsoft/signout", {}) assert r.status_code == 200, r.text p = r.json() - assert p["status"] == "needs_auth" and p["config"]["accounts"] == {} and plugs_plane.m365_person_field(MEMBER) not in p["secrets_set"] + assert p["status"] == "needs_auth" and p["config"]["accounts"] == {} and p["secrets_set"] == ["client_secret"] # no orphaned sign-in field def test_the_application_identity_connects_at_once_and_does_not_sign_people_in(client, entra):