From 847e2de1af13156d0757e9ca0c6d5c8ad6fc5204 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 17 Aug 2026 13:59:03 -0700 Subject: [PATCH] Add Gmail modify support --- apps/cloud/src/engine/execution-stack.ts | 9 +-- apps/marketing/src/pages/index.astro | 9 +-- apps/marketing/src/pages/privacy.astro | 18 +++-- e2e/scenarios/first-party-oauth.test.ts | 40 +++++++++-- .../src/providers/google/discovery.test.ts | 72 +++++++++++++++++++ .../openapi/src/providers/google/discovery.ts | 11 ++- .../openapi/src/providers/google/presets.ts | 2 +- .../providers/google/spec-format-adapter.ts | 5 +- packages/plugins/openapi/src/sdk/plugin.ts | 15 +++- .../plugins/openapi/src/sdk/spec-format.ts | 3 + .../src/components/add-account-modal.tsx | 19 +++++ 11 files changed, 177 insertions(+), 26 deletions(-) diff --git a/apps/cloud/src/engine/execution-stack.ts b/apps/cloud/src/engine/execution-stack.ts index ecbdee7142..9846ca84cf 100644 --- a/apps/cloud/src/engine/execution-stack.ts +++ b/apps/cloud/src/engine/execution-stack.ts @@ -89,13 +89,14 @@ export const CloudPluginsProvider: Layer.Layer = Layer.succeed( */ export const CLOUD_MOUNT_PREFIX = "/api" as const; -// Initial Google launch boundary. Calendar + Sheets are sensitive scopes but -// not restricted Workspace scopes; Gmail and account-wide Drive remain absent -// until their separate verification/security work is complete. The same scope -// source builds the catalog auth templates, preventing config drift. +// Initial Google launch boundary. Gmail uses gmail.modify for read, send, and +// trash operations while immediate permanent deletion remains absent until the +// broader mail.google.com scope is approved. Account-wide Drive remains absent. +// The same scope source builds the catalog auth templates, preventing drift. const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [ ...new Set([ ...googleCatalogOAuthScopesForPreset("google-calendar"), + ...googleCatalogOAuthScopesForPreset("google-gmail"), ...googleCatalogOAuthScopesForPreset("google-sheets"), ]), ]; diff --git a/apps/marketing/src/pages/index.astro b/apps/marketing/src/pages/index.astro index cd0940b879..d147d171c5 100644 --- a/apps/marketing/src/pages/index.astro +++ b/apps/marketing/src/pages/index.astro @@ -133,14 +133,15 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo

- Connect any agent to everything. + Executor connects any agent to everything.

- Executor is an MCP gateway. Anything that speaks MCP, like Claude - Code, Cursor, or Codex, points at one endpoint and reaches every - tool you connect. + Executor is an integration platform and MCP gateway for AI agents. + It lets you securely connect services such as Google Workspace, + GitHub, and Slack, then read data and run the actions you request + through one endpoint.

diff --git a/apps/marketing/src/pages/privacy.astro b/apps/marketing/src/pages/privacy.astro index 143c58ebe9..b1a4818eb2 100644 --- a/apps/marketing/src/pages/privacy.astro +++ b/apps/marketing/src/pages/privacy.astro @@ -57,10 +57,15 @@ import LegalLayout from "../components/LegalLayout.astro";

If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you request through that integration. Depending on the service and permissions you choose, this may include accessing - or modifying Google Calendar events, Google Sheets spreadsheets, or other Google Workspace content. Executor stores - OAuth credentials and connection metadata so the integration can continue working, and processes Google Workspace - content when carrying out your requested tool calls and returning their results to you or the agent you directed to - make the call. + or modifying Google Calendar events, Google Sheets spreadsheets, Gmail messages, drafts, threads, attachments, + labels, or other Google Workspace content. For Gmail, this can include reading and searching email, composing and + sending messages, and organizing or moving messages to Trash when you request those actions. +

+

+ Executor stores OAuth credentials and connection metadata so the integration can continue working. It processes + Google Workspace content only while carrying out your requested tool calls and returning their results to you or + the agent you directed to make the call. That content can appear in the resulting agent session or in another + service when you explicitly direct Executor to send it there.

We do not use Google Workspace API data for advertising or to train generalized artificial-intelligence or @@ -72,6 +77,11 @@ import LegalLayout from "../components/LegalLayout.astro"; Google API Services User Data Policy, including its Limited Use requirements.

+

+ You can stop Executor's access by deleting the Google connection in Executor or revoking Executor from your + Google Account permissions. To request deletion of your Executor account, stored OAuth credentials, connection + metadata, or other personal information, email rhys@executor.sh. +

Website inputs and support communications

diff --git a/e2e/scenarios/first-party-oauth.test.ts b/e2e/scenarios/first-party-oauth.test.ts index cb483882c5..39fcdc2664 100644 --- a/e2e/scenarios/first-party-oauth.test.ts +++ b/e2e/scenarios/first-party-oauth.test.ts @@ -168,7 +168,7 @@ scenario( ); scenario( - "First-party OAuth · Google offers Calendar and Sheets but refuses Gmail scopes", + "First-party OAuth · Google offers Gmail modify but refuses full Gmail and Drive scopes", {}, Effect.scoped( Effect.gen(function* () { @@ -184,6 +184,7 @@ scenario( expect(google?.origin.kind).toBe("first_party"); if (google?.origin.kind !== "first_party") return; expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/calendar"); + expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/gmail.modify"); expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets"); expect(google.origin.allowedScopes).not.toContain("https://mail.google.com/"); expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/drive"); @@ -229,19 +230,50 @@ scenario( "openid", "email", "profile", - "https://mail.google.com/", + "https://www.googleapis.com/auth/gmail.modify", ]), slug: gmail, }, }); + const gmailStarted = yield* client.oauth.start({ + payload: { + client: OAuthClientSlug.make("first-party:google"), + clientOwner: "org", + owner: "org", + name: ConnectionName.make("gmail"), + integration: gmail, + template: AuthTemplateSlug.make("oauth"), + }, + }); + expect(gmailStarted.status).toBe("redirect"); + const gmailAuthorizationUrl = + gmailStarted.status === "redirect" ? gmailStarted.authorizationUrl : ""; + expect( + new Set(new URL(gmailAuthorizationUrl).searchParams.get("scope")?.split(" ") ?? []), + ).toEqual( + new Set(["openid", "email", "profile", "https://www.googleapis.com/auth/gmail.modify"]), + ); + + const fullGmail = IntegrationSlug.make(unique("google_gmail_full")); + yield* client.openapi.addSpec({ + payload: { + ...googleShapedIntegrationSpec([ + "openid", + "email", + "profile", + "https://mail.google.com/", + ]), + slug: fullGmail, + }, + }); const blocked = yield* client.oauth .start({ payload: { client: OAuthClientSlug.make("first-party:google"), clientOwner: "org", owner: "org", - name: ConnectionName.make("gmail"), - integration: gmail, + name: ConnectionName.make("gmail-full"), + integration: fullGmail, template: AuthTemplateSlug.make("oauth"), }, }) diff --git a/packages/plugins/openapi/src/providers/google/discovery.test.ts b/packages/plugins/openapi/src/providers/google/discovery.test.ts index 9579685851..5f4f2829a9 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.test.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.test.ts @@ -959,6 +959,78 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI integratio }), ); +it.effect("filters Gmail operations to the explicitly selected consent scope", () => + Effect.gen(function* () { + const modifyScope = "https://www.googleapis.com/auth/gmail.modify"; + const fullScope = "https://mail.google.com/"; + const result = yield* convertGoogleDiscoveryBundleToOpenApi({ + consentScopes: [modifyScope], + documents: [ + { + discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", + // @effect-diagnostics-next-line preferSchemaOverJson:off + documentText: JSON.stringify({ + name: "gmail", + version: "v1", + title: "Gmail API", + rootUrl: "https://gmail.googleapis.com/", + servicePath: "", + auth: { + oauth2: { + scopes: { + [modifyScope]: { description: "Read and modify Gmail" }, + [fullScope]: { description: "Full Gmail access" }, + }, + }, + }, + resources: { + users: { + resources: { + messages: { + methods: { + list: { + id: "gmail.users.messages.list", + httpMethod: "GET", + path: "gmail/v1/users/{userId}/messages", + scopes: [modifyScope, fullScope], + parameters: { + userId: { location: "path", required: true, type: "string" }, + }, + }, + delete: { + id: "gmail.users.messages.delete", + httpMethod: "DELETE", + path: "gmail/v1/users/{userId}/messages/{id}", + scopes: [fullScope], + parameters: { + userId: { location: "path", required: true, type: "string" }, + id: { location: "path", required: true, type: "string" }, + }, + }, + }, + }, + }, + }, + }, + schemas: {}, + }), + }, + ], + }); + + const spec = decodeConvertedSpec(result.specText); + const operationIds = Object.values(spec.paths).flatMap((path) => + Object.values(path).map((operation) => operation.operationId), + ); + expect(operationIds).toContain("gmail.users.messages.list"); + expect(operationIds).not.toContain("gmail.users.messages.delete"); + const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2"); + expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([ + modifyScope, + ]); + }), +); + // --------------------------------------------------------------------------- // The merged bundle scope set is the COMPACTED + FILTERED union: sub-scopes // collapse under their broad parent (`gmail.*` → `mail.google.com/`, diff --git a/packages/plugins/openapi/src/providers/google/discovery.ts b/packages/plugins/openapi/src/providers/google/discovery.ts index 96f2abeefb..00c798b035 100644 --- a/packages/plugins/openapi/src/providers/google/discovery.ts +++ b/packages/plugins/openapi/src/providers/google/discovery.ts @@ -831,9 +831,6 @@ const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photosli const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload"; const GOOGLE_PHOTOS_UPLOAD_PATH = "/v1/uploads"; -const isGooglePhotosService = (service: string): boolean => - service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE; - const discoveryScopesForService = ( service: string, document: DiscoveryDocument, @@ -1124,10 +1121,10 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`); const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`; const scopeDescriptions = discoveryScopesForService(info.service, info.document); - const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service); + const filterConsentScopes = consentScopeSet !== null; for (const [scope, description] of Object.entries(scopeDescriptions)) { - if (filterPhotosScopes && !consentScopeSet.has(scope)) continue; + if (filterConsentScopes && !consentScopeSet.has(scope)) continue; rawScopes[scope] ??= description; } @@ -1140,10 +1137,10 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( const rawPathTemplate = Option.getOrUndefined(method.path); if (!methodId || !rawPathTemplate || !method.httpMethod) continue; const methodScopes = discoveryMethodScopesForService(info.service, method); - const oauthScopes = filterPhotosScopes + const oauthScopes = filterConsentScopes ? methodScopes.filter((scope) => consentScopeSet.has(scope)) : methodScopes; - if (filterPhotosScopes && methodScopes.length > 0 && oauthScopes.length === 0) continue; + if (filterConsentScopes && methodScopes.length > 0 && oauthScopes.length === 0) continue; const toolPath = methodId; const wirePath = rawPathTemplate.startsWith("/") ? rawPathTemplate : `/${rawPathTemplate}`; diff --git a/packages/plugins/openapi/src/providers/google/presets.ts b/packages/plugins/openapi/src/providers/google/presets.ts index 33fbab3830..5c2131971b 100644 --- a/packages/plugins/openapi/src/providers/google/presets.ts +++ b/packages/plugins/openapi/src/providers/google/presets.ts @@ -250,7 +250,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = export const googleOAuthConsentScopes: Readonly> = { "google-calendar": ["https://www.googleapis.com/auth/calendar"], - "google-gmail": ["https://mail.google.com/"], + "google-gmail": ["https://www.googleapis.com/auth/gmail.modify"], "google-sheets": ["https://www.googleapis.com/auth/spreadsheets"], "google-drive": ["https://www.googleapis.com/auth/drive"], "google-docs": ["https://www.googleapis.com/auth/documents"], diff --git a/packages/plugins/openapi/src/providers/google/spec-format-adapter.ts b/packages/plugins/openapi/src/providers/google/spec-format-adapter.ts index 05872126ab..f7a5d14186 100644 --- a/packages/plugins/openapi/src/providers/google/spec-format-adapter.ts +++ b/packages/plugins/openapi/src/providers/google/spec-format-adapter.ts @@ -61,7 +61,10 @@ export const googleDiscoveryAdapter: SpecFormatAdapter = { ), { concurrency: 4 }, ); - const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({ documents }); + const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({ + documents, + ...(input.consentScopes ? { consentScopes: input.consentScopes } : {}), + }); const document = documents.length === 1 ? yield* parseJson(documents[0]!.documentText) diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 234b163f3c..4b5c6a6f07 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -646,7 +646,13 @@ export const openApiPlugin = definePlugin< const resolveSpecForInput = ( config: Pick< OpenApiSpecConfig, - "spec" | "specFormat" | "specOverrides" | "headers" | "queryParams" | "baseUrl" + | "spec" + | "specFormat" + | "specOverrides" + | "headers" + | "queryParams" + | "baseUrl" + | "authenticationTemplate" >, httpClientLayer: Layer.Layer, ): Effect.Effect< @@ -670,6 +676,13 @@ export const openApiPlugin = definePlugin< ...(config.headers ? { headers: config.headers } : {}), ...(config.queryParams ? { queryParams: config.queryParams } : {}), }, + ...(config.authenticationTemplate + ? { + consentScopes: config.authenticationTemplate.flatMap((template) => + "kind" in template && template.kind === "oauth2" ? template.scopes : [], + ), + } + : {}), httpClientLayer, }); return yield* applyOverridesToResolvedSpec(resolved, config.specOverrides); diff --git a/packages/plugins/openapi/src/sdk/spec-format.ts b/packages/plugins/openapi/src/sdk/spec-format.ts index 1d45244a49..c42c8c2869 100644 --- a/packages/plugins/openapi/src/sdk/spec-format.ts +++ b/packages/plugins/openapi/src/sdk/spec-format.ts @@ -15,6 +15,9 @@ export interface SpecFetchCredentials { export interface SpecFetchInput { readonly urls: readonly string[]; readonly credentials?: SpecFetchCredentials; + /** Explicit OAuth scopes selected by the caller. Format adapters may use + * these to omit operations that the resulting connection cannot invoke. */ + readonly consentScopes?: readonly string[]; readonly httpClientLayer: Layer.Layer; } diff --git a/packages/react/src/components/add-account-modal.tsx b/packages/react/src/components/add-account-modal.tsx index 21e6beb490..2db411754c 100644 --- a/packages/react/src/components/add-account-modal.tsx +++ b/packages/react/src/components/add-account-modal.tsx @@ -1565,6 +1565,9 @@ function AddAccountModalView(props: AddAccountModalProps) { [...oauthApps, ...oauthNearApps, ...oauthOtherApps].find( (c: OAuthClientOption) => String(c.slug) === selectedApp, ) ?? null; + const isBuiltInGoogleClient = + chosenClient?.origin.kind === "first_party" && + String(chosenClient.slug) === "first-party:google"; const oauthBusy = ccBusy || oauthPopup.busy; const cimdConnecting = cimdBusy || oauthPopup.busy; const dcrConnecting = dcrBusy || oauthPopup.busy; @@ -2880,6 +2883,22 @@ function AddAccountModalView(props: AddAccountModalProps) { />

)} + + {isBuiltInGoogleClient && showPlaceStep ? ( +

+ Executor uses the Google data you authorize only to perform the actions you + request. Read the{` `} + + privacy policy + + . +

+ ) : null}
{continueError ? (