History

Add Gmail modify support (#1630)

Rhys Sullivan committed 2 months ago

Commit df2eeaa1 parent 75a3c4511 files · +177 −26Browse files

Unified · Split

11 files
  1. apps/cloud/src/engine/execution-stack.ts +5 −4
  2. apps/marketing/src/pages/index.astro +5 −4
  3. apps/marketing/src/pages/privacy.astro +14 −4
  4. e2e/scenarios/first-party-oauth.test.ts +36 −4
  5. packages/plugins/openapi/src/providers/google/discovery.test.ts +72 −0
  6. packages/plugins/openapi/src/providers/google/discovery.ts +4 −7
  7. packages/plugins/openapi/src/providers/google/presets.ts +1 −1
  8. packages/plugins/openapi/src/providers/google/spec-format-adapter.ts +4 −1
  9. packages/plugins/openapi/src/sdk/plugin.ts +14 −1
  10. packages/plugins/openapi/src/sdk/spec-format.ts +3 −0
  11. packages/react/src/components/add-account-modal.tsx +19 −0
apps/cloud/src/engine/execution-stack.ts+5−4
@@ -89,13 +89,14 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 88 unmodified lines
8989 */
9090 export const CLOUD_MOUNT_PREFIX = "/api" as const;
9191
92−// Initial Google launch boundary. Calendar + Sheets are sensitive scopes but
93−// not restricted Workspace scopes; Gmail and account-wide Drive remain absent
94−// until their separate verification/security work is complete. The same scope
95−// source builds the catalog auth templates, preventing config drift.
92+// Initial Google launch boundary. Gmail uses gmail.modify for read, send, and
93+// trash operations while immediate permanent deletion remains absent until the
94+// broader mail.google.com scope is approved. Account-wide Drive remains absent.
95+// The same scope source builds the catalog auth templates, preventing drift.
9696 const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [
9797 ...new Set([
9898 ...googleCatalogOAuthScopesForPreset("google-calendar"),
99+ ...googleCatalogOAuthScopesForPreset("google-gmail"),
99100 ...googleCatalogOAuthScopesForPreset("google-sheets"),
100101 ]),
101102 ];
apps/marketing/src/pages/index.astro+5−4
@@ -133,14 +133,15 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo · 132 unmodified lines
133133 <h1
134134 class="rise-1 text-[clamp(2.8rem,7.2vw,6rem)] font-semibold tracking-[-0.025em] leading-[1.02] text-ink mb-6 mx-auto max-w-[18ch] text-balance"
135135 >
136− Connect any agent to <span class="serif-italic">everything</span>.
136+ Executor connects any agent to <span class="serif-italic">everything</span>.
137137 </h1>
138138 <p
139139 class="rise-2 mx-auto mb-12 max-w-[50ch] text-[clamp(1.05rem,2.4vw,1.3rem)] leading-[1.5] text-ink-2"
140140 >
141− Executor is an MCP gateway. Anything that speaks MCP, like Claude
142− Code, Cursor, or Codex, points at one endpoint and reaches every
143− tool you connect.
141+ Executor is an integration platform and MCP gateway for AI agents.
142+ It lets you securely connect services such as Google Workspace,
143+ GitHub, and Slack, then read data and run the actions you request
144+ through one endpoint.
144145 </p>
145146 <div class="rise-3 max-w-[760px] mx-auto mb-12">
146147 <div class="surface-card p-6 sm:p-7">
apps/marketing/src/pages/privacy.astro+14−4
@@ -57,10 +57,15 @@ import LegalLayout from "../components/LegalLayout.astro"; · 56 unmodified lines
5757 <p>
5858 If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you
5959 request through that integration. Depending on the service and permissions you choose, this may include accessing
60− or modifying Google Calendar events, Google Sheets spreadsheets, or other Google Workspace content. Executor stores
61− OAuth credentials and connection metadata so the integration can continue working, and processes Google Workspace
62− content when carrying out your requested tool calls and returning their results to you or the agent you directed to
63− make the call.
60+ or modifying Google Calendar events, Google Sheets spreadsheets, Gmail messages, drafts, threads, attachments,
61+ labels, or other Google Workspace content. For Gmail, this can include reading and searching email, composing and
62+ sending messages, and organizing or moving messages to Trash when you request those actions.
63+ </p>
64+ <p>
65+ Executor stores OAuth credentials and connection metadata so the integration can continue working. It processes
66+ Google Workspace content only while carrying out your requested tool calls and returning their results to you or
67+ the agent you directed to make the call. That content can appear in the resulting agent session or in another
68+ service when you explicitly direct Executor to send it there.
6469 </p>
6570 <p>
6671 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"; · 5 unmodified lines
7277 <a href="https://developers.google.com/terms/api-services-user-data-policy">Google API Services User Data Policy</a>,
7378 including its Limited Use requirements.
7479 </p>
80+ <p>
81+ You can stop Executor&apos;s access by deleting the Google connection in Executor or revoking Executor from your
82+ Google Account permissions. To request deletion of your Executor account, stored OAuth credentials, connection
83+ metadata, or other personal information, email <a href="mailto:rhys@executor.sh">rhys@executor.sh</a>.
84+ </p>
7585
7686 <h3>Website inputs and support communications</h3>
7787 <p>
e2e/scenarios/first-party-oauth.test.ts+36−4
@@ -168,7 +168,7 @@ scenario( · 167 unmodified lines
168168 );
169169
170170 scenario(
171− "First-party OAuth · Google offers Calendar and Sheets but refuses Gmail scopes",
171+ "First-party OAuth · Google offers Gmail modify but refuses full Gmail and Drive scopes",
172172 {},
173173 Effect.scoped(
174174 Effect.gen(function* () {
@@ -184,6 +184,7 @@ scenario( · 9 unmodified lines
184184 expect(google?.origin.kind).toBe("first_party");
185185 if (google?.origin.kind !== "first_party") return;
186186 expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/calendar");
187+ expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/gmail.modify");
187188 expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets");
188189 expect(google.origin.allowedScopes).not.toContain("https://mail.google.com/");
189190 expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/drive");
@@ -229,19 +230,50 @@ scenario( · 39 unmodified lines
229230 "openid",
230231 "email",
231232 "profile",
232− "https://mail.google.com/",
233+ "https://www.googleapis.com/auth/gmail.modify",
233234 ]),
234235 slug: gmail,
235236 },
236237 });
238+ const gmailStarted = yield* client.oauth.start({
239+ payload: {
240+ client: OAuthClientSlug.make("first-party:google"),
241+ clientOwner: "org",
242+ owner: "org",
243+ name: ConnectionName.make("gmail"),
244+ integration: gmail,
245+ template: AuthTemplateSlug.make("oauth"),
246+ },
247+ });
248+ expect(gmailStarted.status).toBe("redirect");
249+ const gmailAuthorizationUrl =
250+ gmailStarted.status === "redirect" ? gmailStarted.authorizationUrl : "";
251+ expect(
252+ new Set(new URL(gmailAuthorizationUrl).searchParams.get("scope")?.split(" ") ?? []),
253+ ).toEqual(
254+ new Set(["openid", "email", "profile", "https://www.googleapis.com/auth/gmail.modify"]),
255+ );
256+
257+ const fullGmail = IntegrationSlug.make(unique("google_gmail_full"));
258+ yield* client.openapi.addSpec({
259+ payload: {
260+ ...googleShapedIntegrationSpec([
261+ "openid",
262+ "email",
263+ "profile",
264+ "https://mail.google.com/",
265+ ]),
266+ slug: fullGmail,
267+ },
268+ });
237269 const blocked = yield* client.oauth
238270 .start({
239271 payload: {
240272 client: OAuthClientSlug.make("first-party:google"),
241273 clientOwner: "org",
242274 owner: "org",
243− name: ConnectionName.make("gmail"),
244− integration: gmail,
275+ name: ConnectionName.make("gmail-full"),
276+ integration: fullGmail,
245277 template: AuthTemplateSlug.make("oauth"),
246278 },
247279 })
packages/plugins/openapi/src/providers/google/discovery.test.ts+72−0
@@ -959,6 +959,78 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI integratio · 958 unmodified lines
959959 }),
960960 );
961961
962+it.effect("filters Gmail operations to the explicitly selected consent scope", () =>
963+ Effect.gen(function* () {
964+ const modifyScope = "https://www.googleapis.com/auth/gmail.modify";
965+ const fullScope = "https://mail.google.com/";
966+ const result = yield* convertGoogleDiscoveryBundleToOpenApi({
967+ consentScopes: [modifyScope],
968+ documents: [
969+ {
970+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
971+ // @effect-diagnostics-next-line preferSchemaOverJson:off
972+ documentText: JSON.stringify({
973+ name: "gmail",
974+ version: "v1",
975+ title: "Gmail API",
976+ rootUrl: "https://gmail.googleapis.com/",
977+ servicePath: "",
978+ auth: {
979+ oauth2: {
980+ scopes: {
981+ [modifyScope]: { description: "Read and modify Gmail" },
982+ [fullScope]: { description: "Full Gmail access" },
983+ },
984+ },
985+ },
986+ resources: {
987+ users: {
988+ resources: {
989+ messages: {
990+ methods: {
991+ list: {
992+ id: "gmail.users.messages.list",
993+ httpMethod: "GET",
994+ path: "gmail/v1/users/{userId}/messages",
995+ scopes: [modifyScope, fullScope],
996+ parameters: {
997+ userId: { location: "path", required: true, type: "string" },
998+ },
999+ },
1000+ delete: {
1001+ id: "gmail.users.messages.delete",
1002+ httpMethod: "DELETE",
1003+ path: "gmail/v1/users/{userId}/messages/{id}",
1004+ scopes: [fullScope],
1005+ parameters: {
1006+ userId: { location: "path", required: true, type: "string" },
1007+ id: { location: "path", required: true, type: "string" },
1008+ },
1009+ },
1010+ },
1011+ },
1012+ },
1013+ },
1014+ },
1015+ schemas: {},
1016+ }),
1017+ },
1018+ ],
1019+ });
1020+
1021+ const spec = decodeConvertedSpec(result.specText);
1022+ const operationIds = Object.values(spec.paths).flatMap((path) =>
1023+ Object.values(path).map((operation) => operation.operationId),
1024+ );
1025+ expect(operationIds).toContain("gmail.users.messages.list");
1026+ expect(operationIds).not.toContain("gmail.users.messages.delete");
1027+ const oauthTemplate = result.authenticationTemplate?.find((entry) => entry.kind === "oauth2");
1028+ expect(oauthTemplate?.kind === "oauth2" ? oauthTemplate.scopes : undefined).toEqual([
1029+ modifyScope,
1030+ ]);
1031+ }),
1032+);
1033+
9621034 // ---------------------------------------------------------------------------
9631035 // The merged bundle scope set is the COMPACTED + FILTERED union: sub-scopes
9641036 // collapse under their broad parent (`gmail.*` → `mail.google.com/`,
packages/plugins/openapi/src/providers/google/discovery.ts+4−7
@@ -831,9 +831,6 @@ const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photosli · 830 unmodified lines
831831 const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload";
832832 const GOOGLE_PHOTOS_UPLOAD_PATH = "/v1/uploads";
833833
834−const isGooglePhotosService = (service: string): boolean =>
835− service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE;
836−
837834 const discoveryScopesForService = (
838835 service: string,
839836 document: DiscoveryDocument,
@@ -1124,10 +1121,10 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 284 unmodified lines
11241121 const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`);
11251122 const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`;
11261123 const scopeDescriptions = discoveryScopesForService(info.service, info.document);
1127− const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service);
1124+ const filterConsentScopes = consentScopeSet !== null;
11281125
11291126 for (const [scope, description] of Object.entries(scopeDescriptions)) {
1130− if (filterPhotosScopes && !consentScopeSet.has(scope)) continue;
1127+ if (filterConsentScopes && !consentScopeSet.has(scope)) continue;
11311128 rawScopes[scope] ??= description;
11321129 }
11331130
@@ -1140,10 +1137,10 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 6 unmodified lines
11401137 const rawPathTemplate = Option.getOrUndefined(method.path);
11411138 if (!methodId || !rawPathTemplate || !method.httpMethod) continue;
11421139 const methodScopes = discoveryMethodScopesForService(info.service, method);
1143− const oauthScopes = filterPhotosScopes
1140+ const oauthScopes = filterConsentScopes
11441141 ? methodScopes.filter((scope) => consentScopeSet.has(scope))
11451142 : methodScopes;
1146− if (filterPhotosScopes && methodScopes.length > 0 && oauthScopes.length === 0) continue;
1143+ if (filterConsentScopes && methodScopes.length > 0 && oauthScopes.length === 0) continue;
11471144
11481145 const toolPath = methodId;
11491146 const wirePath = rawPathTemplate.startsWith("/") ? rawPathTemplate : `/${rawPathTemplate}`;
packages/plugins/openapi/src/providers/google/presets.ts+1−1
@@ -250,7 +250,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = · 249 unmodified lines
250250
251251 export const googleOAuthConsentScopes: Readonly<Record<string, readonly string[]>> = {
252252 "google-calendar": ["https://www.googleapis.com/auth/calendar"],
253− "google-gmail": ["https://mail.google.com/"],
253+ "google-gmail": ["https://www.googleapis.com/auth/gmail.modify"],
254254 "google-sheets": ["https://www.googleapis.com/auth/spreadsheets"],
255255 "google-drive": ["https://www.googleapis.com/auth/drive"],
256256 "google-docs": ["https://www.googleapis.com/auth/documents"],
packages/plugins/openapi/src/providers/google/spec-format-adapter.ts+4−1
@@ -61,7 +61,10 @@ export const googleDiscoveryAdapter: SpecFormatAdapter = { · 60 unmodified lines
6161 ),
6262 { concurrency: 4 },
6363 );
64− const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({ documents });
64+ const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({
65+ documents,
66+ ...(input.consentScopes ? { consentScopes: input.consentScopes } : {}),
67+ });
6568 const document =
6669 documents.length === 1
6770 ? yield* parseJson(documents[0]!.documentText)
packages/plugins/openapi/src/sdk/plugin.ts+14−1
@@ -646,7 +646,13 @@ export const openApiPlugin = definePlugin< · 645 unmodified lines
646646 const resolveSpecForInput = (
647647 config: Pick<
648648 OpenApiSpecConfig,
649− "spec" | "specFormat" | "specOverrides" | "headers" | "queryParams" | "baseUrl"
649+ | "spec"
650+ | "specFormat"
651+ | "specOverrides"
652+ | "headers"
653+ | "queryParams"
654+ | "baseUrl"
655+ | "authenticationTemplate"
650656 >,
651657 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
652658 ): Effect.Effect<
@@ -670,6 +676,13 @@ export const openApiPlugin = definePlugin< · 17 unmodified lines
670676 ...(config.headers ? { headers: config.headers } : {}),
671677 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
672678 },
679+ ...(config.authenticationTemplate
680+ ? {
681+ consentScopes: config.authenticationTemplate.flatMap((template) =>
682+ "kind" in template && template.kind === "oauth2" ? template.scopes : [],
683+ ),
684+ }
685+ : {}),
673686 httpClientLayer,
674687 });
675688 return yield* applyOverridesToResolvedSpec(resolved, config.specOverrides);
packages/plugins/openapi/src/sdk/spec-format.ts+3−0
@@ -15,6 +15,9 @@ export interface SpecFetchCredentials { · 14 unmodified lines
1515 export interface SpecFetchInput {
1616 readonly urls: readonly string[];
1717 readonly credentials?: SpecFetchCredentials;
18+ /** Explicit OAuth scopes selected by the caller. Format adapters may use
19+ * these to omit operations that the resulting connection cannot invoke. */
20+ readonly consentScopes?: readonly string[];
1821 readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>;
1922 }
2023
packages/react/src/components/add-account-modal.tsx+19−0
@@ -1565,6 +1565,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1564 unmodified lines
15651565 [...oauthApps, ...oauthNearApps, ...oauthOtherApps].find(
15661566 (c: OAuthClientOption) => String(c.slug) === selectedApp,
15671567 ) ?? null;
1568+ const isBuiltInGoogleClient =
1569+ chosenClient?.origin.kind === "first_party" &&
1570+ String(chosenClient.slug) === "first-party:google";
15681571 const oauthBusy = ccBusy || oauthPopup.busy;
15691572 const cimdConnecting = cimdBusy || oauthPopup.busy;
15701573 const dcrConnecting = dcrBusy || oauthPopup.busy;
@@ -2880,6 +2883,22 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1309 unmodified lines
28802883 />
28812884 </div>
28822885 )}
2886+
2887+ {isBuiltInGoogleClient && showPlaceStep ? (
2888+ <p className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
2889+ Executor uses the Google data you authorize only to perform the actions you
2890+ request. Read the{` `}
2891+ <a
2892+ href="https://executor.sh/privacy"
2893+ target="_blank"
2894+ rel="noreferrer"
2895+ className="font-medium text-foreground underline underline-offset-2"
2896+ >
2897+ privacy policy
2898+ </a>
2899+ .
2900+ </p>
2901+ ) : null}
28832902 </div>
28842903
28852904 {continueError ? (