From 68e2c7835b30d28b618df0b3dbc2218827928dfc Mon Sep 17 00:00:00 2001 From: Rhys Sullivan Date: Thu, 25 Jun 2026 17:04:40 -0700 Subject: [PATCH] feat(google): add agent setup tools for Google integration Adds three static-source tools so an agent can set up Google products through chat, mirroring the web product-picker flow: - listProducts: read-only catalog of available Google products with their OAuth audience tier and special-consent flags. - addBundle: resolves selected product ids to Discovery specs and registers one integration covering them; surfaces audience warnings and the OAuth next steps. - setupStatus: read-only report of whether Google is configured. Secret entry stays in the web UI: the tools never accept a client secret and instead point the agent at the OAuth handoff flow, keeping the client secret off the agent channel. The product catalog and audience-warning helpers are consolidated in the SDK presets module and reused by both the agent tools and the React accounts panel. --- .../google/src/react/GoogleAccountsPanel.tsx | 14 +- .../google/src/sdk/agent-tools.test.ts | 331 ++++++++++++++++++ packages/plugins/google/src/sdk/index.ts | 3 + packages/plugins/google/src/sdk/plugin.ts | 225 +++++++++++- packages/plugins/google/src/sdk/presets.ts | 29 ++ 5 files changed, 588 insertions(+), 14 deletions(-) create mode 100644 packages/plugins/google/src/sdk/agent-tools.test.ts diff --git a/packages/plugins/google/src/react/GoogleAccountsPanel.tsx b/packages/plugins/google/src/react/GoogleAccountsPanel.tsx index 7d7ed655d..5124e7189 100644 --- a/packages/plugins/google/src/react/GoogleAccountsPanel.tsx +++ b/packages/plugins/google/src/react/GoogleAccountsPanel.tsx @@ -23,14 +23,7 @@ import { import type { Authentication } from "@executor-js/plugin-openapi"; import { googleConfigAtom, googleConfigure } from "./atoms"; -import { googleAudienceWarningsForUrls } from "../sdk/presets"; - -const GOOGLE_AUDIENCE_WARNING: Readonly> = { - "workspace-admin": - "This connection includes Google Workspace admin APIs. Connecting requires a Workspace admin account; personal Gmail accounts cannot grant these scopes.", - "unsupported-user": - "This connection includes APIs that Google does not grant through standard user OAuth consent. Those tools may fail to authorize.", -}; +import { googleAudienceWarningMessagesForUrls } from "../sdk/presets"; const NO_AUTH_METHOD: AuthMethod = { id: "none", @@ -96,10 +89,7 @@ export default function GoogleAccountsPanel(props: { const audienceWarnings = useMemo(() => { if (!AsyncResult.isSuccess(configResult) || configResult.value == null) return []; const urls = configResult.value.googleDiscoveryUrls ?? []; - return googleAudienceWarningsForUrls(urls).flatMap((audience: string) => { - const message = GOOGLE_AUDIENCE_WARNING[audience]; - return message ? [message] : []; - }); + return googleAudienceWarningMessagesForUrls(urls); }, [configResult]); return ( diff --git a/packages/plugins/google/src/sdk/agent-tools.test.ts b/packages/plugins/google/src/sdk/agent-tools.test.ts new file mode 100644 index 000000000..e872ab317 --- /dev/null +++ b/packages/plugins/google/src/sdk/agent-tools.test.ts @@ -0,0 +1,331 @@ +// --------------------------------------------------------------------------- +// Google agent-facing setup tools (`listProducts`, `addBundle`, `setupStatus`). +// +// These mirror the web Add-Google flow so an agent configuring Google by +// conversation gets the same guided experience the product picker gives a +// human: list products by name, bundle the chosen ids in one call, and receive +// the exact OAuth next steps. They are static source tools, dispatched by their +// fqid through `executor.execute("executor.google.", input)` and returning +// the `{ ok, data }` / `{ ok, error }` ToolResult envelope. +// +// The stub Discovery host serves canonical `www.googleapis.com` Discovery +// documents. `normalizeGoogleDiscoveryUrl` rewrites every product URL (even +// Keep's `keep.googleapis.com/$discovery` form) to that canonical shape before +// fetching, so the stub is keyed on the normalized URLs the tools actually hit. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; +import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; + +import { ToolAddress, createExecutor } from "@executor-js/sdk"; +import { makeTestConfig, memoryCredentialsPlugin } from "@executor-js/sdk/testing"; + +import { googlePlugin } from "./plugin"; +import { GOOGLE_AUDIENCE_WARNING, googleOpenApiPresets } from "./presets"; + +// --- Canned Discovery documents ------------------------------------------- +// One method each; enough for the bundle converter to register a tool. Calendar +// and Gmail are standard-user; Chat is workspace-admin and Keep is +// unsupported-user, so a bundle spanning them exercises both consent warnings. + +const discoveryDoc = (input: { + readonly name: string; + readonly version: string; + readonly title: string; + readonly rootUrl: string; + readonly servicePath: string; + readonly scope: string; + readonly methodId: string; + readonly path: string; +}) => ({ + name: input.name, + version: input.version, + title: input.title, + rootUrl: input.rootUrl, + servicePath: input.servicePath, + auth: { oauth2: { scopes: { [input.scope]: { description: input.title } } } }, + resources: { + items: { + methods: { + list: { + id: input.methodId, + httpMethod: "GET", + path: input.path, + scopes: [input.scope], + parameters: {}, + }, + }, + }, + }, + schemas: { + Item: { id: "Item", type: "object", properties: { id: { type: "string" } } }, + }, +}); + +// Every product URL normalizes to the canonical www.googleapis.com Discovery +// endpoint before fetch, so the stub is keyed on those normalized URLs. +const canonical = (service: string, version: string) => + `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`; + +const DISCOVERY_BODIES: Readonly> = { + [canonical("calendar", "v3")]: JSON.stringify( + discoveryDoc({ + name: "calendar", + version: "v3", + title: "Calendar API", + rootUrl: "https://www.googleapis.com/", + servicePath: "calendar/v3/", + scope: "https://www.googleapis.com/auth/calendar", + methodId: "calendar.events.list", + path: "calendars/{calendarId}/events", + }), + ), + [canonical("gmail", "v1")]: JSON.stringify( + discoveryDoc({ + name: "gmail", + version: "v1", + title: "Gmail API", + rootUrl: "https://gmail.googleapis.com/", + servicePath: "", + scope: "https://mail.google.com/", + methodId: "gmail.users.messages.list", + path: "gmail/v1/users/{userId}/messages", + }), + ), + [canonical("chat", "v1")]: JSON.stringify( + discoveryDoc({ + name: "chat", + version: "v1", + title: "Google Chat API", + rootUrl: "https://chat.googleapis.com/", + servicePath: "", + scope: "https://www.googleapis.com/auth/chat.spaces", + methodId: "chat.spaces.list", + path: "v1/spaces", + }), + ), + [canonical("keep", "v1")]: JSON.stringify( + discoveryDoc({ + name: "keep", + version: "v1", + title: "Google Keep API", + rootUrl: "https://keep.googleapis.com/", + servicePath: "", + scope: "https://www.googleapis.com/auth/keep", + methodId: "keep.notes.list", + path: "v1/notes", + }), + ), +}; + +// A stub HTTP client that serves the canned Discovery document for whichever +// canonical URL the bundle converter fetches; `requests` counts every fetch so +// a test can prove a tool validated input BEFORE reaching the network. +const makeStub = () => { + const counter = { requests: 0 }; + const layer = Layer.succeed(HttpClient.HttpClient)( + HttpClient.make((request: HttpClientRequest.HttpClientRequest) => { + counter.requests += 1; + const url = new URL(request.url); + const body = DISCOVERY_BODIES[`${url.origin}${url.pathname}`]; + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + body === undefined + ? new Response("not found", { status: 404 }) + : new Response(body, { + status: 200, + headers: { "content-type": "application/json" }, + }), + ), + ); + }), + ); + return { counter, layer }; +}; + +const address = (tool: string) => ToolAddress.make(`executor.google.${tool}`); + +// --- ToolResult envelope helpers ------------------------------------------ + +interface ToolOk { + readonly ok: true; + readonly data: T; +} +interface ToolFail { + readonly ok: false; + readonly error: { readonly code: string; readonly message: string }; +} + +const expectOk = (raw: unknown): T => { + expect((raw as { ok: boolean }).ok).toBe(true); + return (raw as ToolOk).data; +}; + +const expectFail = (raw: unknown): ToolFail["error"] => { + expect((raw as { ok: boolean }).ok).toBe(false); + return (raw as ToolFail).error; +}; + +interface ListProductsData { + readonly products: readonly { + readonly id: string; + readonly name: string; + readonly oauthAudience: string; + readonly consentScopes: readonly string[]; + readonly recommended: boolean; + readonly needsSpecialConsent: boolean; + }[]; +} + +interface AddBundleData { + readonly slug: string; + readonly toolCount: number; + readonly products: readonly string[]; + readonly audienceWarnings: readonly string[]; + readonly nextSteps: string; +} + +interface SetupStatusData { + readonly configured: boolean; + readonly slug: string; + readonly products: readonly string[]; + readonly discoveryUrls: readonly string[]; + readonly audienceWarnings: readonly string[]; + readonly nextSteps: string; +} + +const plugins = (layer: Layer.Layer) => + [googlePlugin({ httpClientLayer: layer }), memoryCredentialsPlugin()] as const; + +describe("Google agent setup tools", () => { + it.effect("listProducts mirrors the catalog with recommended and consent flags", () => + Effect.gen(function* () { + const { layer, counter } = makeStub(); + const executor = yield* createExecutor(makeTestConfig({ plugins: plugins(layer) })); + + const data = expectOk(yield* executor.execute(address("listProducts"), {})); + + // One entry per catalog preset, and listing never touches the network. + expect(data.products.length).toBe(googleOpenApiPresets.length); + expect(counter.requests).toBe(0); + + const byId = new Map(data.products.map((product) => [product.id, product] as const)); + + const gmail = byId.get("google-gmail"); + expect(gmail?.recommended).toBe(true); + expect(gmail?.needsSpecialConsent).toBe(false); + expect(gmail?.consentScopes).toContain("https://mail.google.com/"); + + // workspace-admin and unsupported-user are the special-consent tiers. + expect(byId.get("google-chat")?.needsSpecialConsent).toBe(true); + expect(byId.get("google-keep")?.needsSpecialConsent).toBe(true); + + // advanced-user is NOT special consent, and is not a picker default. + const youtube = byId.get("google-youtube-data"); + expect(youtube?.needsSpecialConsent).toBe(false); + expect(youtube?.recommended).toBe(false); + }), + ); + + it.effect("addBundle resolves product ids to one integration with OAuth next steps", () => + Effect.gen(function* () { + const { layer } = makeStub(); + const executor = yield* createExecutor(makeTestConfig({ plugins: plugins(layer) })); + + const data = expectOk( + yield* executor.execute(address("addBundle"), { + productIds: ["google-calendar", "google-gmail"], + slug: "google", + }), + ); + + expect(data.slug).toBe("google"); + expect(data.toolCount).toBeGreaterThanOrEqual(2); + expect([...data.products]).toEqual(["google-calendar", "google-gmail"]); + expect([...data.audienceWarnings]).toEqual([]); + // The connect step hands secret entry to the web UI, never to chat. + expect(data.nextSteps).toContain("oauth.clients.createHandoff"); + expect(data.nextSteps).toContain("Never ask for the client secret in chat"); + + // One integration was actually registered under the chosen slug. + const integration = yield* executor.google.getIntegration("google"); + expect(integration?.slug).toBeDefined(); + }), + ); + + it.effect("addBundle rejects an unknown product id before any fetch", () => + Effect.gen(function* () { + const { layer, counter } = makeStub(); + const executor = yield* createExecutor(makeTestConfig({ plugins: plugins(layer) })); + + const raw = yield* executor.execute(address("addBundle"), { + productIds: ["google-calendar", "not-a-product"], + }); + + // Name the bad id back to the caller so the agent can correct itself. + expect(raw).toMatchObject({ + ok: false, + error: { code: "unknown_product", message: expect.stringContaining("not-a-product") }, + }); + // Validation happens before the network is touched, and nothing registers. + expect(counter.requests).toBe(0); + expect(yield* executor.google.getIntegration("google")).toBeNull(); + }), + ); + + it.effect("addBundle fails when no products and no custom urls are given", () => + Effect.gen(function* () { + const { layer, counter } = makeStub(); + const executor = yield* createExecutor(makeTestConfig({ plugins: plugins(layer) })); + + const error = expectFail(yield* executor.execute(address("addBundle"), {})); + + expect(error.code).toBe("no_products_selected"); + expect(counter.requests).toBe(0); + }), + ); + + it.effect("addBundle surfaces a consent warning per special-consent tier in the bundle", () => + Effect.gen(function* () { + const { layer } = makeStub(); + const executor = yield* createExecutor(makeTestConfig({ plugins: plugins(layer) })); + + const data = expectOk( + yield* executor.execute(address("addBundle"), { + productIds: ["google-calendar", "google-chat", "google-keep"], + slug: "ga", + }), + ); + + const warnings = [...data.audienceWarnings]; + expect(warnings).toContain(GOOGLE_AUDIENCE_WARNING["workspace-admin"]); + expect(warnings).toContain(GOOGLE_AUDIENCE_WARNING["unsupported-user"]); + expect(warnings.length).toBe(2); + }), + ); + + it.effect("setupStatus reports unconfigured before setup and configured after addBundle", () => + Effect.gen(function* () { + const { layer } = makeStub(); + const executor = yield* createExecutor(makeTestConfig({ plugins: plugins(layer) })); + + const before = expectOk(yield* executor.execute(address("setupStatus"), {})); + expect(before.configured).toBe(false); + expect(before.nextSteps).toContain("listProducts"); + + yield* executor.execute(address("addBundle"), { + productIds: ["google-calendar", "google-gmail"], + slug: "google", + }); + + const after = expectOk(yield* executor.execute(address("setupStatus"), {})); + expect(after.configured).toBe(true); + expect(after.slug).toBe("google"); + expect([...after.products].sort()).toEqual(["google-calendar", "google-gmail"]); + expect([...after.audienceWarnings]).toEqual([]); + expect(after.nextSteps).toContain("oauth.clients.createHandoff"); + }), + ); +}); diff --git a/packages/plugins/google/src/sdk/index.ts b/packages/plugins/google/src/sdk/index.ts index 62e1d12a4..2e8013fee 100644 --- a/packages/plugins/google/src/sdk/index.ts +++ b/packages/plugins/google/src/sdk/index.ts @@ -12,7 +12,10 @@ export { googleOAuthConsentScopes, googleOAuthConsentScopesForPreset, googleAudienceWarningsForUrls, + googleAudienceWarningMessagesForUrls, + GOOGLE_AUDIENCE_WARNING, googlePresetForDiscoveryUrl, + googleOpenApiPresetById, type GoogleOpenApiOAuthAudience, type GoogleOpenApiPreset, type GooglePreset, diff --git a/packages/plugins/google/src/sdk/plugin.ts b/packages/plugins/google/src/sdk/plugin.ts index 597a30b96..9806700cc 100644 --- a/packages/plugins/google/src/sdk/plugin.ts +++ b/packages/plugins/google/src/sdk/plugin.ts @@ -1,4 +1,4 @@ -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; import type { Layer } from "effect"; import { HttpClient } from "effect/unstable/http"; @@ -7,9 +7,11 @@ import { IntegrationDetectionResult, IntegrationNotFoundError, IntegrationSlug, + ToolResult, definePlugin, mergeAuthTemplates, sha256Hex, + tool, type AuthMethodDescriptor, type Integration, type IntegrationConfig, @@ -25,6 +27,7 @@ import { openApiStoredOperationsFromCompiled, resolveOpenApiBackedAnnotations, resolveOpenApiBackedTools, + OpenApiParseError, type Authentication, type AuthenticationInput, type OpenapiStore, @@ -36,7 +39,14 @@ import { normalizeGoogleDiscoveryUrl, } from "./discovery"; import { decodeGoogleIntegrationConfig, type GoogleIntegrationConfig } from "./config"; -import { googleOpenApiBundlePreset } from "./presets"; +import { + googleAudienceWarningMessagesForUrls, + googleOAuthConsentScopesForPreset, + googleOpenApiBundlePreset, + googleOpenApiPresetById, + googleOpenApiPresets, + googlePresetForDiscoveryUrl, +} from "./presets"; export interface GoogleBundleConfig { readonly urls: readonly string[]; @@ -289,6 +299,208 @@ const makeGooglePluginExtension = ( export type GooglePluginExtension = ReturnType; +// --------------------------------------------------------------------------- +// Agent-facing setup tools. +// +// These mirror the web Add-Google flow (product picker, bundle, connect) so an +// agent configuring Google by conversation gets the same guided experience: +// pick products by name, bundle them in one call, and receive the exact OAuth +// next steps. Secret entry (the Google Cloud Client ID / Client Secret) still +// happens in the web UI via the oauth.clients handoff: Google has no dynamic +// client registration, so the user must bring their own Google Cloud OAuth +// client, and the secret never crosses the agent. +// --------------------------------------------------------------------------- + +const GoogleProductSchema = Schema.Struct({ + id: Schema.String, + name: Schema.String, + summary: Schema.String, + discoveryUrl: Schema.optional(Schema.String), + oauthAudience: Schema.String, + consentScopes: Schema.Array(Schema.String), + recommended: Schema.Boolean, + needsSpecialConsent: Schema.Boolean, +}); + +const ListProductsOutput = Schema.Struct({ products: Schema.Array(GoogleProductSchema) }); + +const AddBundleToolInput = Schema.Struct({ + productIds: Schema.optional(Schema.Array(Schema.String)), + customDiscoveryUrls: Schema.optional(Schema.Array(Schema.String)), + slug: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + description: Schema.optional(Schema.String), +}); + +const AddBundleToolOutput = Schema.Struct({ + slug: Schema.String, + toolCount: Schema.Number, + products: Schema.Array(Schema.String), + audienceWarnings: Schema.Array(Schema.String), + nextSteps: Schema.String, +}); + +const SetupStatusInput = Schema.Struct({ slug: Schema.optional(Schema.String) }); + +const SetupStatusOutput = Schema.Struct({ + configured: Schema.Boolean, + slug: Schema.String, + products: Schema.Array(Schema.String), + discoveryUrls: Schema.Array(Schema.String), + audienceWarnings: Schema.Array(Schema.String), + nextSteps: Schema.String, +}); + +const ListProductsOutputStd = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(ListProductsOutput), +); +const AddBundleToolInputStd = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(AddBundleToolInput), +); +const AddBundleToolOutputStd = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(AddBundleToolOutput), +); +const SetupStatusInputStd = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(SetupStatusInput), +); +const SetupStatusOutputStd = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(SetupStatusOutput), +); + +const googleToolFailure = (code: string, message: string) => ToolResult.fail({ code, message }); + +const needsSpecialConsent = (audience: string): boolean => + audience === "workspace-admin" || audience === "unsupported-user"; + +// The connect step is identical for any Google bundle, so phrase it once. Google +// has no DCR, so the user brings a Google Cloud OAuth client; the secret is +// entered in the web UI through the handoff, never in chat. +const googleConnectInstructions = (slug: string): string => + `Integration "${slug}" is registered. To connect an account, Google requires your own Google Cloud OAuth client (Google does not support automatic / dynamic client registration). ` + + `1) Call oauth.clients.createHandoff for integration "${slug}", then ask the user to open the returned URL and enter their Google Cloud OAuth Client ID and Client Secret in the Executor web UI. Never ask for the client secret in chat. ` + + `2) After they save it, call oauth.clients.list to find the new client slug, then oauth.start for "${slug}" to begin Google consent and return the authorization URL for the user to approve.`; + +const googleSetupTools = (self: GooglePluginExtension) => [ + tool({ + name: "listProducts", + description: + "List the Google APIs (Gmail, Calendar, Drive, …) that can be bundled into one Google integration. Call this first when a user wants to set up Google, then pass the chosen ids to `addBundle`. `recommended` marks the defaults the web picker pre-selects; `needsSpecialConsent` flags products that require a Workspace admin account or that Google does not grant through standard user consent.", + outputSchema: ListProductsOutputStd, + execute: () => + Effect.succeed( + ToolResult.ok({ + products: googleOpenApiPresets.map((preset) => ({ + id: preset.id, + name: preset.name, + summary: preset.summary, + ...(preset.url ? { discoveryUrl: preset.url } : {}), + oauthAudience: preset.oauthAudience, + consentScopes: googleOAuthConsentScopesForPreset(preset.id), + recommended: preset.featured === true, + needsSpecialConsent: needsSpecialConsent(preset.oauthAudience), + })), + }), + ), + }), + tool({ + name: "addBundle", + description: + "Register a Google integration from chosen product ids (see `listProducts`), the same one-call bundling the web Add-Google flow does. Pass `productIds` and/or raw `customDiscoveryUrls`. Returns the integration slug, tool count, any consent warnings for the selected APIs, and the exact OAuth next steps. This only registers the integration; connecting an account (Google Cloud OAuth client + consent) is a separate step described in `nextSteps`.", + annotations: { + requiresApproval: true, + approvalDescription: "Add a Google integration", + }, + inputSchema: AddBundleToolInputStd, + outputSchema: AddBundleToolOutputStd, + execute: (input: typeof AddBundleToolInput.Type) => + Effect.gen(function* () { + const productIds = input.productIds ?? []; + const unknownIds: string[] = []; + const presetUrls: string[] = []; + for (const id of productIds) { + const url = googleOpenApiPresetById(id)?.url; + if (url) presetUrls.push(url); + else unknownIds.push(id); + } + if (unknownIds.length > 0) { + return googleToolFailure( + "unknown_product", + `Unknown Google product id(s): ${unknownIds.join(", ")}. Call listProducts to see valid ids.`, + ); + } + + const urls = [...new Set([...presetUrls, ...(input.customDiscoveryUrls ?? [])])]; + if (urls.length === 0) { + return googleToolFailure( + "no_products_selected", + "Pass at least one productId (see listProducts) or a customDiscoveryUrls entry.", + ); + } + + return yield* self + .addBundle({ + urls, + slug: input.slug, + name: input.name, + description: input.description, + }) + .pipe( + Effect.map((result) => + ToolResult.ok({ + slug: String(result.slug), + toolCount: result.toolCount, + products: productIds, + audienceWarnings: googleAudienceWarningMessagesForUrls(urls), + nextSteps: googleConnectInstructions(String(result.slug)), + }), + ), + Effect.catchTags({ + OpenApiParseError: ({ message }: OpenApiParseError) => + Effect.succeed(googleToolFailure("google_discovery_failed", message)), + IntegrationAlreadyExistsError: ({ slug }: IntegrationAlreadyExistsError) => + Effect.succeed( + googleToolFailure( + "integration_already_exists", + `Integration ${slug} already exists; update it instead of re-adding.`, + ), + ), + }), + ); + }), + }), + tool({ + name: "setupStatus", + description: + "Report where a Google integration is in setup: whether it is registered, which products it bundles, any consent warnings, and the next step to take. Use this to resume or verify an in-progress Google setup.", + inputSchema: SetupStatusInputStd, + outputSchema: SetupStatusOutputStd, + execute: (input: typeof SetupStatusInput.Type) => + Effect.gen(function* () { + const slug = input.slug?.trim() || DEFAULT_GOOGLE_SLUG; + const config = yield* self.getConfig(slug); + if (!config) { + return ToolResult.ok({ + configured: false, + slug, + products: [], + discoveryUrls: [], + audienceWarnings: [], + nextSteps: `No Google integration "${slug}" yet. Call listProducts to see what is available, then addBundle with the productIds you want.`, + }); + } + const urls = config.googleDiscoveryUrls ?? []; + return ToolResult.ok({ + configured: true, + slug, + products: urls.map((url) => googlePresetForDiscoveryUrl(url)?.id ?? url), + discoveryUrls: [...urls], + audienceWarnings: googleAudienceWarningMessagesForUrls(urls), + nextSteps: googleConnectInstructions(slug), + }); + }), + }), +]; + export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ id: "google" as const, packageName: "@executor-js/plugin-google", @@ -297,6 +509,15 @@ export const googlePlugin = definePlugin((options?: GooglePluginOptions) => ({ extension: (ctx: PluginCtx) => makeGooglePluginExtension(options, ctx), + staticSources: (self) => [ + { + id: "google", + kind: "executor", + name: "Google", + tools: googleSetupTools(self), + }, + ], + describeAuthMethods: describeGoogleAuthMethods, describeIntegrationDisplay: describeGoogleIntegrationDisplay, diff --git a/packages/plugins/google/src/sdk/presets.ts b/packages/plugins/google/src/sdk/presets.ts index e369c369d..75918cdf3 100644 --- a/packages/plugins/google/src/sdk/presets.ts +++ b/packages/plugins/google/src/sdk/presets.ts @@ -276,3 +276,32 @@ export const googleAudienceWarningsForUrls = ( } return [...seen]; }; + +// --------------------------------------------------------------------------- +// Human-facing consent cautions, keyed by `oauthAudience`. Shared by the web +// Add-account panel and the agent-facing `google.addBundle` / `google.setupStatus` +// tools so both surfaces warn with identical wording. +// --------------------------------------------------------------------------- + +export const GOOGLE_AUDIENCE_WARNING: Readonly> = { + "workspace-admin": + "This connection includes Google Workspace admin APIs. Connecting requires a Workspace admin account; personal Gmail accounts cannot grant these scopes.", + "unsupported-user": + "This connection includes APIs that Google does not grant through standard user OAuth consent. Those tools may fail to authorize.", +}; + +/** The caution messages for a set of Discovery URLs: `googleAudienceWarningsForUrls` + * mapped through `GOOGLE_AUDIENCE_WARNING`. Returns `[]` when nothing needs a warning. */ +export const googleAudienceWarningMessagesForUrls = (urls: readonly string[]): readonly string[] => + googleAudienceWarningsForUrls(urls).flatMap((audience) => { + const message = GOOGLE_AUDIENCE_WARNING[audience]; + return message ? [message] : []; + }); + +const googlePresetsById: ReadonlyMap = new Map( + googleOpenApiPresets.map((preset) => [preset.id, preset] as const), +); + +/** Look up a bundleable Google product preset by its stable `id` (e.g. `google-gmail`). */ +export const googleOpenApiPresetById = (id: string): GoogleOpenApiPreset | undefined => + googlePresetsById.get(id);