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
89 */89 */
90export const CLOUD_MOUNT_PREFIX = "/api" as const;90export const CLOUD_MOUNT_PREFIX = "/api" as const;
91 91
92// Initial Google launch boundary. Calendar + Sheets are sensitive scopes but92// Initial Google launch boundary. Gmail uses gmail.modify for read, send, and
93// not restricted Workspace scopes; Gmail and account-wide Drive remain absent93// trash operations while immediate permanent deletion remains absent until the
94// until their separate verification/security work is complete. The same scope94// broader mail.google.com scope is approved. Account-wide Drive remains absent.
95// source builds the catalog auth templates, preventing config drift.95// The same scope source builds the catalog auth templates, preventing drift.
96const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [96const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [
97 ...new Set([97 ...new Set([
98 ...googleCatalogOAuthScopesForPreset("google-calendar"),98 ...googleCatalogOAuthScopesForPreset("google-calendar"),
99 ...googleCatalogOAuthScopesForPreset("google-gmail"),
99 ...googleCatalogOAuthScopesForPreset("google-sheets"),100 ...googleCatalogOAuthScopesForPreset("google-sheets"),
100 ]),101 ]),
101];102];
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
133 <h1133 <h1
134 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"134 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"
135 >135 >
136 Connect any agent to <span class="serif-italic">everything</span>.136 Executor connects any agent to <span class="serif-italic">everything</span>.
137 </h1>137 </h1>
138 <p138 <p
139 class="rise-2 mx-auto mb-12 max-w-[50ch] text-[clamp(1.05rem,2.4vw,1.3rem)] leading-[1.5] text-ink-2"139 class="rise-2 mx-auto mb-12 max-w-[50ch] text-[clamp(1.05rem,2.4vw,1.3rem)] leading-[1.5] text-ink-2"
140 >140 >
141 Executor is an MCP gateway. Anything that speaks MCP, like Claude141 Executor is an integration platform and MCP gateway for AI agents.
142 Code, Cursor, or Codex, points at one endpoint and reaches every142 It lets you securely connect services such as Google Workspace,
143 tool you connect.143 GitHub, and Slack, then read data and run the actions you request
144 through one endpoint.
144 </p>145 </p>
145 <div class="rise-3 max-w-[760px] mx-auto mb-12">146 <div class="rise-3 max-w-[760px] mx-auto mb-12">
146 <div class="surface-card p-6 sm:p-7">147 <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
57 <p>57 <p>
58 If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you58 If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you
59 request through that integration. Depending on the service and permissions you choose, this may include accessing59 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 stores60 or modifying Google Calendar events, Google Sheets spreadsheets, Gmail messages, drafts, threads, attachments,
61 OAuth credentials and connection metadata so the integration can continue working, and processes Google Workspace61 labels, or other Google Workspace content. For Gmail, this can include reading and searching email, composing and
62 content when carrying out your requested tool calls and returning their results to you or the agent you directed to62 sending messages, and organizing or moving messages to Trash when you request those actions.
63 make the call.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.
64 </p>69 </p>
65 <p>70 <p>
66 We do not use Google Workspace API data for advertising or to train generalized artificial-intelligence or71 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
72 <a href="https://developers.google.com/terms/api-services-user-data-policy">Google API Services User Data Policy</a>,77 <a href="https://developers.google.com/terms/api-services-user-data-policy">Google API Services User Data Policy</a>,
73 including its Limited Use requirements.78 including its Limited Use requirements.
74 </p>79 </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>
75 85
76 <h3>Website inputs and support communications</h3>86 <h3>Website inputs and support communications</h3>
77 <p>87 <p>
e2e/scenarios/first-party-oauth.test.ts+36−4
@@ -168,7 +168,7 @@ scenario( · 167 unmodified lines
168);168);
169 169
170scenario(170scenario(
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",
172 {},172 {},
173 Effect.scoped(173 Effect.scoped(
174 Effect.gen(function* () {174 Effect.gen(function* () {
@@ -184,6 +184,7 @@ scenario( · 9 unmodified lines
184 expect(google?.origin.kind).toBe("first_party");184 expect(google?.origin.kind).toBe("first_party");
185 if (google?.origin.kind !== "first_party") return;185 if (google?.origin.kind !== "first_party") return;
186 expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/calendar");186 expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/calendar");
187 expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/gmail.modify");
187 expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets");188 expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets");
188 expect(google.origin.allowedScopes).not.toContain("https://mail.google.com/");189 expect(google.origin.allowedScopes).not.toContain("https://mail.google.com/");
189 expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/drive");190 expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/drive");
@@ -229,19 +230,50 @@ scenario( · 39 unmodified lines
229 "openid",230 "openid",
230 "email",231 "email",
231 "profile",232 "profile",
232 "https://mail.google.com/",233 "https://www.googleapis.com/auth/gmail.modify",
233 ]),234 ]),
234 slug: gmail,235 slug: gmail,
235 },236 },
236 });237 });
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 });
237 const blocked = yield* client.oauth269 const blocked = yield* client.oauth
238 .start({270 .start({
239 payload: {271 payload: {
240 client: OAuthClientSlug.make("first-party:google"),272 client: OAuthClientSlug.make("first-party:google"),
241 clientOwner: "org",273 clientOwner: "org",
242 owner: "org",274 owner: "org",
243 name: ConnectionName.make("gmail"),275 name: ConnectionName.make("gmail-full"),
244 integration: gmail,276 integration: fullGmail,
245 template: AuthTemplateSlug.make("oauth"),277 template: AuthTemplateSlug.make("oauth"),
246 },278 },
247 })279 })
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
959 }),959 }),
960);960);
961 961
962it.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
962// ---------------------------------------------------------------------------1034// ---------------------------------------------------------------------------
963// The merged bundle scope set is the COMPACTED + FILTERED union: sub-scopes1035// The merged bundle scope set is the COMPACTED + FILTERED union: sub-scopes
964// collapse under their broad parent (`gmail.*` → `mail.google.com/`,1036// 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
831const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload";831const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload";
832const GOOGLE_PHOTOS_UPLOAD_PATH = "/v1/uploads";832const GOOGLE_PHOTOS_UPLOAD_PATH = "/v1/uploads";
833 833
834const isGooglePhotosService = (service: string): boolean =>
835 service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE;
836
837const discoveryScopesForService = (834const discoveryScopesForService = (
838 service: string,835 service: string,
839 document: DiscoveryDocument,836 document: DiscoveryDocument,
@@ -1124,10 +1121,10 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 284 unmodified lines
1124 const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`);1121 const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`);
1125 const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`;1122 const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`;
1126 const scopeDescriptions = discoveryScopesForService(info.service, info.document);1123 const scopeDescriptions = discoveryScopesForService(info.service, info.document);
1127 const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service);1124 const filterConsentScopes = consentScopeSet !== null;
1128 1125
1129 for (const [scope, description] of Object.entries(scopeDescriptions)) {1126 for (const [scope, description] of Object.entries(scopeDescriptions)) {
1130 if (filterPhotosScopes && !consentScopeSet.has(scope)) continue;1127 if (filterConsentScopes && !consentScopeSet.has(scope)) continue;
1131 rawScopes[scope] ??= description;1128 rawScopes[scope] ??= description;
1132 }1129 }
1133 1130
@@ -1140,10 +1137,10 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 6 unmodified lines
1140 const rawPathTemplate = Option.getOrUndefined(method.path);1137 const rawPathTemplate = Option.getOrUndefined(method.path);
1141 if (!methodId || !rawPathTemplate || !method.httpMethod) continue;1138 if (!methodId || !rawPathTemplate || !method.httpMethod) continue;
1142 const methodScopes = discoveryMethodScopesForService(info.service, method);1139 const methodScopes = discoveryMethodScopesForService(info.service, method);
1143 const oauthScopes = filterPhotosScopes1140 const oauthScopes = filterConsentScopes
1144 ? methodScopes.filter((scope) => consentScopeSet.has(scope))1141 ? methodScopes.filter((scope) => consentScopeSet.has(scope))
1145 : methodScopes;1142 : methodScopes;
1146 if (filterPhotosScopes && methodScopes.length > 0 && oauthScopes.length === 0) continue;1143 if (filterConsentScopes && methodScopes.length > 0 && oauthScopes.length === 0) continue;
1147 1144
1148 const toolPath = methodId;1145 const toolPath = methodId;
1149 const wirePath = rawPathTemplate.startsWith("/") ? rawPathTemplate : `/${rawPathTemplate}`;1146 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
250 250
251export const googleOAuthConsentScopes: Readonly<Record<string, readonly string[]>> = {251export const googleOAuthConsentScopes: Readonly<Record<string, readonly string[]>> = {
252 "google-calendar": ["https://www.googleapis.com/auth/calendar"],252 "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"],
254 "google-sheets": ["https://www.googleapis.com/auth/spreadsheets"],254 "google-sheets": ["https://www.googleapis.com/auth/spreadsheets"],
255 "google-drive": ["https://www.googleapis.com/auth/drive"],255 "google-drive": ["https://www.googleapis.com/auth/drive"],
256 "google-docs": ["https://www.googleapis.com/auth/documents"],256 "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
61 ),61 ),
62 { concurrency: 4 },62 { concurrency: 4 },
63 );63 );
64 const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({ documents });64 const conversion = yield* convertGoogleDiscoveryBundleToOpenApi({
65 documents,
66 ...(input.consentScopes ? { consentScopes: input.consentScopes } : {}),
67 });
65 const document =68 const document =
66 documents.length === 169 documents.length === 1
67 ? yield* parseJson(documents[0]!.documentText)70 ? 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
646 const resolveSpecForInput = (646 const resolveSpecForInput = (
647 config: Pick<647 config: Pick<
648 OpenApiSpecConfig,648 OpenApiSpecConfig,
649 "spec" | "specFormat" | "specOverrides" | "headers" | "queryParams" | "baseUrl"649 | "spec"
650 | "specFormat"
651 | "specOverrides"
652 | "headers"
653 | "queryParams"
654 | "baseUrl"
655 | "authenticationTemplate"
650 >,656 >,
651 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,657 httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>,
652 ): Effect.Effect<658 ): Effect.Effect<
@@ -670,6 +676,13 @@ export const openApiPlugin = definePlugin< · 17 unmodified lines
670 ...(config.headers ? { headers: config.headers } : {}),676 ...(config.headers ? { headers: config.headers } : {}),
671 ...(config.queryParams ? { queryParams: config.queryParams } : {}),677 ...(config.queryParams ? { queryParams: config.queryParams } : {}),
672 },678 },
679 ...(config.authenticationTemplate
680 ? {
681 consentScopes: config.authenticationTemplate.flatMap((template) =>
682 "kind" in template && template.kind === "oauth2" ? template.scopes : [],
683 ),
684 }
685 : {}),
673 httpClientLayer,686 httpClientLayer,
674 });687 });
675 return yield* applyOverridesToResolvedSpec(resolved, config.specOverrides);688 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
15export interface SpecFetchInput {15export interface SpecFetchInput {
16 readonly urls: readonly string[];16 readonly urls: readonly string[];
17 readonly credentials?: SpecFetchCredentials;17 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[];
18 readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>;21 readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>;
19}22}
20 23
packages/react/src/components/add-account-modal.tsx+19−0
@@ -1565,6 +1565,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1564 unmodified lines
1565 [...oauthApps, ...oauthNearApps, ...oauthOtherApps].find(1565 [...oauthApps, ...oauthNearApps, ...oauthOtherApps].find(
1566 (c: OAuthClientOption) => String(c.slug) === selectedApp,1566 (c: OAuthClientOption) => String(c.slug) === selectedApp,
1567 ) ?? null;1567 ) ?? null;
1568 const isBuiltInGoogleClient =
1569 chosenClient?.origin.kind === "first_party" &&
1570 String(chosenClient.slug) === "first-party:google";
1568 const oauthBusy = ccBusy || oauthPopup.busy;1571 const oauthBusy = ccBusy || oauthPopup.busy;
1569 const cimdConnecting = cimdBusy || oauthPopup.busy;1572 const cimdConnecting = cimdBusy || oauthPopup.busy;
1570 const dcrConnecting = dcrBusy || oauthPopup.busy;1573 const dcrConnecting = dcrBusy || oauthPopup.busy;
@@ -2880,6 +2883,22 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1309 unmodified lines
2880 />2883 />
2881 </div>2884 </div>
2882 )}2885 )}
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}
2883 </div>2902 </div>
2884 2903
2885 {continueError ? (2904 {continueError ? (