Unified · Split
| @@ -89,13 +89,14 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = Layer.succeed( · 88 unmodified lines | ||
| 89 | 89 | */ |
| 90 | 90 | export const CLOUD_MOUNT_PREFIX = "/api" as const; |
| 91 | 91 | |
| 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. | |
| 96 | 96 | const GOOGLE_FIRST_PARTY_ALLOWED_SCOPES: readonly string[] = [ |
| 97 | 97 | ...new Set([ |
| 98 | 98 | ...googleCatalogOAuthScopesForPreset("google-calendar"), |
| 99 | + ...googleCatalogOAuthScopesForPreset("google-gmail"), | |
| 99 | 100 | ...googleCatalogOAuthScopesForPreset("google-sheets"), |
| 100 | 101 | ]), |
| 101 | 102 | ]; |
| @@ -133,14 +133,15 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo · 132 unmodified lines | ||
| 133 | 133 | <h1 |
| 134 | 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 | 137 | </h1> |
| 138 | 138 | <p |
| 139 | 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 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. | |
| 144 | 145 | </p> |
| 145 | 146 | <div class="rise-3 max-w-[760px] mx-auto mb-12"> |
| 146 | 147 | <div class="surface-card p-6 sm:p-7"> |
| @@ -57,10 +57,15 @@ import LegalLayout from "../components/LegalLayout.astro"; · 56 unmodified lines | ||
| 57 | 57 | <p> |
| 58 | 58 | If you connect a Google Workspace service, Executor uses the permissions you grant to perform the actions you |
| 59 | 59 | 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. | |
| 64 | 69 | </p> |
| 65 | 70 | <p> |
| 66 | 71 | 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 | 77 | <a href="https://developers.google.com/terms/api-services-user-data-policy">Google API Services User Data Policy</a>, |
| 73 | 78 | including its Limited Use requirements. |
| 74 | 79 | </p> |
| 80 | + <p> | |
| 81 | + You can stop Executor'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 | 86 | <h3>Website inputs and support communications</h3> |
| 77 | 87 | <p> |
| @@ -168,7 +168,7 @@ scenario( · 167 unmodified lines | ||
| 168 | 168 | ); |
| 169 | 169 | |
| 170 | 170 | 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", | |
| 172 | 172 | {}, |
| 173 | 173 | Effect.scoped( |
| 174 | 174 | Effect.gen(function* () { |
| @@ -184,6 +184,7 @@ scenario( · 9 unmodified lines | ||
| 184 | 184 | expect(google?.origin.kind).toBe("first_party"); |
| 185 | 185 | if (google?.origin.kind !== "first_party") return; |
| 186 | 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 | 188 | expect(google.origin.allowedScopes).toContain("https://www.googleapis.com/auth/spreadsheets"); |
| 188 | 189 | expect(google.origin.allowedScopes).not.toContain("https://mail.google.com/"); |
| 189 | 190 | expect(google.origin.allowedScopes).not.toContain("https://www.googleapis.com/auth/drive"); |
| @@ -229,19 +230,50 @@ scenario( · 39 unmodified lines | ||
| 229 | 230 | "openid", |
| 230 | 231 | "email", |
| 231 | 232 | "profile", |
| 232 | − "https://mail.google.com/", | |
| 233 | + "https://www.googleapis.com/auth/gmail.modify", | |
| 233 | 234 | ]), |
| 234 | 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 | 269 | const blocked = yield* client.oauth |
| 238 | 270 | .start({ |
| 239 | 271 | payload: { |
| 240 | 272 | client: OAuthClientSlug.make("first-party:google"), |
| 241 | 273 | clientOwner: "org", |
| 242 | 274 | owner: "org", |
| 243 | − name: ConnectionName.make("gmail"), | |
| 244 | − integration: gmail, | |
| 275 | + name: ConnectionName.make("gmail-full"), | |
| 276 | + integration: fullGmail, | |
| 245 | 277 | template: AuthTemplateSlug.make("oauth"), |
| 246 | 278 | }, |
| 247 | 279 | }) |
| @@ -959,6 +959,78 @@ it.effect("bundles Google Discovery documents into one Google OpenAPI integratio · 958 unmodified lines | ||
| 959 | 959 | }), |
| 960 | 960 | ); |
| 961 | 961 | |
| 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 | + | |
| 962 | 1034 | // --------------------------------------------------------------------------- |
| 963 | 1035 | // The merged bundle scope set is the COMPACTED + FILTERED union: sub-scopes |
| 964 | 1036 | // collapse under their broad parent (`gmail.*` → `mail.google.com/`, |
| @@ -831,9 +831,6 @@ const GOOGLE_PHOTOS_APPENDONLY_SCOPE = "https://www.googleapis.com/auth/photosli · 830 unmodified lines | ||
| 831 | 831 | const GOOGLE_PHOTOS_UPLOAD_TOOL_PATH = "photoslibrary.mediaItems.upload"; |
| 832 | 832 | const GOOGLE_PHOTOS_UPLOAD_PATH = "/v1/uploads"; |
| 833 | 833 | |
| 834 | −const isGooglePhotosService = (service: string): boolean => | |
| 835 | − service === GOOGLE_PHOTOS_LIBRARY_SERVICE || service === GOOGLE_PHOTOS_PICKER_SERVICE; | |
| 836 | − | |
| 837 | 834 | const discoveryScopesForService = ( |
| 838 | 835 | service: string, |
| 839 | 836 | document: DiscoveryDocument, |
| @@ -1124,10 +1121,10 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 284 unmodified lines | ||
| 1124 | 1121 | const schemaPrefix = schemaComponentPart(`${info.service}_${info.version}`); |
| 1125 | 1122 | const schemaNameForRef = (name: string) => `${schemaPrefix}_${schemaComponentPart(name)}`; |
| 1126 | 1123 | const scopeDescriptions = discoveryScopesForService(info.service, info.document); |
| 1127 | − const filterPhotosScopes = consentScopeSet !== null && isGooglePhotosService(info.service); | |
| 1124 | + const filterConsentScopes = consentScopeSet !== null; | |
| 1128 | 1125 | |
| 1129 | 1126 | for (const [scope, description] of Object.entries(scopeDescriptions)) { |
| 1130 | − if (filterPhotosScopes && !consentScopeSet.has(scope)) continue; | |
| 1127 | + if (filterConsentScopes && !consentScopeSet.has(scope)) continue; | |
| 1131 | 1128 | rawScopes[scope] ??= description; |
| 1132 | 1129 | } |
| 1133 | 1130 | |
| @@ -1140,10 +1137,10 @@ export const convertGoogleDiscoveryBundleToOpenApi = Effect.fn( · 6 unmodified lines | ||
| 1140 | 1137 | const rawPathTemplate = Option.getOrUndefined(method.path); |
| 1141 | 1138 | if (!methodId || !rawPathTemplate || !method.httpMethod) continue; |
| 1142 | 1139 | const methodScopes = discoveryMethodScopesForService(info.service, method); |
| 1143 | − const oauthScopes = filterPhotosScopes | |
| 1140 | + const oauthScopes = filterConsentScopes | |
| 1144 | 1141 | ? methodScopes.filter((scope) => consentScopeSet.has(scope)) |
| 1145 | 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 | 1145 | const toolPath = methodId; |
| 1149 | 1146 | const wirePath = rawPathTemplate.startsWith("/") ? rawPathTemplate : `/${rawPathTemplate}`; |
| @@ -250,7 +250,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] = · 249 unmodified lines | ||
| 250 | 250 | |
| 251 | 251 | export const googleOAuthConsentScopes: Readonly<Record<string, readonly string[]>> = { |
| 252 | 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 | 254 | "google-sheets": ["https://www.googleapis.com/auth/spreadsheets"], |
| 255 | 255 | "google-drive": ["https://www.googleapis.com/auth/drive"], |
| 256 | 256 | "google-docs": ["https://www.googleapis.com/auth/documents"], |
| @@ -61,7 +61,10 @@ export const googleDiscoveryAdapter: SpecFormatAdapter = { · 60 unmodified lines | ||
| 61 | 61 | ), |
| 62 | 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 | 68 | const document = |
| 66 | 69 | documents.length === 1 |
| 67 | 70 | ? yield* parseJson(documents[0]!.documentText) |
| @@ -646,7 +646,13 @@ export const openApiPlugin = definePlugin< · 645 unmodified lines | ||
| 646 | 646 | const resolveSpecForInput = ( |
| 647 | 647 | config: Pick< |
| 648 | 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 | 657 | httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>, |
| 652 | 658 | ): Effect.Effect< |
| @@ -670,6 +676,13 @@ export const openApiPlugin = definePlugin< · 17 unmodified lines | ||
| 670 | 676 | ...(config.headers ? { headers: config.headers } : {}), |
| 671 | 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 | 686 | httpClientLayer, |
| 674 | 687 | }); |
| 675 | 688 | return yield* applyOverridesToResolvedSpec(resolved, config.specOverrides); |
| @@ -15,6 +15,9 @@ export interface SpecFetchCredentials { · 14 unmodified lines | ||
| 15 | 15 | export interface SpecFetchInput { |
| 16 | 16 | readonly urls: readonly string[]; |
| 17 | 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 | 21 | readonly httpClientLayer: Layer.Layer<HttpClient.HttpClient, never, never>; |
| 19 | 22 | } |
| 20 | 23 | |
| @@ -1565,6 +1565,9 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1564 unmodified lines | ||
| 1565 | 1565 | [...oauthApps, ...oauthNearApps, ...oauthOtherApps].find( |
| 1566 | 1566 | (c: OAuthClientOption) => String(c.slug) === selectedApp, |
| 1567 | 1567 | ) ?? null; |
| 1568 | + const isBuiltInGoogleClient = | |
| 1569 | + chosenClient?.origin.kind === "first_party" && | |
| 1570 | + String(chosenClient.slug) === "first-party:google"; | |
| 1568 | 1571 | const oauthBusy = ccBusy || oauthPopup.busy; |
| 1569 | 1572 | const cimdConnecting = cimdBusy || oauthPopup.busy; |
| 1570 | 1573 | const dcrConnecting = dcrBusy || oauthPopup.busy; |
| @@ -2880,6 +2883,22 @@ function AddAccountModalView(props: AddAccountModalProps) { · 1309 unmodified lines | ||
| 2880 | 2883 | /> |
| 2881 | 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 | 2902 | </div> |
| 2884 | 2903 | |
| 2885 | 2904 | {continueError ? ( |