From 8728e5258a05107a1aa654661697869145930574 Mon Sep 17 00:00:00 2001 From: zak <36251971+zrm625@users.noreply.github.com> Date: Wed, 14 Oct 2026 00:18:05 -0400 Subject: [PATCH] Add exact per-account Google toolkit bundles --- .changeset/exact-google-toolkit-bundles.md | 5 + e2e/scenarios/google-health-checks.test.ts | 118 +++++++++++++++++- .../toolkits/src/google-bundles.test.ts | 60 +++++++++ .../plugins/toolkits/src/google-bundles.ts | 34 +++++ packages/plugins/toolkits/src/page.tsx | 94 +++++++++++++- 5 files changed, 308 insertions(+), 3 deletions(-) create mode 100644 .changeset/exact-google-toolkit-bundles.md create mode 100644 packages/plugins/toolkits/src/google-bundles.test.ts create mode 100644 packages/plugins/toolkits/src/google-bundles.ts diff --git a/.changeset/exact-google-toolkit-bundles.md b/.changeset/exact-google-toolkit-bundles.md new file mode 100644 index 0000000000..62863f81fd --- /dev/null +++ b/.changeset/exact-google-toolkit-bundles.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-toolkits": patch +--- + +Add per-account Email read-only, Drive read-only, and Photos picker tool bundles. Selection saves exact current tool addresses, preserves account permissions and approval policies, and does not grant future operations automatically. diff --git a/e2e/scenarios/google-health-checks.test.ts b/e2e/scenarios/google-health-checks.test.ts index 507699adbd..84df5ccc3c 100644 --- a/e2e/scenarios/google-health-checks.test.ts +++ b/e2e/scenarios/google-health-checks.test.ts @@ -6,6 +6,7 @@ import type { HttpApiClient } from "effect/unstable/httpapi"; import { composePluginApi } from "@executor-js/api/server"; import { connectEmulator, type EmulatorClient } from "@executor-js/emulate"; import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; +import { toolkitsPlugin } from "@executor-js/plugin-toolkits/server"; import { AuthTemplateSlug, ConnectionName, @@ -19,7 +20,7 @@ import { Api, Browser, Target } from "../src/services"; import type { Identity, Target as TargetShape } from "../src/target"; import { type BrowserSurface, visit } from "../src/surfaces/browser"; -const api = composePluginApi([openApiHttpPlugin()] as const); +const api = composePluginApi([openApiHttpPlugin(), toolkitsPlugin()] as const); type Client = HttpApiClient.ForApi; const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2"); const CONNECTION = ConnectionName.make("main"); @@ -442,3 +443,118 @@ scenario( ); }), ); + +scenario( + "Google · email toolkit bundle saves exact tools from an emulator account", + { timeout: 300_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const emulator = yield* createGoogleEmulator; + const integration = IntegrationSlug.make("google_gmail"); + const oauthClient = OAuthClientSlug.make(unique("bundle_oauth")); + const toolkit = yield* client.toolkits.create({ + payload: { owner: "org", name: unique("email_bundle") }, + }); + + yield* Effect.gen(function* () { + yield* addGooglePresetFromDirectUrl( + browser, + identity, + "Gmail", + "google-gmail", + googleDiscoveryUrl("gmail", "v1"), + String(integration), + ); + yield* connectGoogleAccount({ + client, + emulator: emulator.client, + emulatorBaseUrl: emulator.baseUrl, + target, + integration, + oauthClient, + }); + const tools = yield* client.tools.list({ query: { integration, connection: CONNECTION } }); + const expected = tools + .filter( + (tool) => + String(tool.name).startsWith("gmail.") && + ["get", "list"].includes(String(tool.name).split(".").at(-1) ?? ""), + ) + .map((tool) => String(tool.address).replace(/^tools\./, "")) + .sort(); + expect(expected.length).toBeGreaterThan(0); + expect(tools.some((tool) => String(tool.name) === "gmail.users.messages.send")).toBe(true); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Choose the Email read-only tool bundle for this account", async () => { + await visit(page, `/default/toolkits/${toolkit.slug}`); + await page.getByRole("button", { name: "Manage toolkit connections" }).click(); + const dialog = page.getByRole("dialog", { name: "Manage connections" }); + await dialog.getByRole("button", { name: /^Add bundle Email read-only from / }).click(); + await dialog.getByRole("button", { name: /^Add bundle Email read-only from / }).waitFor(); + await page.waitForFunction(() => + Array.from(document.querySelectorAll("button")).some( + (button) => + button.getAttribute("aria-label")?.startsWith("Add bundle Email read-only from ") && + button.disabled, + ), + ); + await page.keyboard.press("Escape"); + await dialog.waitFor({ state: "hidden" }); + }); + await step( + "The toolkit shows email reads while send and delete stay outside the toolkit", + async () => { + const tree = page.getByRole("region", { name: "Toolkit tools" }); + await tree.getByLabel("Filter tools").fill("messages.list"); + await tree.getByRole("button").filter({ hasText: "list" }).last().waitFor(); + await tree.getByLabel("Filter tools").fill("messages.send"); + expect(await tree.getByRole("button").filter({ hasText: "send" }).count()).toBe(0); + await tree.getByLabel("Filter tools").fill("messages.delete"); + expect(await tree.getByRole("button").filter({ hasText: "delete" }).count()).toBe(0); + await tree.getByLabel("Filter tools").clear(); + }, + ); + await step("Remove the exact bundle tools without disconnecting the account", async () => { + await page.getByRole("button", { name: "Manage toolkit connections" }).click(); + const dialog = page.getByRole("dialog", { name: "Manage connections" }); + await dialog + .getByRole("button", { name: /^Remove bundle Email read-only from / }) + .click(); + await dialog + .getByRole("button", { name: /^Remove bundle Email read-only from / }) + .waitFor({ state: "hidden" }); + await dialog.getByRole("button", { name: /^Add bundle Email read-only from / }).click(); + await page.waitForFunction(() => + Array.from(document.querySelectorAll("button")).some( + (button) => + button.getAttribute("aria-label")?.startsWith("Add bundle Email read-only from ") && + button.disabled, + ), + ); + }); + }); + const saved = yield* client.toolkits.listConnections({ params: { toolkitId: toolkit.id } }); + expect(saved.connections.map((row) => row.pattern).sort()).toEqual(expected); + const account = yield* client.connections.list({ query: { integration, owner: "org" } }); + expect(account.map((row) => row.name)).toContain(CONNECTION); + }).pipe( + Effect.ensuring( + Effect.gen(function* () { + yield* client.toolkits.remove({ params: { toolkitId: toolkit.id } }).pipe(Effect.ignore); + yield* client.connections + .remove({ params: { owner: "org", integration, name: CONNECTION } }) + .pipe(Effect.ignore); + yield* client.oauth + .removeClient({ params: { slug: oauthClient }, payload: { owner: "org" } }) + .pipe(Effect.ignore); + yield* client.openapi.removeSpec({ params: { slug: integration } }).pipe(Effect.ignore); + }), + ), + ); + }), +); diff --git a/packages/plugins/toolkits/src/google-bundles.test.ts b/packages/plugins/toolkits/src/google-bundles.test.ts new file mode 100644 index 0000000000..44d7957080 --- /dev/null +++ b/packages/plugins/toolkits/src/google-bundles.test.ts @@ -0,0 +1,60 @@ +import { expect, it } from "@effect/vitest"; +import { googleToolkitBundles } from "./google-bundles"; + +it("selects exact read operations and excludes send, modify, delete and permission writes", () => { + const names = [ + "gmail.users.messages.list", + "gmail.users.messages.get", + "gmail.users.messages.send", + "gmail.users.messages.modify", + "gmail.users.messages.delete", + "drive.files.list", + "drive.files.export", + "drive.files.delete", + "drive.permissions.create", + ]; + const bundles = googleToolkitBundles( + names.map((name) => ({ name, pattern: `service.org.account.${name}` })), + ); + expect(bundles.map((bundle) => [bundle.name, bundle.patterns])).toEqual([ + [ + "Email read-only", + [ + "service.org.account.gmail.users.messages.get", + "service.org.account.gmail.users.messages.list", + ], + ], + [ + "Drive read-only", + ["service.org.account.drive.files.export", "service.org.account.drive.files.list"], + ], + ]); +}); + +it("includes picker session management but excludes Library uploads", () => { + const names = [ + "photospicker.sessions.create", + "photospicker.sessions.delete", + "photospicker.mediaItems.list", + "photoslibrary.mediaItems.batchCreate", + "photoslibrary.uploads.upload", + ]; + const bundles = googleToolkitBundles( + names.map((name) => ({ name, pattern: `photos.org.main.${name}` })), + ); + expect(bundles.map((bundle) => bundle.patterns)).toEqual([ + [ + "photos.org.main.photospicker.mediaItems.list", + "photos.org.main.photospicker.sessions.create", + "photos.org.main.photospicker.sessions.delete", + ], + ]); +}); + +it("deduplicates current addresses without widening account or operation selection", () => { + const tools = [ + { name: "gmail.users.messages.list", pattern: "gmail.user.mine.gmail.users.messages.list" }, + ]; + expect(googleToolkitBundles([...tools, ...tools])[0]?.patterns).toEqual([tools[0].pattern]); + expect(googleToolkitBundles([])).toEqual([]); +}); diff --git a/packages/plugins/toolkits/src/google-bundles.ts b/packages/plugins/toolkits/src/google-bundles.ts new file mode 100644 index 0000000000..c8090e4ad9 --- /dev/null +++ b/packages/plugins/toolkits/src/google-bundles.ts @@ -0,0 +1,34 @@ +const readOperation = (name: string): boolean => + ["get", "list", "export"].includes(name.split(".").at(-1) ?? ""); + +const templates = [ + { + id: "google-email-readonly", + name: "Email read-only", + summary: "Current Gmail get and list tools.", + matches: (name: string) => name.startsWith("gmail.") && readOperation(name), + }, + { + id: "google-photos-picker", + name: "Photos picker", + summary: "Picker sessions and explicitly selected media. No Library uploads.", + matches: (name: string) => name.startsWith("photospicker."), + }, + { + id: "google-drive-readonly", + name: "Drive read-only", + summary: "Current Drive get, list, and export tools.", + matches: (name: string) => name.startsWith("drive.") && readOperation(name), + }, +] as const; + +/** Snapshot exact tool addresses. New operations are never granted implicitly. */ +export const googleToolkitBundles = ( + tools: readonly { readonly name: string; readonly pattern: string }[], +) => + templates.flatMap(({ matches, ...template }) => { + const patterns = [ + ...new Set(tools.filter((tool) => matches(tool.name)).map((tool) => tool.pattern)), + ].sort((a, b) => a.localeCompare(b)); + return patterns.length > 0 ? [{ ...template, patterns }] : []; + }); diff --git a/packages/plugins/toolkits/src/page.tsx b/packages/plugins/toolkits/src/page.tsx index e2f0e9d726..d80d7dee38 100644 --- a/packages/plugins/toolkits/src/page.tsx +++ b/packages/plugins/toolkits/src/page.tsx @@ -1,4 +1,5 @@ import { useId, useMemo, useState } from "react"; +import { googleToolkitBundles } from "./google-bundles"; import { Link, useNavigate } from "@tanstack/react-router"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import * as Atom from "effect/unstable/reactivity/Atom"; @@ -385,8 +386,10 @@ const configuredConnectionViews = ( showOwnerLabels: boolean, ): readonly ConfiguredConnectionView[] => connections.map((connection) => { - const group = connectionGroups.find((candidate) => - candidate.patterns.includes(connection.pattern), + const group = connectionGroups.find( + (candidate) => + candidate.patterns.includes(connection.pattern) || + candidate.tools.some((tool) => toolMatchId(tool) === connection.pattern), ); if (!group) { return { @@ -812,6 +815,37 @@ function AddConnectionDialog(props: { }); }, [props.groups, trimmedQuery]); + const bundleRows = useMemo( + () => + filteredGroups.flatMap((group) => { + const integration = props.integrations.find( + (row) => String(row.slug) === group.integration, + ); + if (integration?.family !== "google") return []; + return googleToolkitBundles( + group.tools + .filter((tool) => !tool.static) + .map((tool) => ({ + name: tool.name, + pattern: toolMatchId(tool), + })), + ).map((bundle) => ({ + ...bundle, + group, + exactConnectionIds: props.configuredConnections + .filter((connection) => bundle.patterns.includes(connection.pattern)) + .map((connection) => connection.id), + missingPatterns: bundle.patterns.filter( + (pattern) => + !props.configuredConnections.some((connection) => + matchPattern(connection.pattern, pattern), + ), + ), + })); + }), + [filteredGroups, props.integrations, props.configuredConnections], + ); + const addConnection = async (patterns: readonly string[]) => { await props.onAddPatterns(patterns); }; @@ -877,6 +911,62 @@ function AddConnectionDialog(props: { ) : null}
+ {bundleRows.length > 0 ? ( +
+
+ Tool bundles +
+

+ Select current tools only. Account permissions stay the same. +

+
+ {bundleRows.map((bundle) => ( +
+ +
+
{bundle.name}
+
+ {connectionTitleForHost(bundle.group, props.showOwnerLabels)} ·{" "} + {bundle.patterns.length} tools +
+

{bundle.summary}

+
+ + {bundle.exactConnectionIds.length > 0 ? ( + + ) : null} +
+ ))} +
+
+ ) : null}
Connections