Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions apps/cloud/src/engine/execution-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,13 +89,14 @@ export const CloudPluginsProvider: Layer.Layer<PluginsProvider> = 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"),
]),
];
Expand Down
9 changes: 5 additions & 4 deletions apps/marketing/src/pages/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -133,14 +133,15 @@ Source (and the place to start if something breaks): https://github.com/UsefulSo
<h1
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"
>
Connect any agent to <span class="serif-italic">everything</span>.
Executor connects any agent to <span class="serif-italic">everything</span>.
</h1>
<p
class="rise-2 mx-auto mb-12 max-w-[50ch] text-[clamp(1.05rem,2.4vw,1.3rem)] leading-[1.5] text-ink-2"
>
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.
</p>
<div class="rise-3 max-w-[760px] mx-auto mb-12">
<div class="surface-card p-6 sm:p-7">
Expand Down
18 changes: 14 additions & 4 deletions apps/marketing/src/pages/privacy.astro
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,15 @@ import LegalLayout from "../components/LegalLayout.astro";
<p>
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.
</p>
<p>
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.
</p>
<p>
We do not use Google Workspace API data for advertising or to train generalized artificial-intelligence or
Expand All @@ -72,6 +77,11 @@ import LegalLayout from "../components/LegalLayout.astro";
<a href="https://developers.google.com/terms/api-services-user-data-policy">Google API Services User Data Policy</a>,
including its Limited Use requirements.
</p>
<p>
You can stop Executor&apos;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 <a href="mailto:rhys@executor.sh">rhys@executor.sh</a>.
</p>

<h3>Website inputs and support communications</h3>
<p>
Expand Down
40 changes: 36 additions & 4 deletions e2e/scenarios/first-party-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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* () {
Expand All @@ -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");
Expand Down Expand Up @@ -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"),
},
})
Expand Down
72 changes: 72 additions & 0 deletions packages/plugins/openapi/src/providers/google/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/`,
Expand Down
11 changes: 4 additions & 7 deletions packages/plugins/openapi/src/providers/google/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
}

Expand All @@ -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}`;
Expand Down
2 changes: 1 addition & 1 deletion packages/plugins/openapi/src/providers/google/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -250,7 +250,7 @@ export const googlePhotosOpenApiPresets: readonly GoogleOpenApiPreset[] =

export const googleOAuthConsentScopes: Readonly<Record<string, readonly string[]>> = {
"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"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
15 changes: 14 additions & 1 deletion packages/plugins/openapi/src/sdk/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpClient.HttpClient, never, never>,
): Effect.Effect<
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions packages/plugins/openapi/src/sdk/spec-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HttpClient.HttpClient, never, never>;
}

Expand Down
19 changes: 19 additions & 0 deletions packages/react/src/components/add-account-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -2880,6 +2883,22 @@ function AddAccountModalView(props: AddAccountModalProps) {
/>
</div>
)}

{isBuiltInGoogleClient && showPlaceStep ? (
<p className="rounded-md border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
Executor uses the Google data you authorize only to perform the actions you
request. Read the{` `}
<a
href="https://executor.sh/privacy"
target="_blank"
rel="noreferrer"
className="font-medium text-foreground underline underline-offset-2"
>
privacy policy
</a>
.
</p>
) : null}
</div>

{continueError ? (
Expand Down
Loading