diff --git a/docs/self-hosting-guide.md b/docs/self-hosting-guide.md index 035a5c1c..1f7865a1 100644 --- a/docs/self-hosting-guide.md +++ b/docs/self-hosting-guide.md @@ -621,6 +621,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. | **Your own browser.** `HR_BROWSER_CDP_URL` on the container (`-e HR_BROWSER_CDP_URL=http://host.docker.internal:9222` for a Chrome started on the host with `--remote-debugging-port=9222`, or any Chrome DevTools @@ -650,6 +651,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 89a3be5e..02487db3 100644 --- a/gateway/app.py +++ b/gateway/app.py @@ -13678,11 +13678,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) @@ -13793,6 +13807,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"}, } @@ -13835,6 +13853,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 ""} @@ -13867,6 +13886,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 {})} @@ -13924,12 +13944,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) @@ -13937,6 +13961,194 @@ 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 + # 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 = "" + 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.""" @@ -14243,16 +14455,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..aedf63a7 100644 --- a/gateway/plugs_plane.py +++ b/gateway/plugs_plane.py @@ -22,7 +22,9 @@ import asyncio import base64 +import hashlib import json +import re import time import httpx @@ -50,7 +52,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 +175,324 @@ 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 m365_person_field(member: str) -> str: + """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: + 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 98dd745b..31a07e08 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..bd254ae2 --- /dev/null +++ b/gateway/tests/test_plugs_m365.py @@ -0,0 +1,127 @@ +"""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")) + 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 ") diff --git a/gateway/tests/test_plugs_m365_signin.py b/gateway/tests/test_plugs_m365_signin.py new file mode 100644 index 00000000..902a027e --- /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 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): + 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`); }