Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/exact-google-toolkit-bundles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Remove AI attribution from the commit metadata

The reviewed commit records Codex <codex@openai.com> as its author, so publishing it would put explicit AI attribution into a public commit. Rewrite the commit metadata to use the responsible human or approved project identity before this change is published.

AGENTS.md reference: AGENTS.md:L41-L42

Useful? React with 👍 / 👎.

"@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.
118 changes: 117 additions & 1 deletion e2e/scenarios/google-health-checks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof api>;
const GOOGLE_AUTH_TEMPLATE = AuthTemplateSlug.make("googleOAuth2");
const CONNECTION = ConnectionName.make("main");
Expand Down Expand Up @@ -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);
}),
),
);
}),
);
60 changes: 60 additions & 0 deletions packages/plugins/toolkits/src/google-bundles.test.ts
Original file line number Diff line number Diff line change
@@ -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([]);
});
34 changes: 34 additions & 0 deletions packages/plugins/toolkits/src/google-bundles.ts
Original file line number Diff line number Diff line change
@@ -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 }] : [];
});
94 changes: 92 additions & 2 deletions packages/plugins/toolkits/src/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
),
Comment on lines +838 to +842

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve legacy connection policies when adding exact bundles

For a migrated toolkit whose account access is still represented by a legacy broad approve policy, this check ignores that policy because it examines only persisted connection records, so the bundle is offered as missing and exact records are added beneath it. The server's legacyConnectionPolicyIds then stops treating the broad policy as an access marker once it matches any exact connection, converting it into an active approve rule and removing broad access; meanwhile the client still classifies it as legacy because no identical broad connection record exists. The resulting toolkit can display all account tools while MCP permits only the bundle, and approval-required picker operations can become automatically approved.

Useful? React with 👍 / 👎.

),
}));
}),
[filteredGroups, props.integrations, props.configuredConnections],
);

const addConnection = async (patterns: readonly string[]) => {
await props.onAddPatterns(patterns);
};
Expand Down Expand Up @@ -877,6 +911,62 @@ function AddConnectionDialog(props: {
) : null}
</div>
<div className="min-h-0 overflow-y-auto p-3" style={{ maxHeight: "32rem" }}>
{bundleRows.length > 0 ? (
<div className="mb-4 space-y-2">
<div className="px-1 text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
Tool bundles
</div>
<p className="px-1 text-xs text-muted-foreground">
Select current tools only. Account permissions stay the same.
</p>
<div className="overflow-hidden rounded-md border border-border/70 bg-background/30">
{bundleRows.map((bundle) => (
<div
key={`${bundle.group.id}:${bundle.id}`}
className="flex min-w-0 items-center gap-3 border-b border-border/50 px-3 py-2.5 last:border-b-0"
>
<BoxIcon className="size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1 space-y-1">
<div className="text-xs font-medium">{bundle.name}</div>
<div className="text-[11px] text-muted-foreground">
{connectionTitleForHost(bundle.group, props.showOwnerLabels)} ·{" "}
{bundle.patterns.length} tools
</div>
<p className="text-[11px] text-muted-foreground">{bundle.summary}</p>
</div>
<Button
type="button"
size="sm"
variant="outline"
disabled={bundle.missingPatterns.length === 0}
aria-label={`Add bundle ${bundle.name} from ${connectionTitleForHost(bundle.group, props.showOwnerLabels)}`}
onClick={() => void addConnection(bundle.missingPatterns)}
Comment on lines +941 to +943

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Deduplicate bundle selections when displaying connection counts

Adding a bundle creates one toolkit connection record for every exact tool pattern, but ToolkitTile still renders and counts every resulting ConfiguredConnectionView as a separate account. Selecting a read-only bundle from one Google account therefore makes the toolkit card report many “connections” and show repeated copies of the same account icon. Group or deduplicate these exact selections by their ToolkitConnectionGroup before feeding the tile's icon stack and connection count.

Useful? React with 👍 / 👎.

>
{bundle.missingPatterns.length === 0
? "Added"
: `Add ${bundle.missingPatterns.length}`}
</Button>
{bundle.exactConnectionIds.length > 0 ? (
<Button
type="button"
size="sm"
variant="outline"
aria-label={`Remove bundle ${bundle.name} from ${connectionTitleForHost(bundle.group, props.showOwnerLabels)}`}
onClick={() =>
void (async () => {
for (const id of bundle.exactConnectionIds)
await removeConnection(id);
})()
}
>
Remove
</Button>
) : null}
</div>
))}
</div>
</div>
) : null}
<div className="mb-2 flex items-center justify-between px-1">
<span className="text-[10px] font-semibold uppercase tracking-widest text-muted-foreground">
Connections
Expand Down
Loading