History

google discovery support

Rhys Sullivan committed 7 months ago

Commit 2d88a2d1 parent 3033ded38 files · +3964 −180Browse files

Unified · Split

38 files
  1. AGENTS.md +0 −3
  2. apps/server/package.json +1 −0
  3. apps/server/src/handlers/google-discovery.ts +16 −0
  4. apps/server/src/main.ts +7 −1
  5. apps/server/src/services/executor.ts +9 −0
  6. apps/web/package.json +1 −0
  7. apps/web/src/pages/secrets.tsx +26 −26
  8. apps/web/src/pages/sources.tsx +6 −1
  9. bun.lock +40 −0
  10. packages/clients/react/package.json +1 −0
  11. packages/clients/react/src/index.ts +2 −0
  12. packages/clients/react/src/secret-picker.tsx +136 −0
  13. packages/plugins/google-discovery/fixtures/drive.json +96 −0
  14. packages/plugins/google-discovery/package.json +47 −0
  15. packages/plugins/google-discovery/src/api/group.ts +128 −0
  16. packages/plugins/google-discovery/src/api/handlers.ts +139 −0
  17. packages/plugins/google-discovery/src/api/index.ts +5 −0
  18. packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +667 −0
  19. packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx +38 −0
  20. packages/plugins/google-discovery/src/react/GoogleDiscoverySourceSummary.tsx +16 −0
  21. packages/plugins/google-discovery/src/react/atoms.ts +18 −0
  22. packages/plugins/google-discovery/src/react/client.ts +16 −0
  23. packages/plugins/google-discovery/src/react/index.ts +8 −0
  24. packages/plugins/google-discovery/src/react/source-plugin.ts +10 −0
  25. packages/plugins/google-discovery/src/sdk/binding-store.ts +163 −0
  26. packages/plugins/google-discovery/src/sdk/document.test.ts +53 −0
  27. packages/plugins/google-discovery/src/sdk/document.ts +559 −0
  28. packages/plugins/google-discovery/src/sdk/errors.ts +32 −0
  29. packages/plugins/google-discovery/src/sdk/index.ts +40 −0
  30. packages/plugins/google-discovery/src/sdk/invoke.ts +446 −0
  31. packages/plugins/google-discovery/src/sdk/oauth.ts +169 −0
  32. packages/plugins/google-discovery/src/sdk/plugin.test.ts +350 −0
  33. packages/plugins/google-discovery/src/sdk/plugin.ts +538 −0
  34. packages/plugins/google-discovery/src/sdk/types.ts +121 −0
  35. packages/plugins/google-discovery/tsconfig.json +27 −0
  36. packages/plugins/onepassword/src/react/OnePasswordSettings.tsx +2 −2
  37. packages/plugins/onepassword/src/sdk/client.ts +18 −2
  38. packages/plugins/openapi/src/react/AddOpenApiSource.tsx +13 −145
AGENTS.md+0−3
@@ -1,3 +0,0 @@
1−## Git
2−
3−Never commit, push, or create branches unless I explicitly tell you to.
apps/server/package.json+1−0
@@ -12,6 +12,7 @@ · 11 unmodified lines
1212 "@effect/sql-sqlite-bun": "catalog:",
1313 "@executor/api": "workspace:*",
1414 "@executor/plugin-file-secrets": "workspace:*",
15+ "@executor/plugin-google-discovery": "workspace:*",
1516 "@executor/plugin-keychain": "workspace:*",
1617 "@executor/plugin-onepassword": "workspace:*",
1718 "@executor/plugin-mcp": "workspace:*",
apps/server/src/handlers/google-discovery.ts+16−0
@@ -0,0 +1,16 @@
1+import { Effect, Layer } from "effect";
2+import {
3+ GoogleDiscoveryExtensionService,
4+ GoogleDiscoveryHandlers,
5+} from "@executor/plugin-google-discovery/api";
6+import { ExecutorService } from "../services/executor";
7+
8+const GoogleDiscoveryExtensionLive = Layer.effect(
9+ GoogleDiscoveryExtensionService,
10+ Effect.map(ExecutorService, (executor) => executor.googleDiscovery),
11+);
12+
13+export const GoogleDiscoveryHandlersLive = Layer.provide(
14+ GoogleDiscoveryHandlers,
15+ GoogleDiscoveryExtensionLive,
16+);
apps/server/src/main.ts+7−1
@@ -9,12 +9,14 @@ import { Layer } from "effect"; · 8 unmodified lines
99 import { addGroup } from "@executor/api";
1010 import { OpenApiGroup } from "@executor/plugin-openapi/api";
1111 import { McpGroup } from "@executor/plugin-mcp/api";
12+import { GoogleDiscoveryGroup } from "@executor/plugin-google-discovery/api";
1213 import { OnePasswordGroup } from "@executor/plugin-onepassword/api";
1314 import { ToolsHandlers } from "./handlers/tools";
1415 import { SourcesHandlers } from "./handlers/sources";
1516 import { SecretsHandlers } from "./handlers/secrets";
1617 import { OpenApiHandlersLive } from "./handlers/openapi";
1718 import { McpSourceHandlersLive } from "./handlers/mcp-source";
19+import { GoogleDiscoveryHandlersLive } from "./handlers/google-discovery";
1820 import { OnePasswordHandlersLive } from "./handlers/onepassword";
1921 import { ExecutorService, ExecutorServiceLayer, getExecutor, type ServerExecutor } from "./services/executor";
2022 import { createMcpRequestHandler, type McpRequestHandler } from "./mcp";
@@ -23,7 +25,10 @@ import { createMcpRequestHandler, type McpRequestHandler } from "./mcp"; · 2 unmodified lines
2325 // Composed API — core + plugin groups
2426 // ---------------------------------------------------------------------------
2527
26−const ExecutorApiWithPlugins = addGroup(OpenApiGroup).add(McpGroup).add(OnePasswordGroup);
28+const ExecutorApiWithPlugins = addGroup(OpenApiGroup)
29+ .add(McpGroup)
30+ .add(GoogleDiscoveryGroup)
31+ .add(OnePasswordGroup);
2732
2833 // ---------------------------------------------------------------------------
2934 // API Layer
@@ -36,6 +41,7 @@ const ApiBase = HttpApiBuilder.api(ExecutorApiWithPlugins).pipe( · 6 unmodified lines
3641 SecretsHandlers,
3742 OpenApiHandlersLive,
3843 McpSourceHandlersLive,
44+ GoogleDiscoveryHandlersLive,
3945 OnePasswordHandlersLive,
4046 ]),
4147 );
apps/server/src/services/executor.ts+9−0
@@ -7,6 +7,11 @@ import { createExecutor, scopeKv } from "@executor/sdk"; · 6 unmodified lines
77 import { makeSqliteKv, makeKvConfig, migrate } from "@executor/storage-file";
88 import { openApiPlugin, makeKvOperationStore, type OpenApiPluginExtension } from "@executor/plugin-openapi";
99 import { mcpPlugin, makeKvBindingStore, type McpPluginExtension } from "@executor/plugin-mcp";
10+import {
11+ googleDiscoveryPlugin,
12+ makeKvBindingStore as makeKvGoogleDiscoveryBindingStore,
13+ type GoogleDiscoveryPluginExtension,
14+} from "@executor/plugin-google-discovery";
1015 import { keychainPlugin } from "@executor/plugin-keychain";
1116 import { fileSecretsPlugin } from "@executor/plugin-file-secrets";
1217 import { onepasswordPlugin, type OnePasswordExtension } from "@executor/plugin-onepassword";
@@ -16,6 +21,7 @@ import type { Executor, ExecutorPlugin } from "@executor/sdk"; · 3 unmodified lines
1621 type ServerPlugins = readonly [
1722 ExecutorPlugin<"openapi", OpenApiPluginExtension>,
1823 ExecutorPlugin<"mcp", McpPluginExtension>,
24+ ExecutorPlugin<"googleDiscovery", GoogleDiscoveryPluginExtension>,
1925 ReturnType<typeof keychainPlugin>,
2026 ReturnType<typeof fileSecretsPlugin>,
2127 ExecutorPlugin<"onepassword", OnePasswordExtension>,
@@ -69,6 +75,9 @@ const ExecutorLayer = Layer.effect( · 47 unmodified lines
6975 mcpPlugin({
7076 bindingStore: makeKvBindingStore(kv, "mcp"),
7177 }),
78+ googleDiscoveryPlugin({
79+ bindingStore: makeKvGoogleDiscoveryBindingStore(kv, "google-discovery"),
80+ }),
7281 keychainPlugin(),
7382 fileSecretsPlugin(),
7483 onepasswordPlugin({
apps/web/package.json+1−0
@@ -6,6 +6,7 @@ · 5 unmodified lines
66 "dependencies": {
77 "@executor/plugin-onepassword": "workspace:*",
88 "@executor/plugin-mcp": "workspace:*",
9+ "@executor/plugin-google-discovery": "workspace:*",
910 "@executor/plugin-openapi": "workspace:*",
1011 "@executor/sdk": "workspace:*",
1112 "@executor/react": "workspace:*",
apps/web/src/pages/secrets.tsx+26−26
@@ -311,6 +311,32 @@ export function SecretsPage() { · 310 unmodified lines
311311 </Button>
312312 </div>
313313
314+ {/* Provider plugins */}
315+ {secretProviderPlugins.length > 0 && (
316+ <div className="mb-10">
317+ <div className="flex items-center gap-3 mb-4">
318+ <h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/60">
319+ Providers
320+ </h2>
321+ <div className="flex-1 h-px bg-border/50" />
322+ </div>
323+ <div className="grid gap-3">
324+ {secretProviderPlugins.map((plugin) => (
325+ <Suspense
326+ key={plugin.key}
327+ fallback={
328+ <div className="rounded-xl border border-border/60 bg-card p-5 animate-pulse">
329+ <div className="h-4 w-24 rounded bg-muted" />
330+ </div>
331+ }
332+ >
333+ <plugin.settings />
334+ </Suspense>
335+ ))}
336+ </div>
337+ </div>
338+ )}
339+
314340 {/* Secrets list */}
315341 {Result.match(secrets, {
316342 onInitial: () => (
@@ -355,32 +381,6 @@ export function SecretsPage() { · 38 unmodified lines
355381 ),
356382 })}
357383
358− {/* Provider plugins */}
359− {secretProviderPlugins.length > 0 && (
360− <div className="mt-12">
361− <div className="flex items-center gap-3 mb-4">
362− <h2 className="text-[11px] font-semibold uppercase tracking-[0.08em] text-muted-foreground/60">
363− Providers
364− </h2>
365− <div className="flex-1 h-px bg-border/50" />
366− </div>
367− <div className="grid gap-3">
368− {secretProviderPlugins.map((plugin) => (
369− <Suspense
370− key={plugin.key}
371− fallback={
372− <div className="rounded-xl border border-border/60 bg-card p-5 animate-pulse">
373− <div className="h-4 w-24 rounded bg-muted" />
374− </div>
375− }
376− >
377− <plugin.settings />
378− </Suspense>
379− ))}
380− </div>
381− </div>
382− )}
383−
384384 <AddSecretDialog open={addOpen} onOpenChange={setAddOpen} />
385385 </div>
386386 </div>
apps/web/src/pages/sources.tsx+6−1
@@ -4,13 +4,18 @@ import { Result, useAtomValue, useAtomRefresh, sourcesAtom } from "@executor/rea · 3 unmodified lines
44 import type { SourcePlugin } from "@executor/react";
55 import { openApiSourcePlugin } from "@executor/plugin-openapi/react";
66 import { mcpSourcePlugin } from "@executor/plugin-mcp/react";
7+import { googleDiscoverySourcePlugin } from "@executor/plugin-google-discovery/react";
78 import { McpInstallCard } from "../components/mcp-install-card";
89
910 // ---------------------------------------------------------------------------
1011 // Registered source plugins
1112 // ---------------------------------------------------------------------------
1213
13−const sourcePlugins: SourcePlugin[] = [openApiSourcePlugin, mcpSourcePlugin];
14+const sourcePlugins: SourcePlugin[] = [
15+ openApiSourcePlugin,
16+ mcpSourcePlugin,
17+ googleDiscoverySourcePlugin,
18+];
1419
1520 // ---------------------------------------------------------------------------
1621 // Page
bun.lock+40−0
@@ -21,6 +21,7 @@ · 20 unmodified lines
2121 "@executor/api": "workspace:*",
2222 "@executor/host-mcp": "workspace:*",
2323 "@executor/plugin-file-secrets": "workspace:*",
24+ "@executor/plugin-google-discovery": "workspace:*",
2425 "@executor/plugin-keychain": "workspace:*",
2526 "@executor/plugin-mcp": "workspace:*",
2627 "@executor/plugin-onepassword": "workspace:*",
@@ -40,6 +41,7 @@ · 13 unmodified lines
4041 "name": "@executor/web",
4142 "version": "0.0.0",
4243 "dependencies": {
44+ "@executor/plugin-google-discovery": "workspace:*",
4345 "@executor/plugin-mcp": "workspace:*",
4446 "@executor/plugin-onepassword": "workspace:*",
4547 "@executor/plugin-openapi": "workspace:*",
@@ -75,6 +77,7 @@ · 29 unmodified lines
7577 "@effect/platform": "catalog:",
7678 "@executor/api": "workspace:*",
7779 "@executor/sdk": "workspace:*",
80+ "@executor/ui": "workspace:*",
7881 "effect": "catalog:",
7982 "react": "^19.1.0",
8083 },
@@ -213,6 +216,41 @@ · 132 unmodified lines
213216 "vitest": "catalog:",
214217 },
215218 },
219+ "packages/plugins/google-discovery": {
220+ "name": "@executor/plugin-google-discovery",
221+ "version": "0.0.0",
222+ "dependencies": {
223+ "@effect/platform": "catalog:",
224+ "@executor/api": "workspace:*",
225+ "@executor/sdk": "workspace:*",
226+ "effect": "catalog:",
227+ },
228+ "devDependencies": {
229+ "@effect-atom/atom-react": "^0.5.0",
230+ "@effect/vitest": "catalog:",
231+ "@executor/react": "workspace:*",
232+ "@executor/ui": "workspace:*",
233+ "@types/node": "catalog:",
234+ "@types/react": "^19.1.0",
235+ "bun-types": "catalog:",
236+ "react": "^19.1.0",
237+ "vitest": "catalog:",
238+ },
239+ "peerDependencies": {
240+ "@effect-atom/atom-react": "^0.5.0",
241+ "@executor/react": "workspace:*",
242+ "@executor/ui": "workspace:*",
243+ "@tanstack/react-router": "^1.168.10",
244+ "react": "^19.1.0",
245+ },
246+ "optionalPeers": [
247+ "@effect-atom/atom-react",
248+ "@executor/react",
249+ "@executor/ui",
250+ "@tanstack/react-router",
251+ "react",
252+ ],
253+ },
216254 "packages/plugins/keychain": {
217255 "name": "@executor/plugin-keychain",
218256 "version": "0.0.0",
@@ -555,6 +593,8 @@ · 336 unmodified lines
555593
556594 "@executor/plugin-file-secrets": ["@executor/plugin-file-secrets@workspace:packages/plugins/file-secrets"],
557595
596+ "@executor/plugin-google-discovery": ["@executor/plugin-google-discovery@workspace:packages/plugins/google-discovery"],
597+
558598 "@executor/plugin-keychain": ["@executor/plugin-keychain@workspace:packages/plugins/keychain"],
559599
560600 "@executor/plugin-mcp": ["@executor/plugin-mcp@workspace:packages/plugins/mcp"],
packages/clients/react/package.json+1−0
@@ -11,6 +11,7 @@ · 10 unmodified lines
1111 "@effect-atom/atom": "^0.5.3",
1212 "@effect-atom/atom-react": "^0.5.0",
1313 "@executor/api": "workspace:*",
14+ "@executor/ui": "workspace:*",
1415 "@executor/sdk": "workspace:*",
1516 "effect": "catalog:",
1617 "react": "^19.1.0"
packages/clients/react/src/index.ts+2−0
@@ -37,6 +37,8 @@ export { · 36 unmodified lines
3737
3838 // Provider
3939 export { ExecutorProvider } from "./provider";
40+export { SecretPicker } from "./secret-picker";
41+export type { SecretPickerSecret } from "./secret-picker";
4042
4143 // Re-export commonly needed SDK types
4244 export { ScopeId, ToolId } from "@executor/sdk";
packages/clients/react/src/secret-picker.tsx+136−0
@@ -0,0 +1,136 @@
1+import { useState, type ChangeEvent, type FocusEvent } from "react";
2+
3+import { Input } from "@executor/ui/components/input";
4+import {
5+ Command,
6+ CommandEmpty,
7+ CommandGroup,
8+ CommandItem,
9+ CommandList,
10+} from "@executor/ui/components/command";
11+import {
12+ Popover,
13+ PopoverAnchor,
14+ PopoverContent,
15+} from "@executor/ui/components/popover";
16+
17+export interface SecretPickerSecret {
18+ readonly id: string;
19+ readonly name: string;
20+ readonly provider?: string;
21+}
22+
23+const PROVIDER_LABELS: Record<string, string> = {
24+ keychain: "Keychain",
25+ file: "Local",
26+ memory: "Memory",
27+ onepassword: "1Password",
28+};
29+
30+const providerLabel = (key: string | undefined): string => {
31+ if (!key) return "Local";
32+ return PROVIDER_LABELS[key] ?? key;
33+};
34+
35+export function SecretPicker(props: {
36+ readonly value: string | null;
37+ readonly onSelect: (secretId: string) => void;
38+ readonly secrets: readonly SecretPickerSecret[];
39+ readonly placeholder?: string;
40+}) {
41+ const { value, onSelect, secrets, placeholder = "Search secrets…" } = props;
42+ const [open, setOpen] = useState(false);
43+ const [query, setQuery] = useState("");
44+
45+ const selected = secrets.find((secret) => secret.id === value) ?? null;
46+
47+ const grouped = new Map<string, SecretPickerSecret[]>();
48+ for (const secret of secrets) {
49+ const key = providerLabel(secret.provider);
50+ const group = grouped.get(key);
51+ if (group) {
52+ group.push(secret);
53+ } else {
54+ grouped.set(key, [secret]);
55+ }
56+ }
57+
58+ const groups: [string, SecretPickerSecret[]][] = [...grouped.entries()]
59+ .map(([label, items]): [string, SecretPickerSecret[]] => [
60+ label,
61+ [...items].sort((a, b) => a.name.localeCompare(b.name)),
62+ ])
63+ .sort(([a], [b]) => a.localeCompare(b));
64+
65+ return (
66+ <div className="relative w-full">
67+ <Popover open={open} onOpenChange={setOpen} modal={false}>
68+ <PopoverAnchor asChild>
69+ <Input
70+ value={open ? query : (selected ? selected.name : "")}
71+ onChange={(event: ChangeEvent<HTMLInputElement>) => {
72+ setQuery(event.target.value);
73+ if (!open) setOpen(true);
74+ }}
75+ onFocus={() => setOpen(true)}
76+ onBlur={(event: FocusEvent<HTMLInputElement>) => {
77+ const related = event.relatedTarget as HTMLElement | null;
78+ if (related?.closest("[data-slot=popover-content]")) return;
79+ setOpen(false);
80+ }}
81+ placeholder={placeholder}
82+ className="text-sm"
83+ />
84+ </PopoverAnchor>
85+ <PopoverContent
86+ className="w-(--radix-popover-trigger-width) p-0"
87+ align="start"
88+ onOpenAutoFocus={(event: Event) => event.preventDefault()}
89+ onCloseAutoFocus={(event: Event) => event.preventDefault()}
90+ onInteractOutside={(event: Event) => {
91+ const target = event.target as HTMLElement | null;
92+ if (target?.closest("[data-slot=popover-anchor]")) {
93+ event.preventDefault();
94+ }
95+ }}
96+ >
97+ <Command shouldFilter={false}>
98+ <CommandList>
99+ <CommandEmpty>No secrets found</CommandEmpty>
100+ {groups.map(([label, items]) => {
101+ const lowerQuery = query.toLowerCase();
102+ const filtered = lowerQuery
103+ ? items.filter((secret) =>
104+ secret.name.toLowerCase().includes(lowerQuery) ||
105+ secret.id.toLowerCase().includes(lowerQuery),
106+ )
107+ : items;
108+ if (filtered.length === 0) return null;
109+ return (
110+ <CommandGroup key={label} heading={label}>
111+ {filtered.map((secret) => (
112+ <CommandItem
113+ key={secret.id}
114+ value={`${secret.name} ${secret.id}`}
115+ onSelect={() => {
116+ onSelect(secret.id);
117+ setOpen(false);
118+ setQuery("");
119+ }}
120+ >
121+ <span className="truncate">{secret.name}</span>
122+ <span className="ml-auto truncate text-[10px] font-mono text-muted-foreground">
123+ {secret.id}
124+ </span>
125+ </CommandItem>
126+ ))}
127+ </CommandGroup>
128+ );
129+ })}
130+ </CommandList>
131+ </Command>
132+ </PopoverContent>
133+ </Popover>
134+ </div>
135+ );
136+}
packages/plugins/google-discovery/fixtures/drive.json+96−0
@@ -0,0 +1,96 @@
1+{
2+ "name": "drive",
3+ "version": "v3",
4+ "title": "Google Drive",
5+ "rootUrl": "https://www.googleapis.com/",
6+ "servicePath": "drive/v3/",
7+ "parameters": {
8+ "prettyPrint": {
9+ "location": "query",
10+ "type": "boolean",
11+ "description": "Returns response with indentations and line breaks."
12+ }
13+ },
14+ "resources": {
15+ "files": {
16+ "methods": {
17+ "get": {
18+ "id": "drive.files.get",
19+ "path": "files/{fileId}",
20+ "httpMethod": "GET",
21+ "parameters": {
22+ "fileId": {
23+ "location": "path",
24+ "type": "string",
25+ "required": true
26+ },
27+ "fields": {
28+ "location": "query",
29+ "type": "string"
30+ }
31+ },
32+ "response": {
33+ "$ref": "File"
34+ },
35+ "scopes": [
36+ "https://www.googleapis.com/auth/drive.readonly"
37+ ]
38+ },
39+ "update": {
40+ "id": "drive.files.update",
41+ "path": "files/{fileId}",
42+ "httpMethod": "PATCH",
43+ "parameters": {
44+ "fileId": {
45+ "location": "path",
46+ "type": "string",
47+ "required": true
48+ }
49+ },
50+ "request": {
51+ "$ref": "UpdateFileRequest"
52+ },
53+ "response": {
54+ "$ref": "File"
55+ },
56+ "scopes": [
57+ "https://www.googleapis.com/auth/drive"
58+ ]
59+ }
60+ }
61+ }
62+ },
63+ "schemas": {
64+ "File": {
65+ "type": "object",
66+ "properties": {
67+ "id": {
68+ "type": "string"
69+ },
70+ "name": {
71+ "type": "string"
72+ }
73+ }
74+ },
75+ "UpdateFileRequest": {
76+ "type": "object",
77+ "properties": {
78+ "name": {
79+ "type": "string"
80+ }
81+ }
82+ }
83+ },
84+ "auth": {
85+ "oauth2": {
86+ "scopes": {
87+ "https://www.googleapis.com/auth/drive": {
88+ "description": "See, edit, create, and delete all of your Google Drive files"
89+ },
90+ "https://www.googleapis.com/auth/drive.readonly": {
91+ "description": "See and download all your Google Drive files"
92+ }
93+ }
94+ }
95+ }
96+}
packages/plugins/google-discovery/package.json+47−0
@@ -0,0 +1,47 @@
1+{
2+ "name": "@executor/plugin-google-discovery",
3+ "private": true,
4+ "type": "module",
5+ "version": "0.0.0",
6+ "exports": {
7+ ".": "./src/sdk/index.ts",
8+ "./api": "./src/api/index.ts",
9+ "./react": "./src/react/index.ts"
10+ },
11+ "scripts": {
12+ "typecheck": "bunx tsc --noEmit -p tsconfig.json",
13+ "test": "vitest run",
14+ "test:watch": "vitest"
15+ },
16+ "dependencies": {
17+ "@effect/platform": "catalog:",
18+ "@executor/api": "workspace:*",
19+ "@executor/sdk": "workspace:*",
20+ "effect": "catalog:"
21+ },
22+ "peerDependencies": {
23+ "react": "^19.1.0",
24+ "@effect-atom/atom-react": "^0.5.0",
25+ "@tanstack/react-router": "^1.168.10",
26+ "@executor/react": "workspace:*",
27+ "@executor/ui": "workspace:*"
28+ },
29+ "peerDependenciesMeta": {
30+ "react": { "optional": true },
31+ "@effect-atom/atom-react": { "optional": true },
32+ "@tanstack/react-router": { "optional": true },
33+ "@executor/react": { "optional": true },
34+ "@executor/ui": { "optional": true }
35+ },
36+ "devDependencies": {
37+ "@effect-atom/atom-react": "^0.5.0",
38+ "@effect/vitest": "catalog:",
39+ "@executor/react": "workspace:*",
40+ "@executor/ui": "workspace:*",
41+ "@types/node": "catalog:",
42+ "@types/react": "^19.1.0",
43+ "bun-types": "catalog:",
44+ "react": "^19.1.0",
45+ "vitest": "catalog:"
46+ }
47+}
packages/plugins/google-discovery/src/api/group.ts+128−0
@@ -0,0 +1,128 @@
1+import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema } from "@effect/platform";
2+import { Schema } from "effect";
3+import { ScopeId } from "@executor/sdk";
4+
5+export { HttpApiSchema };
6+
7+const scopeIdParam = HttpApiSchema.param("scopeId", ScopeId);
8+
9+const AuthPayload = Schema.Union(
10+ Schema.Struct({
11+ kind: Schema.Literal("none"),
12+ }),
13+ Schema.Struct({
14+ kind: Schema.Literal("oauth2"),
15+ clientId: Schema.String,
16+ clientSecretSecretId: Schema.NullOr(Schema.String),
17+ accessTokenSecretId: Schema.String,
18+ refreshTokenSecretId: Schema.NullOr(Schema.String),
19+ tokenType: Schema.optional(Schema.String),
20+ expiresAt: Schema.NullOr(Schema.Number),
21+ scope: Schema.NullOr(Schema.String),
22+ scopes: Schema.Array(Schema.String),
23+ }),
24+);
25+
26+const ProbePayload = Schema.Struct({
27+ discoveryUrl: Schema.String,
28+});
29+
30+const ProbeResponse = Schema.Struct({
31+ name: Schema.String,
32+ title: Schema.NullOr(Schema.String),
33+ service: Schema.String,
34+ version: Schema.String,
35+ toolCount: Schema.Number,
36+ scopes: Schema.Array(Schema.String),
37+});
38+
39+const AddSourcePayload = Schema.Struct({
40+ name: Schema.String,
41+ discoveryUrl: Schema.String,
42+ namespace: Schema.optional(Schema.String),
43+ auth: AuthPayload,
44+});
45+
46+const AddSourceResponse = Schema.Struct({
47+ toolCount: Schema.Number,
48+ namespace: Schema.String,
49+});
50+
51+const StartOAuthPayload = Schema.Struct({
52+ name: Schema.String,
53+ discoveryUrl: Schema.String,
54+ clientId: Schema.String,
55+ clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)),
56+ redirectUrl: Schema.String,
57+ scopes: Schema.optional(Schema.Array(Schema.String)),
58+});
59+
60+const StartOAuthResponse = Schema.Struct({
61+ sessionId: Schema.String,
62+ authorizationUrl: Schema.String,
63+ scopes: Schema.Array(Schema.String),
64+});
65+
66+const CompleteOAuthPayload = Schema.Struct({
67+ state: Schema.String,
68+ code: Schema.optional(Schema.String),
69+ error: Schema.optional(Schema.String),
70+});
71+
72+const CompleteOAuthResponse = Schema.Struct({
73+ kind: Schema.Literal("oauth2"),
74+ clientId: Schema.String,
75+ clientSecretSecretId: Schema.NullOr(Schema.String),
76+ accessTokenSecretId: Schema.String,
77+ refreshTokenSecretId: Schema.NullOr(Schema.String),
78+ tokenType: Schema.String,
79+ expiresAt: Schema.NullOr(Schema.Number),
80+ scope: Schema.NullOr(Schema.String),
81+ scopes: Schema.Array(Schema.String),
82+});
83+
84+const OAuthCallbackParams = Schema.Struct({
85+ state: Schema.String,
86+ code: Schema.optional(Schema.String),
87+ error: Schema.optional(Schema.String),
88+ error_description: Schema.optional(Schema.String),
89+});
90+
91+const HtmlResponse = HttpApiSchema.Text({ contentType: "text/html" });
92+
93+const ApiError = Schema.Struct({
94+ message: Schema.String,
95+}).annotations(HttpApiSchema.annotations({ status: 400 }));
96+
97+export class GoogleDiscoveryGroup extends HttpApiGroup.make("googleDiscovery")
98+ .add(
99+ HttpApiEndpoint.post("probeDiscovery")`/scopes/${scopeIdParam}/google-discovery/probe`
100+ .setPayload(ProbePayload)
101+ .addSuccess(ProbeResponse)
102+ .addError(ApiError),
103+ )
104+ .add(
105+ HttpApiEndpoint.post("addSource")`/scopes/${scopeIdParam}/google-discovery/sources`
106+ .setPayload(AddSourcePayload)
107+ .addSuccess(AddSourceResponse)
108+ .addError(ApiError),
109+ )
110+ .add(
111+ HttpApiEndpoint.post("startOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/start`
112+ .setPayload(StartOAuthPayload)
113+ .addSuccess(StartOAuthResponse)
114+ .addError(ApiError),
115+ )
116+ .add(
117+ HttpApiEndpoint.post("completeOAuth")`/scopes/${scopeIdParam}/google-discovery/oauth/complete`
118+ .setPayload(CompleteOAuthPayload)
119+ .addSuccess(CompleteOAuthResponse)
120+ .addError(ApiError),
121+ )
122+ .add(
123+ HttpApiEndpoint.get("oauthCallback")`/google-discovery/oauth/callback`
124+ .setUrlParams(OAuthCallbackParams)
125+ .addSuccess(HtmlResponse)
126+ .addError(ApiError),
127+ )
128+ .prefix("/v1") {}
packages/plugins/google-discovery/src/api/handlers.ts+139−0
@@ -0,0 +1,139 @@
1+import { HttpApiBuilder, HttpServerResponse } from "@effect/platform";
2+import { Context, Effect } from "effect";
3+
4+import { addGroup } from "@executor/api";
5+import type {
6+ GoogleDiscoveryAddSourceInput,
7+ GoogleDiscoveryOAuthAuthResult,
8+ GoogleDiscoveryPluginExtension,
9+} from "../sdk/plugin";
10+import { GoogleDiscoveryGroup } from "./group";
11+
12+export class GoogleDiscoveryExtensionService extends Context.Tag(
13+ "GoogleDiscoveryExtensionService",
14+)<GoogleDiscoveryExtensionService, GoogleDiscoveryPluginExtension>() {}
15+
16+const ExecutorApiWithGoogleDiscovery = addGroup(GoogleDiscoveryGroup);
17+
18+type OAuthPopupResult =
19+ | ({
20+ type: "executor:oauth-result";
21+ ok: true;
22+ sessionId: string;
23+ } & GoogleDiscoveryOAuthAuthResult)
24+ | {
25+ type: "executor:oauth-result";
26+ ok: false;
27+ sessionId: null;
28+ error: string;
29+ };
30+
31+const escapeHtml = (value: string): string =>
32+ value
33+ .replaceAll("&", "&amp;")
34+ .replaceAll("<", "&lt;")
35+ .replaceAll(">", "&gt;")
36+ .replaceAll('"', "&quot;")
37+ .replaceAll("'", "&#39;");
38+
39+const popupDocument = (payload: OAuthPopupResult): string => {
40+ const serialized = JSON.stringify(payload)
41+ .replaceAll("<", "\\u003c")
42+ .replaceAll(">", "\\u003e")
43+ .replaceAll("&", "\\u0026");
44+ const title = payload.ok ? "Connected" : "Connection failed";
45+ const message = payload.ok
46+ ? "Authentication complete. This window will close automatically."
47+ : payload.error;
48+ const statusColor = payload.ok ? "#22c55e" : "#ef4444";
49+
50+ return `<!doctype html>
51+<html lang="en">
52+<head><meta charset="utf-8"/><meta name="viewport" content="width=device-width,initial-scale=1"/><title>${escapeHtml(title)}</title></head>
53+<body style="font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh;margin:0;background:#fafafa;color:#111">
54+<style>@media(prefers-color-scheme:dark){body{background:#09090b!important;color:#fafafa!important}p{color:#a1a1aa!important}}</style>
55+<main style="text-align:center;max-width:360px;padding:24px">
56+<div style="width:40px;height:40px;border-radius:50%;background:${statusColor};margin:0 auto 16px;display:flex;align-items:center;justify-content:center">
57+<svg width="20" height="20" viewBox="0 0 20 20" fill="none">${payload.ok ? '<path d="M6 10l3 3 5-6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>' : '<path d="M7 7l6 6M13 7l-6 6" stroke="white" stroke-width="2" stroke-linecap="round"/>'}</svg>
58+</div>
59+<h1 style="margin:0 0 8px;font-size:18px;font-weight:600">${escapeHtml(title)}</h1>
60+<p style="margin:0;font-size:14px;color:#666;line-height:1.5">${escapeHtml(message)}</p>
61+</main>
62+<script>
63+(()=>{const p=${serialized};try{if(window.opener)window.opener.postMessage(p,window.location.origin)}finally{setTimeout(()=>window.close(),150)}})();
64+</script>
65+</body></html>`;
66+};
67+
68+export const GoogleDiscoveryHandlers = HttpApiBuilder.group(
69+ ExecutorApiWithGoogleDiscovery,
70+ "googleDiscovery",
71+ (handlers) =>
72+ handlers
73+ .handle("probeDiscovery", ({ payload }) =>
74+ Effect.gen(function* () {
75+ const ext = yield* GoogleDiscoveryExtensionService;
76+ return yield* ext.probeDiscovery(payload.discoveryUrl);
77+ }).pipe(Effect.orDie),
78+ )
79+ .handle("addSource", ({ payload }) =>
80+ Effect.gen(function* () {
81+ const ext = yield* GoogleDiscoveryExtensionService;
82+ return yield* ext.addSource(payload as GoogleDiscoveryAddSourceInput);
83+ }).pipe(Effect.orDie),
84+ )
85+ .handle("startOAuth", ({ payload }) =>
86+ Effect.gen(function* () {
87+ const ext = yield* GoogleDiscoveryExtensionService;
88+ return yield* ext.startOAuth({
89+ name: payload.name,
90+ discoveryUrl: payload.discoveryUrl,
91+ clientId: payload.clientId,
92+ clientSecretSecretId: payload.clientSecretSecretId,
93+ redirectUrl: payload.redirectUrl,
94+ scopes: payload.scopes,
95+ });
96+ }).pipe(Effect.orDie),
97+ )
98+ .handle("completeOAuth", ({ payload }) =>
99+ Effect.gen(function* () {
100+ const ext = yield* GoogleDiscoveryExtensionService;
101+ return yield* ext.completeOAuth({
102+ state: payload.state,
103+ code: payload.code,
104+ error: payload.error,
105+ });
106+ }).pipe(Effect.orDie),
107+ )
108+ .handle("oauthCallback", ({ urlParams }) =>
109+ Effect.gen(function* () {
110+ const ext = yield* GoogleDiscoveryExtensionService;
111+ const result = yield* ext
112+ .completeOAuth({
113+ state: urlParams.state,
114+ code: urlParams.code,
115+ error: urlParams.error ?? urlParams.error_description,
116+ })
117+ .pipe(
118+ Effect.map(
119+ (auth): OAuthPopupResult => ({
120+ type: "executor:oauth-result",
121+ ok: true,
122+ sessionId: urlParams.state,
123+ ...auth,
124+ }),
125+ ),
126+ Effect.catchAll((error) =>
127+ Effect.succeed<OAuthPopupResult>({
128+ type: "executor:oauth-result",
129+ ok: false,
130+ sessionId: null,
131+ error:
132+ error instanceof Error ? error.message : String(error),
133+ }),
134+ ),
135+ );
136+ return yield* HttpServerResponse.html(popupDocument(result));
137+ }).pipe(Effect.orDie),
138+ ),
139+);
packages/plugins/google-discovery/src/api/index.ts+5−0
@@ -0,0 +1,5 @@
1+export { GoogleDiscoveryGroup } from "./group";
2+export {
3+ GoogleDiscoveryExtensionService,
4+ GoogleDiscoveryHandlers,
5+} from "./handlers";
packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx+667−0
@@ -0,0 +1,667 @@
1+import { useCallback, useMemo, useState } from "react";
2+import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react";
3+
4+import {
5+ ScopeId,
6+ SecretPicker,
7+ secretsAtom,
8+ type SecretPickerSecret,
9+} from "@executor/react";
10+import { Badge } from "@executor/ui/components/badge";
11+import { Button } from "@executor/ui/components/button";
12+import {
13+ Collapsible,
14+ CollapsibleContent,
15+ CollapsibleTrigger,
16+} from "@executor/ui/components/collapsible";
17+import { Input } from "@executor/ui/components/input";
18+import { Label } from "@executor/ui/components/label";
19+import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group";
20+import { Spinner } from "@executor/ui/components/spinner";
21+import {
22+ addGoogleDiscoverySource,
23+ probeGoogleDiscovery,
24+ startGoogleDiscoveryOAuth,
25+} from "./atoms";
26+
27+type GoogleDiscoveryTemplate = {
28+ id: string;
29+ name: string;
30+ summary: string;
31+ service: string;
32+ version: string;
33+ discoveryUrl: string;
34+};
35+
36+const defaultGoogleDiscoveryUrl = (service: string, version: string): string =>
37+ `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`;
38+
39+const googleDiscoveryTemplate = (
40+ template: GoogleDiscoveryTemplate,
41+): GoogleDiscoveryTemplate => ({
42+ ...template,
43+ discoveryUrl:
44+ template.discoveryUrl || defaultGoogleDiscoveryUrl(template.service, template.version),
45+});
46+
47+const GOOGLE_DISCOVERY_TEMPLATES: readonly GoogleDiscoveryTemplate[] = [
48+ googleDiscoveryTemplate({
49+ id: "google-calendar",
50+ name: "Google Calendar",
51+ summary: "Calendars, events, ACLs, and scheduling workflows.",
52+ service: "calendar",
53+ version: "v3",
54+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest",
55+ }),
56+ googleDiscoveryTemplate({
57+ id: "google-drive",
58+ name: "Google Drive",
59+ summary: "Files, folders, permissions, comments, and shared drives.",
60+ service: "drive",
61+ version: "v3",
62+ discoveryUrl: defaultGoogleDiscoveryUrl("drive", "v3"),
63+ }),
64+ googleDiscoveryTemplate({
65+ id: "google-gmail",
66+ name: "Gmail",
67+ summary: "Messages, threads, labels, drafts, and mailbox automation.",
68+ service: "gmail",
69+ version: "v1",
70+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/gmail/v1/rest",
71+ }),
72+ googleDiscoveryTemplate({
73+ id: "google-docs",
74+ name: "Google Docs",
75+ summary: "Documents, structural edits, text ranges, and formatting.",
76+ service: "docs",
77+ version: "v1",
78+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest",
79+ }),
80+ googleDiscoveryTemplate({
81+ id: "google-sheets",
82+ name: "Google Sheets",
83+ summary: "Spreadsheets, values, ranges, formatting, and batch updates.",
84+ service: "sheets",
85+ version: "v4",
86+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest",
87+ }),
88+ googleDiscoveryTemplate({
89+ id: "google-slides",
90+ name: "Google Slides",
91+ summary: "Presentations, slides, page elements, and deck updates.",
92+ service: "slides",
93+ version: "v1",
94+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/slides/v1/rest",
95+ }),
96+ googleDiscoveryTemplate({
97+ id: "google-forms",
98+ name: "Google Forms",
99+ summary: "Forms, questions, responses, quizzes, and form metadata.",
100+ service: "forms",
101+ version: "v1",
102+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/forms/v1/rest",
103+ }),
104+ googleDiscoveryTemplate({
105+ id: "google-people",
106+ name: "Google People",
107+ summary: "Contacts, profiles, directory people, and contact groups.",
108+ service: "people",
109+ version: "v1",
110+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/people/v1/rest",
111+ }),
112+ googleDiscoveryTemplate({
113+ id: "google-tasks",
114+ name: "Google Tasks",
115+ summary: "Task lists, task items, notes, and due dates.",
116+ service: "tasks",
117+ version: "v1",
118+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest",
119+ }),
120+ googleDiscoveryTemplate({
121+ id: "google-chat",
122+ name: "Google Chat",
123+ summary: "Spaces, messages, members, reactions, and chat workflows.",
124+ service: "chat",
125+ version: "v1",
126+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/chat/v1/rest",
127+ }),
128+ googleDiscoveryTemplate({
129+ id: "google-bigquery",
130+ name: "Google BigQuery",
131+ summary: "Datasets, tables, jobs, routines, and analytics workflows.",
132+ service: "bigquery",
133+ version: "v2",
134+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/bigquery/v2/rest",
135+ }),
136+ googleDiscoveryTemplate({
137+ id: "google-youtube",
138+ name: "YouTube Data",
139+ summary: "Channels, playlists, videos, comments, captions, and uploads.",
140+ service: "youtube",
141+ version: "v3",
142+ discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest",
143+ }),
144+];
145+
146+const GOOGLE_SERVICE_ICON_URLS: Record<string, string> = {
147+ calendar: "https://fonts.gstatic.com/s/i/productlogos/calendar_2020q4/v8/192px.svg",
148+ drive: "https://fonts.gstatic.com/s/i/productlogos/drive_2020q4/v8/192px.svg",
149+ gmail: "https://fonts.gstatic.com/s/i/productlogos/gmail_2020q4/v8/web-96dp/logo_gmail_2020q4_color_2x_web_96dp.png",
150+ docs: "https://fonts.gstatic.com/s/i/productlogos/docs_2020q4/v12/192px.svg",
151+ sheets: "https://fonts.gstatic.com/s/i/productlogos/sheets_2020q4/v8/192px.svg",
152+ slides: "https://fonts.gstatic.com/s/i/productlogos/slides_2020q4/v12/192px.svg",
153+ forms: "https://fonts.gstatic.com/s/i/productlogos/forms_2020q4/v6/192px.svg",
154+ people: "https://fonts.gstatic.com/s/i/productlogos/contacts/v9/192px.svg",
155+ tasks: "https://fonts.gstatic.com/s/i/productlogos/tasks/v10/192px.svg",
156+ chat: "https://fonts.gstatic.com/s/i/productlogos/chat_2020q4/v8/192px.svg",
157+ bigquery: "https://fonts.gstatic.com/s/i/productlogos/cloud/v8/192px.svg",
158+ youtube: "https://fonts.gstatic.com/s/i/productlogos/youtube/v9/192px.svg",
159+};
160+
161+function GoogleServiceIcon(props: {
162+ readonly service: string;
163+ readonly className?: string;
164+}) {
165+ const { service, className = "size-11" } = props;
166+ const src = GOOGLE_SERVICE_ICON_URLS[service] ?? GOOGLE_SERVICE_ICON_URLS.bigquery;
167+
168+ return (
169+ <img
170+ alt=""
171+ aria-hidden="true"
172+ className={`${className} shrink-0 object-contain`}
173+ decoding="async"
174+ draggable={false}
175+ loading="lazy"
176+ src={src}
177+ />
178+ );
179+}
180+
181+type ProbeResult = {
182+ name: string;
183+ title: string | null;
184+ service: string;
185+ version: string;
186+ toolCount: number;
187+ scopes: readonly string[];
188+};
189+
190+type OAuthAuth = {
191+ kind: "oauth2";
192+ clientId: string;
193+ clientSecretSecretId: string | null;
194+ accessTokenSecretId: string;
195+ refreshTokenSecretId: string | null;
196+ tokenType: string;
197+ expiresAt: number | null;
198+ scope: string | null;
199+ scopes: string[];
200+};
201+
202+type OAuthPopupResult =
203+ | ({
204+ type: "executor:oauth-result";
205+ ok: true;
206+ sessionId: string;
207+ } & OAuthAuth)
208+ | {
209+ type: "executor:oauth-result";
210+ ok: false;
211+ sessionId: null;
212+ error: string;
213+ };
214+
215+const DEFAULT_SCOPE = ScopeId.make("default");
216+
217+function openOAuthPopup(
218+ url: string,
219+ onResult: (data: OAuthPopupResult) => void,
220+ onClosed?: () => void,
221+): void {
222+ const w = 640;
223+ const h = 760;
224+ const left = window.screenX + (window.outerWidth - w) / 2;
225+ const top = window.screenY + (window.outerHeight - h) / 2;
226+ const popup = window.open(
227+ url,
228+ "google-discovery-oauth",
229+ `width=${w},height=${h},left=${left},top=${top},popup=1`,
230+ );
231+
232+ let settled = false;
233+ const settle = () => {
234+ settled = true;
235+ window.removeEventListener("message", onMessage);
236+ };
237+
238+ const onMessage = (event: MessageEvent) => {
239+ if (
240+ event.origin === window.location.origin &&
241+ event.data?.type === "executor:oauth-result" &&
242+ !settled
243+ ) {
244+ settle();
245+ onResult(event.data as OAuthPopupResult);
246+ }
247+ };
248+ window.addEventListener("message", onMessage);
249+
250+ if (popup) {
251+ const interval = window.setInterval(() => {
252+ if (popup.closed) {
253+ window.clearInterval(interval);
254+ if (!settled) {
255+ settle();
256+ onClosed?.();
257+ }
258+ }
259+ }, 500);
260+ }
261+}
262+
263+export default function AddGoogleDiscoverySource(props: {
264+ readonly onComplete: () => void;
265+ readonly onCancel: () => void;
266+}) {
267+ const defaultTemplate =
268+ GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets")
269+ ?? GOOGLE_DISCOVERY_TEMPLATES[0]!;
270+ const [discoveryUrl, setDiscoveryUrl] = useState(
271+ defaultTemplate.discoveryUrl,
272+ );
273+ const [name, setName] = useState(defaultTemplate.name);
274+ const [selectedTemplateId, setSelectedTemplateId] = useState(defaultTemplate.id);
275+ const selectedTemplate =
276+ GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId)
277+ ?? null;
278+ const [authKind, setAuthKind] = useState<"none" | "oauth2">("oauth2");
279+ const [clientId, setClientId] = useState("");
280+ const [clientSecretSecretId, setClientSecretSecretId] = useState<string | null>(null);
281+ const [probe, setProbe] = useState<ProbeResult | null>(null);
282+ const [oauthAuth, setOauthAuth] = useState<OAuthAuth | null>(null);
283+ const [loadingProbe, setLoadingProbe] = useState(false);
284+ const [startingOAuth, setStartingOAuth] = useState(false);
285+ const [adding, setAdding] = useState(false);
286+ const [error, setError] = useState<string | null>(null);
287+ const [showScopes, setShowScopes] = useState(false);
288+
289+ const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promise" });
290+ const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" });
291+ const doStartOAuth = useAtomSet(startGoogleDiscoveryOAuth, { mode: "promise" });
292+ const secrets = useAtomValue(secretsAtom());
293+
294+ const canUseOAuth = useMemo(
295+ () => (probe?.scopes.length ?? 0) > 0,
296+ [probe],
297+ );
298+ const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {
299+ onInitial: () => [] as SecretPickerSecret[],
300+ onFailure: () => [] as SecretPickerSecret[],
301+ onSuccess: ({ value }) =>
302+ value.map((secret) => ({
303+ id: secret.id,
304+ name: secret.name,
305+ provider: secret.provider ? String(secret.provider) : undefined,
306+ })),
307+ });
308+
309+ const applyTemplate = useCallback((template: GoogleDiscoveryTemplate) => {
310+ setSelectedTemplateId(template.id);
311+ setDiscoveryUrl(template.discoveryUrl);
312+ setName(template.name);
313+ setClientSecretSecretId(null);
314+ setProbe(null);
315+ setOauthAuth(null);
316+ setError(null);
317+ setShowScopes(false);
318+ setAuthKind("oauth2");
319+ }, []);
320+
321+ const handleProbe = useCallback(async () => {
322+ setLoadingProbe(true);
323+ setError(null);
324+ setOauthAuth(null);
325+ setShowScopes(false);
326+ try {
327+ const result = await doProbe({
328+ path: { scopeId: DEFAULT_SCOPE },
329+ payload: { discoveryUrl: discoveryUrl.trim() },
330+ });
331+ setProbe({ ...result, scopes: [...result.scopes] });
332+ if (!name.trim()) {
333+ setName(result.name);
334+ }
335+ if (result.scopes.length === 0) {
336+ setAuthKind("none");
337+ }
338+ } catch (e) {
339+ setProbe(null);
340+ setError(e instanceof Error ? e.message : "Failed to inspect discovery document");
341+ } finally {
342+ setLoadingProbe(false);
343+ }
344+ }, [discoveryUrl, doProbe, name]);
345+
346+ const handleStartOAuth = useCallback(async () => {
347+ if (!probe) return;
348+ setStartingOAuth(true);
349+ setError(null);
350+ try {
351+ const response = await doStartOAuth({
352+ path: { scopeId: DEFAULT_SCOPE },
353+ payload: {
354+ name: name.trim() || probe.name,
355+ discoveryUrl: discoveryUrl.trim(),
356+ clientId: clientId.trim(),
357+ clientSecretSecretId,
358+ redirectUrl: `${window.location.origin}/v1/google-discovery/oauth/callback`,
359+ scopes: probe.scopes,
360+ },
361+ });
362+
363+ openOAuthPopup(
364+ response.authorizationUrl,
365+ (result) => {
366+ setStartingOAuth(false);
367+ if (result.ok) {
368+ setOauthAuth({
369+ kind: "oauth2",
370+ clientId: result.clientId,
371+ clientSecretSecretId: result.clientSecretSecretId,
372+ accessTokenSecretId: result.accessTokenSecretId,
373+ refreshTokenSecretId: result.refreshTokenSecretId,
374+ tokenType: result.tokenType,
375+ expiresAt: result.expiresAt,
376+ scope: result.scope,
377+ scopes: [...result.scopes],
378+ });
379+ setError(null);
380+ } else {
381+ setError(result.error);
382+ }
383+ },
384+ () => {
385+ setStartingOAuth(false);
386+ },
387+ );
388+ } catch (e) {
389+ setStartingOAuth(false);
390+ setError(e instanceof Error ? e.message : "Failed to start OAuth");
391+ }
392+ }, [probe, doStartOAuth, name, discoveryUrl, clientId, clientSecretSecretId]);
393+
394+ const handleAdd = useCallback(async () => {
395+ if (!probe) return;
396+ setAdding(true);
397+ setError(null);
398+ try {
399+ await doAdd({
400+ path: { scopeId: DEFAULT_SCOPE },
401+ payload: {
402+ name: name.trim() || probe.name,
403+ discoveryUrl: discoveryUrl.trim(),
404+ auth:
405+ authKind === "oauth2"
406+ ? oauthAuth ?? { kind: "none" as const }
407+ : { kind: "none" as const },
408+ },
409+ });
410+ props.onComplete();
411+ } catch (e) {
412+ setError(e instanceof Error ? e.message : "Failed to add source");
413+ setAdding(false);
414+ }
415+ }, [probe, doAdd, name, discoveryUrl, authKind, oauthAuth, props]);
416+
417+ const addDisabled =
418+ !probe ||
419+ adding ||
420+ (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null));
421+
422+ return (
423+ <div className="space-y-6">
424+ <div>
425+ <h1 className="text-xl font-semibold text-foreground">
426+ Add Google Discovery Source
427+ </h1>
428+ <p className="mt-1 text-[13px] text-muted-foreground">
429+ Connect a Google API from its Discovery document and register its methods as tools.
430+ </p>
431+ </div>
432+
433+ <section className="space-y-2">
434+ <div className="flex items-center justify-between gap-3">
435+ <Label>Presets</Label>
436+ <span className="text-xs text-muted-foreground">
437+ Select a Google API to prefill the source.
438+ </span>
439+ </div>
440+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
441+ {GOOGLE_DISCOVERY_TEMPLATES.map((template) => {
442+ const selected = template.id === selectedTemplateId;
443+ return (
444+ <button
445+ key={template.id}
446+ type="button"
447+ onClick={() => applyTemplate(template)}
448+ className={`relative rounded-xl border px-4 py-3 text-left transition-colors ${
449+ selected
450+ ? "border-primary bg-primary/5 shadow-[0_0_0_1px_rgba(0,0,0,0.02)]"
451+ : "border-border bg-card hover:border-primary/30 hover:bg-card/80"
452+ }`}
453+ >
454+ {selected && (
455+ <Badge variant="secondary" className="absolute top-3 right-3">
456+ Selected
457+ </Badge>
458+ )}
459+ <div className="flex min-w-0 gap-3 pr-20">
460+ <div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background/80 shadow-xs">
461+ <GoogleServiceIcon service={template.service} className="size-6" />
462+ </div>
463+ <div className="min-w-0">
464+ <p className="text-sm font-semibold text-foreground">
465+ {template.name}
466+ </p>
467+ <p className="mt-1 text-xs text-muted-foreground">
468+ {template.summary}
469+ </p>
470+ </div>
471+ </div>
472+ <div className="mt-3 flex items-center justify-between gap-3">
473+ <p className="text-[11px] font-mono text-muted-foreground">
474+ {template.service} · {template.version}
475+ </p>
476+ <div className="h-px flex-1 bg-border/70" />
477+ </div>
478+ </button>
479+ );
480+ })}
481+ </div>
482+ </section>
483+
484+ <section className="space-y-2">
485+ <Label>Discovery URL</Label>
486+ <div className="flex gap-2">
487+ <Input
488+ value={discoveryUrl}
489+ onChange={(e) => {
490+ setSelectedTemplateId("");
491+ setDiscoveryUrl((e.target as HTMLInputElement).value);
492+ }}
493+ placeholder="https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest"
494+ className="flex-1 font-mono text-sm"
495+ />
496+ <Button onClick={handleProbe} disabled={!discoveryUrl.trim() || loadingProbe}>
497+ {loadingProbe ? <><Spinner className="size-3.5" /> Inspecting…</> : "Inspect"}
498+ </Button>
499+ </div>
500+ </section>
501+
502+ <section className="space-y-2">
503+ <Label>Display Name</Label>
504+ <Input
505+ value={name}
506+ onChange={(e) => setName((e.target as HTMLInputElement).value)}
507+ placeholder="Google Sheets"
508+ />
509+ </section>
510+
511+ {probe && (
512+ <section className="space-y-3 rounded-xl border border-border bg-card px-4 py-4">
513+ <div className="flex items-center justify-between gap-3">
514+ <div className="flex items-center gap-3">
515+ <div className="flex size-9 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background/80 shadow-xs">
516+ <GoogleServiceIcon
517+ service={selectedTemplate?.service ?? probe.service}
518+ className="size-5"
519+ />
520+ </div>
521+ <div>
522+ <p className="text-sm font-semibold text-foreground">
523+ {probe.title ?? probe.name}
524+ </p>
525+ <p className="mt-1 text-xs text-muted-foreground">
526+ {probe.service} · {probe.version}
527+ </p>
528+ </div>
529+ </div>
530+ <div className="flex gap-2">
531+ <Badge variant="secondary">{probe.toolCount} tools</Badge>
532+ <Badge variant="outline">{probe.scopes.length} scopes</Badge>
533+ </div>
534+ </div>
535+ </section>
536+ )}
537+
538+ <section className="space-y-3">
539+ <RadioGroup
540+ value={authKind}
541+ onValueChange={(value) => setAuthKind(value as "none" | "oauth2")}
542+ className="flex items-center gap-4"
543+ >
544+ <div className="flex items-center gap-2">
545+ <RadioGroupItem id="google-discovery-auth-none" value="none" />
546+ <Label htmlFor="google-discovery-auth-none" className="text-sm">
547+ No auth
548+ </Label>
549+ </div>
550+ <div className="flex items-center gap-2">
551+ <RadioGroupItem
552+ id="google-discovery-auth-oauth2"
553+ value="oauth2"
554+ />
555+ <Label htmlFor="google-discovery-auth-oauth2" className="text-sm">
556+ OAuth 2.0
557+ </Label>
558+ </div>
559+ </RadioGroup>
560+
561+ {authKind === "oauth2" && (
562+ <div className="space-y-3 rounded-xl border border-border bg-card px-4 py-4">
563+ <div className="space-y-2">
564+ <Label>OAuth Client ID</Label>
565+ <Input
566+ value={clientId}
567+ onChange={(e) => setClientId((e.target as HTMLInputElement).value)}
568+ placeholder="1234567890-abc.apps.googleusercontent.com"
569+ />
570+ </div>
571+ <div className="space-y-2">
572+ <Label>OAuth Client Secret</Label>
573+ <div className="flex items-center gap-2">
574+ <div className="flex-1 min-w-0">
575+ <SecretPicker
576+ value={clientSecretSecretId}
577+ onSelect={setClientSecretSecretId}
578+ secrets={secretList}
579+ placeholder="Optional for confidential clients"
580+ />
581+ </div>
582+ {clientSecretSecretId && (
583+ <Button
584+ variant="outline"
585+ onClick={() => setClientSecretSecretId(null)}
586+ >
587+ Clear
588+ </Button>
589+ )}
590+ </div>
591+ </div>
592+ <Collapsible
593+ open={showScopes}
594+ onOpenChange={setShowScopes}
595+ className="space-y-2"
596+ >
597+ <div className="flex items-start justify-between gap-3">
598+ <div className="min-w-0 space-y-1">
599+ <p className="text-xs text-muted-foreground">
600+ {canUseOAuth
601+ ? `${probe?.scopes.length ?? 0} scopes will be requested from Google.`
602+ : "This API does not advertise OAuth scopes."}
603+ </p>
604+ {canUseOAuth && (probe?.scopes.length ?? 0) > 0 && (
605+ <CollapsibleTrigger asChild>
606+ <button
607+ type="button"
608+ className="text-xs font-medium text-primary hover:underline"
609+ >
610+ {showScopes ? "Hide scopes" : "View scopes"}
611+ </button>
612+ </CollapsibleTrigger>
613+ )}
614+ </div>
615+ <Button
616+ variant="outline"
617+ onClick={handleStartOAuth}
618+ disabled={!probe || !clientId.trim() || !canUseOAuth || startingOAuth}
619+ >
620+ {startingOAuth
621+ ? <><Spinner className="size-3.5" /> Waiting…</>
622+ : oauthAuth
623+ ? "Re-authenticate"
624+ : "Connect Google"}
625+ </Button>
626+ </div>
627+ <CollapsibleContent>
628+ <div className="rounded-lg border border-border/70 bg-muted/20 px-3 py-2">
629+ <ul className="space-y-1">
630+ {(probe?.scopes ?? []).map((scope) => (
631+ <li
632+ key={scope}
633+ className="break-all font-mono text-[11px] text-muted-foreground"
634+ >
635+ {scope}
636+ </li>
637+ ))}
638+ </ul>
639+ </div>
640+ </CollapsibleContent>
641+ </Collapsible>
642+ {oauthAuth && (
643+ <div className="rounded-lg border border-emerald-500/20 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-700 dark:text-emerald-300">
644+ Connected. Access token stored as secret `{oauthAuth.accessTokenSecretId}`.
645+ </div>
646+ )}
647+ </div>
648+ )}
649+ </section>
650+
651+ {error && (
652+ <div className="rounded-lg border border-destructive/20 bg-destructive/5 px-3 py-2 text-sm text-destructive">
653+ {error}
654+ </div>
655+ )}
656+
657+ <div className="flex items-center justify-between border-t border-border pt-4">
658+ <Button variant="outline" onClick={props.onCancel}>
659+ Cancel
660+ </Button>
661+ <Button onClick={handleAdd} disabled={addDisabled}>
662+ {adding ? "Adding…" : "Add Source"}
663+ </Button>
664+ </div>
665+ </div>
666+ );
667+}
packages/plugins/google-discovery/src/react/EditGoogleDiscoverySource.tsx+38−0
@@ -0,0 +1,38 @@
1+import { Badge } from "@executor/ui/components/badge";
2+import { Button } from "@executor/ui/components/button";
3+
4+export default function EditGoogleDiscoverySource({
5+ sourceId,
6+ onSave,
7+}: {
8+ readonly sourceId: string;
9+ readonly onSave: () => void;
10+}) {
11+ return (
12+ <div className="space-y-6">
13+ <div>
14+ <h1 className="text-xl font-semibold text-foreground">
15+ Edit Google Discovery Source
16+ </h1>
17+ <p className="mt-1 text-[13px] text-muted-foreground">
18+ This source is managed from its Discovery document and can be refreshed from the source header.
19+ </p>
20+ </div>
21+
22+ <div className="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-3">
23+ <div className="min-w-0 flex-1">
24+ <p className="truncate text-sm font-semibold text-card-foreground">
25+ {sourceId}
26+ </p>
27+ </div>
28+ <Badge variant="secondary" className="text-[10px]">
29+ Google Discovery
30+ </Badge>
31+ </div>
32+
33+ <div className="flex items-center justify-end border-t border-border pt-4">
34+ <Button onClick={onSave}>Done</Button>
35+ </div>
36+ </div>
37+ );
38+}
packages/plugins/google-discovery/src/react/GoogleDiscoverySourceSummary.tsx+16−0
@@ -0,0 +1,16 @@
1+import { Badge } from "@executor/ui/components/badge";
2+
3+export default function GoogleDiscoverySourceSummary({
4+ sourceId,
5+}: {
6+ readonly sourceId: string;
7+}) {
8+ return (
9+ <span className="inline-flex items-center gap-1.5">
10+ <Badge variant="secondary" className="text-[10px]">
11+ Google
12+ </Badge>
13+ <span className="text-xs text-muted-foreground">{sourceId}</span>
14+ </span>
15+ );
16+}
packages/plugins/google-discovery/src/react/atoms.ts+18−0
@@ -0,0 +1,18 @@
1+import { GoogleDiscoveryClient } from "./client";
2+
3+export const probeGoogleDiscovery = GoogleDiscoveryClient.mutation(
4+ "googleDiscovery",
5+ "probeDiscovery",
6+);
7+export const addGoogleDiscoverySource = GoogleDiscoveryClient.mutation(
8+ "googleDiscovery",
9+ "addSource",
10+);
11+export const startGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation(
12+ "googleDiscovery",
13+ "startOAuth",
14+);
15+export const completeGoogleDiscoveryOAuth = GoogleDiscoveryClient.mutation(
16+ "googleDiscovery",
17+ "completeOAuth",
18+);
packages/plugins/google-discovery/src/react/client.ts+16−0
@@ -0,0 +1,16 @@
1+import { AtomHttpApi } from "@effect-atom/atom-react";
2+import { FetchHttpClient } from "@effect/platform";
3+import { addGroup } from "@executor/api";
4+import { getBaseUrl } from "@executor/react";
5+import { GoogleDiscoveryGroup } from "../api/group";
6+
7+const GoogleDiscoveryApi = addGroup(GoogleDiscoveryGroup);
8+
9+export const GoogleDiscoveryClient = AtomHttpApi.Tag<"GoogleDiscoveryClient">()(
10+ "GoogleDiscoveryClient",
11+ {
12+ api: GoogleDiscoveryApi,
13+ httpClient: FetchHttpClient.layer,
14+ baseUrl: getBaseUrl(),
15+ },
16+);
packages/plugins/google-discovery/src/react/index.ts+8−0
@@ -0,0 +1,8 @@
1+export { googleDiscoverySourcePlugin } from "./source-plugin";
2+export { GoogleDiscoveryClient } from "./client";
3+export {
4+ addGoogleDiscoverySource,
5+ completeGoogleDiscoveryOAuth,
6+ probeGoogleDiscovery,
7+ startGoogleDiscoveryOAuth,
8+} from "./atoms";
packages/plugins/google-discovery/src/react/source-plugin.ts+10−0
@@ -0,0 +1,10 @@
1+import { lazy } from "react";
2+import type { SourcePlugin } from "@executor/react";
3+
4+export const googleDiscoverySourcePlugin: SourcePlugin = {
5+ key: "googleDiscovery",
6+ label: "Google Discovery",
7+ add: lazy(() => import("./AddGoogleDiscoverySource")),
8+ edit: lazy(() => import("./EditGoogleDiscoverySource")),
9+ summary: lazy(() => import("./GoogleDiscoverySourceSummary")),
10+};
packages/plugins/google-discovery/src/sdk/binding-store.ts+163−0
@@ -0,0 +1,163 @@
1+import { Effect, Schema } from "effect";
2+import {
3+ makeInMemoryScopedKv,
4+ scopeKv,
5+ type Kv,
6+ type ScopedKv,
7+ type ToolId,
8+} from "@executor/sdk";
9+
10+import {
11+ GoogleDiscoveryMethodBinding,
12+ GoogleDiscoveryStoredSourceData,
13+} from "./types";
14+
15+const StoredBindingEntry = Schema.Struct({
16+ namespace: Schema.String,
17+ binding: GoogleDiscoveryMethodBinding,
18+});
19+
20+const encodeBindingEntry = Schema.encodeSync(
21+ Schema.parseJson(StoredBindingEntry),
22+);
23+const decodeBindingEntry = Schema.decodeUnknownSync(
24+ Schema.parseJson(StoredBindingEntry),
25+);
26+const encodeSourceData = Schema.encodeSync(
27+ Schema.parseJson(GoogleDiscoveryStoredSourceData),
28+);
29+const decodeSourceData = Schema.decodeUnknownSync(
30+ Schema.parseJson(GoogleDiscoveryStoredSourceData),
31+);
32+
33+export interface GoogleDiscoverySourceMeta {
34+ readonly namespace: string;
35+ readonly name: string;
36+}
37+
38+export interface GoogleDiscoveryBindingStore {
39+ readonly get: (
40+ toolId: ToolId,
41+ ) => Effect.Effect<{
42+ namespace: string;
43+ binding: GoogleDiscoveryMethodBinding;
44+ } | null>;
45+
46+ readonly put: (
47+ toolId: ToolId,
48+ namespace: string,
49+ binding: GoogleDiscoveryMethodBinding,
50+ ) => Effect.Effect<void>;
51+
52+ readonly listByNamespace: (
53+ namespace: string,
54+ ) => Effect.Effect<readonly ToolId[]>;
55+
56+ readonly removeByNamespace: (
57+ namespace: string,
58+ ) => Effect.Effect<readonly ToolId[]>;
59+
60+ readonly putSourceMeta: (meta: GoogleDiscoverySourceMeta) => Effect.Effect<void>;
61+ readonly removeSourceMeta: (namespace: string) => Effect.Effect<void>;
62+ readonly listSourceMeta: () => Effect.Effect<readonly GoogleDiscoverySourceMeta[]>;
63+
64+ readonly putSourceData: (
65+ namespace: string,
66+ data: GoogleDiscoveryStoredSourceData,
67+ ) => Effect.Effect<void>;
68+ readonly getSourceData: (
69+ namespace: string,
70+ ) => Effect.Effect<GoogleDiscoveryStoredSourceData | null>;
71+ readonly removeSourceData: (namespace: string) => Effect.Effect<void>;
72+}
73+
74+const makeStore = (
75+ bindings: ScopedKv,
76+ meta: ScopedKv,
77+ config: ScopedKv,
78+): GoogleDiscoveryBindingStore => ({
79+ get: (toolId) =>
80+ Effect.gen(function* () {
81+ const raw = yield* bindings.get(toolId);
82+ if (!raw) return null;
83+ const entry = decodeBindingEntry(raw);
84+ return {
85+ namespace: entry.namespace,
86+ binding: entry.binding,
87+ };
88+ }),
89+
90+ put: (toolId, namespace, binding) =>
91+ bindings.set(
92+ toolId,
93+ encodeBindingEntry({ namespace, binding }),
94+ ),
95+
96+ listByNamespace: (namespace) =>
97+ Effect.gen(function* () {
98+ const entries = yield* bindings.list();
99+ const ids: ToolId[] = [];
100+ for (const entry of entries) {
101+ const decoded = decodeBindingEntry(entry.value);
102+ if (decoded.namespace === namespace) {
103+ ids.push(entry.key as ToolId);
104+ }
105+ }
106+ return ids;
107+ }),
108+
109+ removeByNamespace: (namespace) =>
110+ Effect.gen(function* () {
111+ const entries = yield* bindings.list();
112+ const ids: ToolId[] = [];
113+ for (const entry of entries) {
114+ const decoded = decodeBindingEntry(entry.value);
115+ if (decoded.namespace === namespace) {
116+ ids.push(entry.key as ToolId);
117+ yield* bindings.delete(entry.key);
118+ }
119+ }
120+ return ids;
121+ }),
122+
123+ putSourceMeta: (sourceMeta) =>
124+ meta.set(sourceMeta.namespace, JSON.stringify(sourceMeta)),
125+
126+ removeSourceMeta: (namespace) =>
127+ meta.delete(namespace).pipe(Effect.asVoid),
128+
129+ listSourceMeta: () =>
130+ Effect.gen(function* () {
131+ const entries = yield* meta.list();
132+ return entries.map((entry) => JSON.parse(entry.value) as GoogleDiscoverySourceMeta);
133+ }),
134+
135+ putSourceData: (namespace, data) =>
136+ config.set(namespace, encodeSourceData(data)),
137+
138+ getSourceData: (namespace) =>
139+ Effect.gen(function* () {
140+ const raw = yield* config.get(namespace);
141+ return raw ? decodeSourceData(raw) : null;
142+ }),
143+
144+ removeSourceData: (namespace) =>
145+ config.delete(namespace).pipe(Effect.asVoid),
146+});
147+
148+export const makeKvBindingStore = (
149+ kv: Kv,
150+ namespace: string,
151+): GoogleDiscoveryBindingStore =>
152+ makeStore(
153+ scopeKv(kv, `${namespace}.bindings`),
154+ scopeKv(kv, `${namespace}.sources`),
155+ scopeKv(kv, `${namespace}.config`),
156+ );
157+
158+export const makeInMemoryBindingStore = (): GoogleDiscoveryBindingStore =>
159+ makeStore(
160+ makeInMemoryScopedKv(),
161+ makeInMemoryScopedKv(),
162+ makeInMemoryScopedKv(),
163+ );
packages/plugins/google-discovery/src/sdk/document.test.ts+53−0
@@ -0,0 +1,53 @@
1+import { readFileSync } from "node:fs";
2+import { resolve } from "node:path";
3+
4+import { Effect, Option } from "effect";
5+import { describe, expect, it } from "vitest";
6+
7+import { extractGoogleDiscoveryManifest } from "./document";
8+
9+const fixturePath = resolve(__dirname, "../../fixtures/drive.json");
10+const fixtureText = readFileSync(fixturePath, "utf8");
11+
12+describe("Google Discovery document", () => {
13+ it("extracts methods, refs, and oauth scopes", async () => {
14+ const manifest = await Effect.runPromise(
15+ extractGoogleDiscoveryManifest(fixtureText),
16+ );
17+
18+ expect(Option.getOrElse(manifest.title, () => "")).toBe("Google Drive");
19+ expect(manifest.service).toBe("drive");
20+ expect(manifest.version).toBe("v3");
21+ expect(manifest.methods.map((method) => method.toolPath)).toEqual([
22+ "files.get",
23+ "files.update",
24+ ]);
25+ expect(Object.keys(manifest.schemaDefinitions)).toEqual([
26+ "File",
27+ "UpdateFileRequest",
28+ ]);
29+
30+ const getFile = manifest.methods.find((method) => method.toolPath === "files.get");
31+ expect(getFile).toBeDefined();
32+ expect(getFile!.binding.pathTemplate).toBe("files/{fileId}");
33+ expect(Option.isSome(getFile!.inputSchema)).toBe(true);
34+ expect(Option.isSome(getFile!.outputSchema)).toBe(true);
35+
36+ const inputSchema = Option.getOrThrow(getFile!.inputSchema) as Record<string, unknown>;
37+ const properties = inputSchema.properties as Record<string, unknown>;
38+ expect(properties.fileId).toMatchObject({ type: "string" });
39+ expect(properties.prettyPrint).toMatchObject({ type: "boolean" });
40+
41+ const updateFile = manifest.methods.find((method) => method.toolPath === "files.update");
42+ expect(updateFile).toBeDefined();
43+ const updateInputSchema = Option.getOrThrow(updateFile!.inputSchema) as Record<
44+ string,
45+ unknown
46+ >;
47+ expect(updateInputSchema.properties).toHaveProperty("body");
48+
49+ const scopes = Option.getOrElse(manifest.oauthScopes, () => ({}));
50+ expect(Object.keys(scopes)).toContain("https://www.googleapis.com/auth/drive");
51+ expect(Object.keys(scopes)).toContain("https://www.googleapis.com/auth/drive.readonly");
52+ });
53+});
packages/plugins/google-discovery/src/sdk/document.ts+559−0
@@ -0,0 +1,559 @@
1+import { Effect, Option, Schema } from "effect";
2+
3+import { GoogleDiscoveryParseError } from "./errors";
4+import {
5+ GoogleDiscoveryHttpMethod,
6+ GoogleDiscoveryManifest,
7+ GoogleDiscoveryManifestMethod,
8+ GoogleDiscoveryMethodBinding,
9+ GoogleDiscoveryParameter,
10+ GoogleDiscoveryParameterLocation,
11+} from "./types";
12+
13+type JsonObject = Record<string, unknown>;
14+
15+const TrimmedString = Schema.String.pipe(Schema.compose(Schema.Trim));
16+const Text = TrimmedString.pipe(Schema.compose(Schema.NonEmptyTrimmedString));
17+const TextOption = Schema.optionalWith(Schema.OptionFromNonEmptyTrimmedString, {
18+ default: () => Option.none(),
19+});
20+const TextArray = Schema.optionalWith(Schema.Array(Text), {
21+ default: () => [] as string[],
22+});
23+const UnknownRecord = Schema.Record({ key: Schema.String, value: Schema.Unknown });
24+const UnknownRecordWithDefault = Schema.optionalWith(UnknownRecord, {
25+ default: () => ({}),
26+});
27+
28+const DiscoveryHttpMethodInput = Schema.optional(
29+ Schema.String.pipe(
30+ Schema.compose(Schema.Trim),
31+ Schema.compose(Schema.Lowercase),
32+ Schema.compose(GoogleDiscoveryHttpMethod),
33+ ),
34+);
35+
36+const DiscoveryParameterLocationInput = Schema.optional(
37+ Schema.String.pipe(
38+ Schema.compose(Schema.Trim),
39+ Schema.compose(Schema.Lowercase),
40+ Schema.compose(GoogleDiscoveryParameterLocation),
41+ ),
42+);
43+
44+const DiscoveryDefaultValue = Schema.Union(
45+ Schema.String,
46+ Schema.Number,
47+ Schema.Boolean,
48+);
49+
50+const DiscoverySchemaModel = Schema.Struct({
51+ type: Schema.optional(
52+ Schema.String.pipe(
53+ Schema.compose(Schema.Trim),
54+ Schema.compose(Schema.Lowercase),
55+ ),
56+ ),
57+ description: TextOption,
58+ properties: UnknownRecordWithDefault,
59+ items: Schema.optional(Schema.Unknown),
60+ additionalProperties: Schema.optional(Schema.Union(Schema.Boolean, Schema.Unknown)),
61+ enum: TextArray,
62+ format: Schema.optional(Text),
63+ readOnly: Schema.optional(Schema.Boolean),
64+ default: Schema.optional(DiscoveryDefaultValue),
65+ $ref: Schema.optional(Text),
66+ required: TextArray,
67+});
68+type DiscoverySchema = typeof DiscoverySchemaModel.Type;
69+
70+const DiscoveryParameterModel = Schema.Struct({
71+ type: Schema.optional(
72+ Schema.String.pipe(
73+ Schema.compose(Schema.Trim),
74+ Schema.compose(Schema.Lowercase),
75+ ),
76+ ),
77+ description: TextOption,
78+ properties: UnknownRecordWithDefault,
79+ items: Schema.optional(Schema.Unknown),
80+ additionalProperties: Schema.optional(Schema.Union(Schema.Boolean, Schema.Unknown)),
81+ enum: TextArray,
82+ format: Schema.optional(Text),
83+ readOnly: Schema.optional(Schema.Boolean),
84+ default: Schema.optional(DiscoveryDefaultValue),
85+ $ref: Schema.optional(Text),
86+ location: DiscoveryParameterLocationInput,
87+ required: Schema.optional(Schema.Boolean),
88+ repeated: Schema.optional(Schema.Boolean),
89+});
90+type DiscoveryParameter = typeof DiscoveryParameterModel.Type;
91+
92+const DiscoveryRefModel = Schema.Struct({
93+ $ref: Schema.optional(Text),
94+});
95+
96+const DiscoveryMethodModel = Schema.Struct({
97+ id: TextOption,
98+ description: TextOption,
99+ httpMethod: DiscoveryHttpMethodInput,
100+ path: TextOption,
101+ parameters: UnknownRecordWithDefault,
102+ request: Schema.optional(DiscoveryRefModel),
103+ response: Schema.optional(DiscoveryRefModel),
104+ scopes: TextArray,
105+});
106+type DiscoveryMethod = typeof DiscoveryMethodModel.Type;
107+
108+const DiscoveryResourceModel = Schema.Struct({
109+ methods: UnknownRecordWithDefault,
110+ resources: UnknownRecordWithDefault,
111+});
112+
113+const DiscoveryDocumentModel = Schema.Struct({
114+ name: TextOption,
115+ version: TextOption,
116+ title: TextOption,
117+ rootUrl: TextOption,
118+ servicePath: Schema.optionalWith(TrimmedString, { default: () => "" }),
119+ parameters: UnknownRecordWithDefault,
120+ methods: UnknownRecordWithDefault,
121+ resources: UnknownRecordWithDefault,
122+ schemas: UnknownRecordWithDefault,
123+ auth: Schema.optional(
124+ Schema.Struct({
125+ oauth2: Schema.optional(
126+ Schema.Struct({
127+ scopes: Schema.optionalWith(
128+ Schema.Record({
129+ key: Schema.String,
130+ value: Schema.Struct({
131+ description: TextOption,
132+ }),
133+ }),
134+ { default: () => ({}) },
135+ ),
136+ }),
137+ ),
138+ }),
139+ ),
140+});
141+type DiscoveryDocument = typeof DiscoveryDocumentModel.Type;
142+
143+const toParseError = (message: string, error: unknown) =>
144+ new GoogleDiscoveryParseError({ message, error });
145+
146+const decodeUnknownWith = <A>(
147+ message: string,
148+ decode: (value: unknown) => A,
149+): ((value: unknown) => Effect.Effect<A, GoogleDiscoveryParseError>) =>
150+ (value) =>
151+ Effect.try({
152+ try: () => decode(value),
153+ catch: (error) => toParseError(message, error),
154+ });
155+
156+const decodeDiscoveryDocument = decodeUnknownWith(
157+ "Failed to decode Google Discovery document",
158+ Schema.decodeUnknownSync(DiscoveryDocumentModel),
159+);
160+
161+const decodeDiscoveryDocumentJson = decodeUnknownWith(
162+ "Failed to parse Google Discovery document",
163+ Schema.decodeUnknownSync(Schema.parseJson(DiscoveryDocumentModel)),
164+);
165+
166+const decodeDiscoverySchema = decodeUnknownWith(
167+ "Failed to decode Google Discovery schema",
168+ Schema.decodeUnknownSync(DiscoverySchemaModel),
169+);
170+
171+const decodeDiscoveryParameter = decodeUnknownWith(
172+ "Failed to decode Google Discovery parameter",
173+ Schema.decodeUnknownSync(DiscoveryParameterModel),
174+);
175+
176+const decodeDiscoveryMethod = decodeUnknownWith(
177+ "Failed to decode Google Discovery method",
178+ Schema.decodeUnknownSync(DiscoveryMethodModel),
179+);
180+
181+const decodeDiscoveryResource = decodeUnknownWith(
182+ "Failed to decode Google Discovery resource",
183+ Schema.decodeUnknownSync(DiscoveryResourceModel),
184+);
185+
186+const schemaRef = (name: string) => `#/$defs/${name}`;
187+
188+const toJsonSchemaSeed = (input: {
189+ type: DiscoverySchema["type"];
190+ description: DiscoverySchema["description"];
191+ properties: DiscoverySchema["properties"];
192+ items: DiscoverySchema["items"];
193+ additionalProperties: DiscoverySchema["additionalProperties"];
194+ enum: DiscoverySchema["enum"];
195+ format: DiscoverySchema["format"];
196+ readOnly: DiscoverySchema["readOnly"];
197+ default: DiscoverySchema["default"];
198+ $ref: DiscoverySchema["$ref"];
199+ required: DiscoverySchema["required"];
200+}): DiscoverySchema => ({
201+ type: input.type,
202+ description: input.description,
203+ properties: input.properties,
204+ items: input.items,
205+ additionalProperties: input.additionalProperties,
206+ enum: input.enum,
207+ format: input.format,
208+ readOnly: input.readOnly,
209+ default: input.default,
210+ $ref: input.$ref,
211+ required: input.required,
212+});
213+
214+const discoverySchemaToJsonSchema = (
215+ schema: DiscoverySchema,
216+): Effect.Effect<unknown, GoogleDiscoveryParseError> =>
217+ Effect.gen(function* () {
218+ const ref = schema.$ref;
219+ if (ref) {
220+ return { $ref: schemaRef(ref) };
221+ }
222+
223+ const description = Option.getOrUndefined(schema.description);
224+ const base: Record<string, unknown> = {
225+ ...(description ? { description } : {}),
226+ ...(schema.format ? { format: schema.format } : {}),
227+ ...(schema.enum.length > 0 ? { enum: schema.enum } : {}),
228+ ...(schema.readOnly === true ? { readOnly: true } : {}),
229+ ...(schema.default !== undefined ? { default: schema.default } : {}),
230+ };
231+
232+ if (schema.type === "array") {
233+ return {
234+ ...base,
235+ type: "array",
236+ items: yield* googleSchemaToJsonSchema(schema.items),
237+ };
238+ }
239+
240+ const properties = schema.properties;
241+ const additionalProperties = schema.additionalProperties;
242+ if (
243+ schema.type === "object" ||
244+ Object.keys(properties).length > 0 ||
245+ additionalProperties !== undefined
246+ ) {
247+ const convertedProperties = Object.fromEntries(
248+ yield* Effect.forEach(
249+ Object.entries(properties),
250+ ([name, property]) =>
251+ googleSchemaToJsonSchema(property).pipe(
252+ Effect.map((jsonSchema) => [name, jsonSchema] as const),
253+ ),
254+ ),
255+ );
256+
257+ const convertedAdditionalProperties =
258+ additionalProperties === undefined
259+ ? undefined
260+ : additionalProperties === true
261+ ? true
262+ : yield* googleSchemaToJsonSchema(additionalProperties);
263+
264+ return {
265+ ...base,
266+ type: "object",
267+ ...(Object.keys(convertedProperties).length > 0
268+ ? { properties: convertedProperties }
269+ : {}),
270+ ...(schema.required.length > 0 ? { required: schema.required } : {}),
271+ ...(convertedAdditionalProperties !== undefined
272+ ? { additionalProperties: convertedAdditionalProperties }
273+ : {}),
274+ };
275+ }
276+
277+ if (
278+ schema.type === "boolean" ||
279+ schema.type === "integer" ||
280+ schema.type === "number" ||
281+ schema.type === "string"
282+ ) {
283+ return { ...base, type: schema.type };
284+ }
285+
286+ if (schema.type === "any") return base;
287+
288+ return Object.keys(base).length > 0 ? base : {};
289+ });
290+
291+const googleSchemaToJsonSchema = (
292+ rawSchema: unknown,
293+): Effect.Effect<unknown, GoogleDiscoveryParseError> =>
294+ rawSchema === undefined
295+ ? Effect.succeed({})
296+ : decodeDiscoverySchema(rawSchema).pipe(
297+ Effect.flatMap(discoverySchemaToJsonSchema),
298+ );
299+
300+const parameterToJsonSchema = (
301+ parameter: DiscoveryParameter,
302+): Effect.Effect<unknown, GoogleDiscoveryParseError> =>
303+ parameter.repeated === true
304+ ? discoverySchemaToJsonSchema(
305+ toJsonSchemaSeed({
306+ type: parameter.type,
307+ description: parameter.description,
308+ properties: parameter.properties,
309+ items: parameter.items,
310+ additionalProperties: parameter.additionalProperties,
311+ enum: parameter.enum,
312+ format: parameter.format,
313+ readOnly: parameter.readOnly,
314+ default: parameter.default,
315+ $ref: parameter.$ref,
316+ required: [],
317+ }),
318+ ).pipe(
319+ Effect.map((items) => ({
320+ type: "array",
321+ items,
322+ })),
323+ )
324+ : discoverySchemaToJsonSchema(
325+ toJsonSchemaSeed({
326+ type: parameter.type,
327+ description: parameter.description,
328+ properties: parameter.properties,
329+ items: parameter.items,
330+ additionalProperties: parameter.additionalProperties,
331+ enum: parameter.enum,
332+ format: parameter.format,
333+ readOnly: parameter.readOnly,
334+ default: parameter.default,
335+ $ref: parameter.$ref,
336+ required: [],
337+ }),
338+ );
339+
340+const toToolPath = (service: string, methodId: string): string => {
341+ const withoutPrefix = methodId.startsWith(`${service}.`)
342+ ? methodId.slice(service.length + 1)
343+ : methodId;
344+ return withoutPrefix.trim();
345+};
346+
347+const toParameter = (
348+ name: string,
349+ rawParameter: unknown,
350+): Effect.Effect<GoogleDiscoveryParameter | null, GoogleDiscoveryParseError> =>
351+ Effect.gen(function* () {
352+ const parameter = yield* decodeDiscoveryParameter(rawParameter);
353+ if (parameter.location === undefined) return null;
354+
355+ return new GoogleDiscoveryParameter({
356+ name,
357+ location: parameter.location,
358+ required: parameter.required === true,
359+ repeated: parameter.repeated === true,
360+ description: parameter.description,
361+ schema: Option.some(yield* parameterToJsonSchema(parameter)),
362+ });
363+ });
364+
365+const mergeParameters = (input: {
366+ globalParameters: Readonly<Record<string, unknown>>;
367+ method: DiscoveryMethod;
368+}): Effect.Effect<GoogleDiscoveryParameter[], GoogleDiscoveryParseError> =>
369+ Effect.gen(function* () {
370+ const merged = new Map<string, GoogleDiscoveryParameter>();
371+
372+ for (const [name, parameter] of Object.entries(input.globalParameters)) {
373+ const converted = yield* toParameter(name, parameter);
374+ if (converted) merged.set(name, converted);
375+ }
376+
377+ for (const [name, parameter] of Object.entries(input.method.parameters)) {
378+ const converted = yield* toParameter(name, parameter);
379+ if (converted) merged.set(name, converted);
380+ }
381+
382+ return [...merged.values()].sort((a, b) => a.name.localeCompare(b.name));
383+ });
384+
385+const buildInputSchema = (input: {
386+ parameters: readonly GoogleDiscoveryParameter[];
387+ requestRef: string | undefined;
388+}): unknown | undefined => {
389+ const properties: Record<string, unknown> = {};
390+ const required: string[] = [];
391+
392+ for (const parameter of input.parameters) {
393+ properties[parameter.name] = Option.getOrElse(parameter.schema, () => ({
394+ type: "string",
395+ }));
396+ if (parameter.required) required.push(parameter.name);
397+ }
398+
399+ if (input.requestRef) {
400+ properties.body = { $ref: schemaRef(input.requestRef) };
401+ }
402+
403+ if (Object.keys(properties).length === 0) return undefined;
404+
405+ return {
406+ type: "object",
407+ properties,
408+ ...(required.length > 0 ? { required } : {}),
409+ additionalProperties: false,
410+ };
411+};
412+
413+const extractScopes = (
414+ document: DiscoveryDocument,
415+): Record<string, string> | undefined => {
416+ const scopes = document.auth?.oauth2?.scopes ?? {};
417+ const normalized = Object.fromEntries(
418+ Object.entries(scopes).map(([scope, value]) => [
419+ scope,
420+ Option.getOrElse(value.description, () => ""),
421+ ]),
422+ );
423+ return Object.keys(normalized).length > 0 ? normalized : undefined;
424+};
425+
426+const manifestMethodFromMethod = (input: {
427+ service: string;
428+ rawMethod: unknown;
429+ globalParameters: Readonly<Record<string, unknown>>;
430+}): Effect.Effect<GoogleDiscoveryManifestMethod | null, GoogleDiscoveryParseError> =>
431+ Effect.gen(function* () {
432+ const method = yield* decodeDiscoveryMethod(input.rawMethod);
433+ const methodId = Option.getOrUndefined(method.id);
434+ const path = Option.getOrUndefined(method.path);
435+ if (!methodId || !path) return null;
436+ if (!method.httpMethod) {
437+ return yield* new GoogleDiscoveryParseError({
438+ message: `Google Discovery method '${methodId}' is missing httpMethod`,
439+ error: new Error(`Missing httpMethod for ${methodId}`),
440+ });
441+ }
442+
443+ const requestRef = method.request?.$ref;
444+ const responseRef = method.response?.$ref;
445+ const parameters = yield* mergeParameters({
446+ globalParameters: input.globalParameters,
447+ method,
448+ });
449+
450+ return new GoogleDiscoveryManifestMethod({
451+ toolPath: toToolPath(input.service, methodId),
452+ description: method.description,
453+ binding: new GoogleDiscoveryMethodBinding({
454+ method: method.httpMethod,
455+ pathTemplate: path,
456+ parameters,
457+ hasBody: requestRef !== undefined,
458+ }),
459+ inputSchema: Option.fromNullable(
460+ buildInputSchema({ parameters, requestRef }),
461+ ),
462+ outputSchema: Option.fromNullable(
463+ responseRef ? { $ref: schemaRef(responseRef) } : undefined,
464+ ),
465+ scopes: method.scopes,
466+ });
467+ });
468+
469+const collectMethods = (input: {
470+ service: string;
471+ rawResource: unknown;
472+ globalParameters: Readonly<Record<string, unknown>>;
473+}): Effect.Effect<GoogleDiscoveryManifestMethod[], GoogleDiscoveryParseError> =>
474+ Effect.gen(function* () {
475+ const resource = yield* decodeDiscoveryResource(input.rawResource);
476+ const methods = yield* Effect.forEach(
477+ Object.values(resource.methods),
478+ (rawMethod) =>
479+ manifestMethodFromMethod({
480+ service: input.service,
481+ rawMethod,
482+ globalParameters: input.globalParameters,
483+ }),
484+ );
485+ const nested = yield* Effect.forEach(
486+ Object.values(resource.resources),
487+ (rawResource) =>
488+ collectMethods({
489+ ...input,
490+ rawResource,
491+ }),
492+ );
493+
494+ return [...methods.flatMap((method) => (method ? [method] : [])), ...nested.flat()];
495+ });
496+
497+export const extractGoogleDiscoveryManifest = Effect.fn(
498+ "GoogleDiscovery.extractManifest",
499+)(function* (discoveryDocument: string | JsonObject) {
500+ const document =
501+ typeof discoveryDocument === "string"
502+ ? yield* decodeDiscoveryDocumentJson(discoveryDocument)
503+ : yield* decodeDiscoveryDocument(discoveryDocument);
504+
505+ const service = Option.getOrUndefined(document.name);
506+ const version = Option.getOrUndefined(document.version);
507+ const rootUrl = Option.getOrUndefined(document.rootUrl);
508+ if (!service || !version || !rootUrl) {
509+ return yield* new GoogleDiscoveryParseError({
510+ message:
511+ "Google Discovery document is missing one of: name, version, rootUrl",
512+ error: new Error("Invalid document"),
513+ });
514+ }
515+
516+ const schemaDefinitions = Object.fromEntries(
517+ yield* Effect.forEach(
518+ Object.entries(document.schemas),
519+ ([name, rawSchema]) =>
520+ googleSchemaToJsonSchema(rawSchema).pipe(
521+ Effect.map((schema) => [name, schema] as const),
522+ ),
523+ ),
524+ );
525+
526+ const topLevelMethods = yield* Effect.forEach(
527+ Object.values(document.methods),
528+ (rawMethod) =>
529+ manifestMethodFromMethod({
530+ service,
531+ rawMethod,
532+ globalParameters: document.parameters,
533+ }),
534+ );
535+
536+ const nestedMethods = yield* Effect.forEach(
537+ Object.values(document.resources),
538+ (rawResource) =>
539+ collectMethods({
540+ service,
541+ rawResource,
542+ globalParameters: document.parameters,
543+ }),
544+ );
545+
546+ return new GoogleDiscoveryManifest({
547+ title: document.title,
548+ service,
549+ version,
550+ rootUrl,
551+ servicePath: document.servicePath,
552+ oauthScopes: Option.fromNullable(extractScopes(document)),
553+ schemaDefinitions,
554+ methods: [
555+ ...topLevelMethods.flatMap((method) => (method ? [method] : [])),
556+ ...nestedMethods.flat(),
557+ ].sort((a, b) => a.toolPath.localeCompare(b.toolPath)),
558+ });
559+});
packages/plugins/google-discovery/src/sdk/errors.ts+32−0
@@ -0,0 +1,32 @@
1+import { Schema } from "effect";
2+
3+export class GoogleDiscoveryParseError extends Schema.TaggedError<GoogleDiscoveryParseError>()(
4+ "GoogleDiscoveryParseError",
5+ {
6+ message: Schema.String,
7+ error: Schema.Defect,
8+ },
9+) {}
10+
11+export class GoogleDiscoveryInvocationError extends Schema.TaggedError<GoogleDiscoveryInvocationError>()(
12+ "GoogleDiscoveryInvocationError",
13+ {
14+ message: Schema.String,
15+ statusCode: Schema.optionalWith(Schema.Number, { as: "Option" }),
16+ error: Schema.Defect,
17+ },
18+) {}
19+
20+export class GoogleDiscoveryOAuthError extends Schema.TaggedError<GoogleDiscoveryOAuthError>()(
21+ "GoogleDiscoveryOAuthError",
22+ {
23+ message: Schema.String,
24+ },
25+) {}
26+
27+export class GoogleDiscoverySourceError extends Schema.TaggedError<GoogleDiscoverySourceError>()(
28+ "GoogleDiscoverySourceError",
29+ {
30+ message: Schema.String,
31+ },
32+) {}
packages/plugins/google-discovery/src/sdk/index.ts+40−0
@@ -0,0 +1,40 @@
1+export { googleDiscoveryPlugin } from "./plugin";
2+export type {
3+ GoogleDiscoveryAddSourceInput,
4+ GoogleDiscoveryOAuthAuthResult,
5+ GoogleDiscoveryOAuthCompleteInput,
6+ GoogleDiscoveryOAuthStartInput,
7+ GoogleDiscoveryOAuthStartResponse,
8+ GoogleDiscoveryPluginExtension,
9+ GoogleDiscoveryProbeResult,
10+} from "./plugin";
11+export { extractGoogleDiscoveryManifest } from "./document";
12+export { makeKvBindingStore, makeInMemoryBindingStore } from "./binding-store";
13+export type { GoogleDiscoveryBindingStore } from "./binding-store";
14+export { makeGoogleDiscoveryInvoker } from "./invoke";
15+export {
16+ buildGoogleAuthorizationUrl,
17+ createPkceCodeVerifier,
18+ exchangeAuthorizationCode,
19+ refreshAccessToken,
20+} from "./oauth";
21+export {
22+ GoogleDiscoveryAuth,
23+ GoogleDiscoveryHttpMethod,
24+ GoogleDiscoveryInvocationResult,
25+ GoogleDiscoveryManifest,
26+ GoogleDiscoveryManifestMethod,
27+ GoogleDiscoveryMethodBinding,
28+ GoogleDiscoveryParameter,
29+ GoogleDiscoveryParameterLocation,
30+ GoogleDiscoveryStoredSourceData,
31+} from "./types";
32+export type {
33+ GoogleDiscoveryOAuthSession,
34+} from "./types";
35+export {
36+ GoogleDiscoveryInvocationError,
37+ GoogleDiscoveryOAuthError,
38+ GoogleDiscoveryParseError,
39+ GoogleDiscoverySourceError,
40+} from "./errors";
packages/plugins/google-discovery/src/sdk/invoke.ts+446−0
@@ -0,0 +1,446 @@
1+import { Effect, Layer, Option } from "effect";
2+import { FetchHttpClient, HttpClient, HttpClientRequest } from "@effect/platform";
3+
4+import {
5+ type ScopeId,
6+ type SecretId,
7+ type ToolId,
8+ ToolInvocationError,
9+ ToolInvocationResult,
10+ type ToolInvoker,
11+} from "@executor/sdk";
12+
13+import { GoogleDiscoveryInvocationError } from "./errors";
14+import type { GoogleDiscoveryBindingStore } from "./binding-store";
15+import {
16+ GoogleDiscoveryInvocationResult,
17+ GoogleDiscoveryStoredSourceData,
18+ type GoogleDiscoveryParameter,
19+} from "./types";
20+import { refreshAccessToken } from "./oauth";
21+
22+const OAUTH_REFRESH_SKEW_MS = 60_000;
23+
24+const SAFE_METHODS = new Set(["get", "head", "options"]);
25+
26+const stringValuesFromParameter = (
27+ value: unknown,
28+ repeated: boolean,
29+): string[] => {
30+ if (value === undefined || value === null) {
31+ return [];
32+ }
33+ if (Array.isArray(value)) {
34+ const normalized = value.flatMap((entry) =>
35+ entry === undefined || entry === null ? [] : [String(entry)],
36+ );
37+ return repeated ? normalized : [normalized.join(",")];
38+ }
39+ if (
40+ typeof value === "string" ||
41+ typeof value === "number" ||
42+ typeof value === "boolean"
43+ ) {
44+ return [String(value)];
45+ }
46+ return [JSON.stringify(value)];
47+};
48+
49+const replacePathParameters = (input: {
50+ pathTemplate: string;
51+ args: Record<string, unknown>;
52+ parameters: readonly GoogleDiscoveryParameter[];
53+}): string =>
54+ input.pathTemplate.replaceAll(/\{([^}]+)\}/g, (_, name: string) => {
55+ const parameter = input.parameters.find(
56+ (entry) => entry.location === "path" && entry.name === name,
57+ );
58+ const values = stringValuesFromParameter(input.args[name], false);
59+ if (values.length === 0) {
60+ if (parameter?.required) {
61+ throw new Error(`Missing required path parameter: ${name}`);
62+ }
63+ return "";
64+ }
65+ return encodeURIComponent(values[0]!);
66+ });
67+
68+const resolveBaseUrl = (source: GoogleDiscoveryStoredSourceData): string =>
69+ new URL(source.servicePath || "", source.rootUrl).toString();
70+
71+const isJsonContentType = (contentType: string | null | undefined): boolean => {
72+ if (!contentType) return false;
73+ const normalized = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
74+ return (
75+ normalized === "application/json" ||
76+ normalized.includes("+json") ||
77+ normalized.includes("json")
78+ );
79+};
80+
81+const resolveOAuthAccessToken = (input: {
82+ sourceId: string;
83+ source: GoogleDiscoveryStoredSourceData;
84+ secrets: {
85+ readonly resolve: (
86+ secretId: SecretId,
87+ scopeId: ScopeId,
88+ ) => Effect.Effect<string, unknown>;
89+ readonly set: (input: {
90+ id: SecretId;
91+ scopeId: ScopeId;
92+ name: string;
93+ value: string;
94+ purpose?: string;
95+ }) => Effect.Effect<unknown, unknown>;
96+ };
97+ scopeId: ScopeId;
98+ bindingStore: GoogleDiscoveryBindingStore;
99+}): Effect.Effect<string, ToolInvocationError> =>
100+ Effect.gen(function* () {
101+ if (input.source.auth.kind !== "oauth2") {
102+ return "";
103+ }
104+
105+ const auth = input.source.auth;
106+ const now = Date.now();
107+ const needsRefresh =
108+ auth.refreshTokenSecretId !== null &&
109+ auth.expiresAt !== null &&
110+ auth.expiresAt <= now + OAUTH_REFRESH_SKEW_MS;
111+
112+ if (!needsRefresh) {
113+ return yield* input.secrets
114+ .resolve(auth.accessTokenSecretId as SecretId, input.scopeId)
115+ .pipe(
116+ Effect.mapError(
117+ () =>
118+ new ToolInvocationError({
119+ toolId: "" as ToolId,
120+ message: "Failed to resolve Google OAuth access token",
121+ cause: undefined,
122+ }),
123+ ),
124+ );
125+ }
126+
127+ const refreshToken = yield* input.secrets
128+ .resolve(auth.refreshTokenSecretId as SecretId, input.scopeId)
129+ .pipe(
130+ Effect.mapError(
131+ () =>
132+ new ToolInvocationError({
133+ toolId: "" as ToolId,
134+ message: "Failed to resolve Google OAuth refresh token",
135+ cause: undefined,
136+ }),
137+ ),
138+ );
139+
140+ const clientSecret =
141+ auth.clientSecretSecretId === null
142+ ? null
143+ : yield* input.secrets
144+ .resolve(auth.clientSecretSecretId as SecretId, input.scopeId)
145+ .pipe(
146+ Effect.mapError(
147+ () =>
148+ new ToolInvocationError({
149+ toolId: "" as ToolId,
150+ message: "Failed to resolve Google OAuth client secret",
151+ cause: undefined,
152+ }),
153+ ),
154+ );
155+
156+ const refreshed = yield* refreshAccessToken({
157+ clientId: auth.clientId,
158+ clientSecret,
159+ refreshToken,
160+ scopes: auth.scopes,
161+ }).pipe(
162+ Effect.mapError(
163+ (error) =>
164+ new ToolInvocationError({
165+ toolId: "" as ToolId,
166+ message: error.message,
167+ cause: undefined,
168+ }),
169+ ),
170+ );
171+
172+ yield* input.secrets.set({
173+ id: auth.accessTokenSecretId as SecretId,
174+ scopeId: input.scopeId,
175+ name: `${input.source.name} Access Token`,
176+ value: refreshed.access_token,
177+ purpose: "google_oauth_access_token",
178+ }).pipe(
179+ Effect.mapError(
180+ () =>
181+ new ToolInvocationError({
182+ toolId: "" as ToolId,
183+ message: "Failed to persist refreshed Google OAuth access token",
184+ cause: undefined,
185+ }),
186+ ),
187+ );
188+
189+ let refreshTokenSecretId = auth.refreshTokenSecretId;
190+ if (refreshed.refresh_token && auth.refreshTokenSecretId) {
191+ yield* input.secrets.set({
192+ id: auth.refreshTokenSecretId as SecretId,
193+ scopeId: input.scopeId,
194+ name: `${input.source.name} Refresh Token`,
195+ value: refreshed.refresh_token,
196+ purpose: "google_oauth_refresh_token",
197+ }).pipe(
198+ Effect.mapError(
199+ () =>
200+ new ToolInvocationError({
201+ toolId: "" as ToolId,
202+ message: "Failed to persist refreshed Google OAuth refresh token",
203+ cause: undefined,
204+ }),
205+ ),
206+ );
207+ refreshTokenSecretId = auth.refreshTokenSecretId;
208+ }
209+
210+ const updatedSource = new GoogleDiscoveryStoredSourceData({
211+ ...input.source,
212+ auth: {
213+ kind: "oauth2",
214+ clientId: auth.clientId,
215+ clientSecretSecretId: auth.clientSecretSecretId,
216+ accessTokenSecretId: auth.accessTokenSecretId,
217+ refreshTokenSecretId,
218+ tokenType: refreshed.token_type ?? auth.tokenType,
219+ expiresAt:
220+ typeof refreshed.expires_in === "number"
221+ ? Date.now() + refreshed.expires_in * 1000
222+ : auth.expiresAt,
223+ scope: refreshed.scope ?? auth.scope,
224+ scopes: auth.scopes,
225+ },
226+ });
227+ yield* input.bindingStore.putSourceData(input.sourceId, updatedSource);
228+
229+ return refreshed.access_token;
230+ });
231+
232+export const annotationsForOperation = (method: string, pathTemplate: string): {
233+ requiresApproval?: boolean;
234+ approvalDescription?: string;
235+} => {
236+ if (SAFE_METHODS.has(method.toLowerCase())) return {};
237+ return {
238+ requiresApproval: true,
239+ approvalDescription: `${method.toUpperCase()} ${pathTemplate}`,
240+ };
241+};
242+
243+const invoke = Effect.fn("GoogleDiscovery.invoke")(function* (input: {
244+ method: string;
245+ pathTemplate: string;
246+ parameters: readonly GoogleDiscoveryParameter[];
247+ hasBody: boolean;
248+ source: GoogleDiscoveryStoredSourceData;
249+ args: Record<string, unknown>;
250+ authorizationHeader?: string;
251+}) {
252+ const client = yield* HttpClient.HttpClient;
253+
254+ const resolvedPath = replacePathParameters({
255+ pathTemplate: input.pathTemplate,
256+ args: input.args,
257+ parameters: input.parameters,
258+ });
259+ const requestUrl = new URL(
260+ resolvedPath.replace(/^\//, ""),
261+ resolveBaseUrl(input.source),
262+ );
263+
264+ for (const parameter of input.parameters) {
265+ if (parameter.location === "path") continue;
266+
267+ const values = stringValuesFromParameter(
268+ input.args[parameter.name],
269+ parameter.repeated,
270+ );
271+ if (values.length === 0) {
272+ if (parameter.required) {
273+ return yield* new GoogleDiscoveryInvocationError({
274+ message: `Missing required ${parameter.location} parameter: ${parameter.name}`,
275+ statusCode: Option.none(),
276+ error: undefined,
277+ });
278+ }
279+ continue;
280+ }
281+
282+ if (parameter.location === "query") {
283+ for (const value of values) {
284+ requestUrl.searchParams.append(parameter.name, value);
285+ }
286+ continue;
287+ }
288+ }
289+
290+ let request = HttpClientRequest.make(
291+ input.method.toUpperCase() as "GET",
292+ )(requestUrl.toString());
293+
294+ for (const parameter of input.parameters) {
295+ if (parameter.location !== "header") continue;
296+ const values = stringValuesFromParameter(
297+ input.args[parameter.name],
298+ parameter.repeated,
299+ );
300+ if (values.length === 0) continue;
301+ request = HttpClientRequest.setHeader(
302+ request,
303+ parameter.name,
304+ parameter.repeated ? values.join(",") : values[0]!,
305+ );
306+ }
307+
308+ if (input.authorizationHeader) {
309+ request = HttpClientRequest.setHeader(
310+ request,
311+ "Authorization",
312+ input.authorizationHeader,
313+ );
314+ }
315+
316+ if (input.hasBody && input.args.body !== undefined) {
317+ request = HttpClientRequest.bodyUnsafeJson(request, input.args.body);
318+ }
319+
320+ const response = yield* client.execute(request).pipe(
321+ Effect.mapError(
322+ (err) =>
323+ new GoogleDiscoveryInvocationError({
324+ message: `HTTP request failed: ${err.message}`,
325+ statusCode: Option.none(),
326+ error: err,
327+ }),
328+ ),
329+ );
330+
331+ const contentType = response.headers["content-type"] ?? null;
332+ const body =
333+ response.status === 204
334+ ? null
335+ : isJsonContentType(contentType)
336+ ? yield* response.json.pipe(Effect.catchAll(() => response.text))
337+ : yield* response.text;
338+
339+ const ok = response.status >= 200 && response.status < 300;
340+
341+ return new GoogleDiscoveryInvocationResult({
342+ status: response.status,
343+ headers: { ...response.headers },
344+ data: ok ? body : null,
345+ error: ok ? null : body,
346+ });
347+});
348+
349+export const makeGoogleDiscoveryInvoker = (input: {
350+ readonly bindingStore: GoogleDiscoveryBindingStore;
351+ readonly secrets: {
352+ readonly resolve: (
353+ secretId: SecretId,
354+ scopeId: ScopeId,
355+ ) => Effect.Effect<string, unknown>;
356+ readonly set: (input: {
357+ id: SecretId;
358+ scopeId: ScopeId;
359+ name: string;
360+ value: string;
361+ purpose?: string;
362+ }) => Effect.Effect<unknown, unknown>;
363+ };
364+ readonly scopeId: ScopeId;
365+ readonly httpClientLayer?: Layer.Layer<HttpClient.HttpClient>;
366+}): ToolInvoker => {
367+ const httpClientLayer = input.httpClientLayer ?? FetchHttpClient.layer;
368+
369+ return {
370+ resolveAnnotations: (toolId: ToolId) =>
371+ Effect.gen(function* () {
372+ const entry = yield* input.bindingStore.get(toolId);
373+ if (!entry) return undefined;
374+ return annotationsForOperation(
375+ entry.binding.method,
376+ entry.binding.pathTemplate,
377+ );
378+ }),
379+
380+ invoke: (toolId: ToolId, args: unknown) =>
381+ Effect.gen(function* () {
382+ const entry = yield* input.bindingStore.get(toolId);
383+ if (!entry) {
384+ return yield* new ToolInvocationError({
385+ toolId,
386+ message: `No Google Discovery operation found for tool "${toolId}"`,
387+ cause: undefined,
388+ });
389+ }
390+
391+ const source = yield* input.bindingStore.getSourceData(entry.namespace);
392+ if (!source) {
393+ return yield* new ToolInvocationError({
394+ toolId,
395+ message: `No Google Discovery source found for "${entry.namespace}"`,
396+ cause: undefined,
397+ });
398+ }
399+
400+ const accessToken =
401+ source.auth.kind === "oauth2"
402+ ? yield* resolveOAuthAccessToken({
403+ sourceId: entry.namespace,
404+ source,
405+ secrets: input.secrets,
406+ scopeId: input.scopeId,
407+ bindingStore: input.bindingStore,
408+ })
409+ : "";
410+
411+ const authHeader =
412+ source.auth.kind === "oauth2"
413+ ? `${source.auth.tokenType} ${accessToken}`
414+ : undefined;
415+
416+ const result = yield* invoke({
417+ method: entry.binding.method,
418+ pathTemplate: entry.binding.pathTemplate,
419+ parameters: entry.binding.parameters,
420+ hasBody: entry.binding.hasBody,
421+ source,
422+ args: (args ?? {}) as Record<string, unknown>,
423+ authorizationHeader: authHeader,
424+ }).pipe(Effect.provide(httpClientLayer));
425+
426+ return new ToolInvocationResult({
427+ data: result.data,
428+ error: result.error,
429+ status: result.status,
430+ });
431+ }).pipe(
432+ Effect.catchAll((error) =>
433+ error instanceof ToolInvocationError
434+ ? Effect.fail(error)
435+ : Effect.fail(
436+ new ToolInvocationError({
437+ toolId,
438+ message:
439+ error instanceof Error ? error.message : String(error),
440+ cause: undefined,
441+ }),
442+ ),
443+ ),
444+ ),
445+ };
446+};
packages/plugins/google-discovery/src/sdk/oauth.ts+169−0
@@ -0,0 +1,169 @@
1+import { createHash, randomBytes } from "node:crypto";
2+
3+import { Effect } from "effect";
4+
5+import { GoogleDiscoveryOAuthError } from "./errors";
6+
7+export type OAuth2TokenResponse = {
8+ readonly access_token: string;
9+ readonly token_type?: string;
10+ readonly refresh_token?: string;
11+ readonly expires_in?: number;
12+ readonly scope?: string;
13+};
14+
15+const encodeBase64Url = (input: Buffer): string =>
16+ input
17+ .toString("base64")
18+ .replaceAll("+", "-")
19+ .replaceAll("/", "_")
20+ .replaceAll("=", "");
21+
22+export const createPkceCodeVerifier = (): string =>
23+ encodeBase64Url(randomBytes(48));
24+
25+const createPkceCodeChallenge = (verifier: string): string =>
26+ encodeBase64Url(createHash("sha256").update(verifier).digest());
27+
28+export const buildGoogleAuthorizationUrl = (input: {
29+ readonly clientId: string;
30+ readonly redirectUrl: string;
31+ readonly scopes: readonly string[];
32+ readonly state: string;
33+ readonly codeVerifier: string;
34+}): string => {
35+ const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
36+ url.searchParams.set("client_id", input.clientId);
37+ url.searchParams.set("redirect_uri", input.redirectUrl);
38+ url.searchParams.set("response_type", "code");
39+ url.searchParams.set("scope", input.scopes.join(" "));
40+ url.searchParams.set("state", input.state);
41+ url.searchParams.set("code_challenge_method", "S256");
42+ url.searchParams.set(
43+ "code_challenge",
44+ createPkceCodeChallenge(input.codeVerifier),
45+ );
46+ url.searchParams.set("access_type", "offline");
47+ url.searchParams.set("include_granted_scopes", "true");
48+ url.searchParams.set("prompt", "consent");
49+ return url.toString();
50+};
51+
52+const decodeTokenResponse = async (
53+ response: Response,
54+): Promise<OAuth2TokenResponse> => {
55+ const rawText = await response.text();
56+ let parsed: unknown;
57+ try {
58+ parsed = JSON.parse(rawText);
59+ } catch {
60+ throw new Error(
61+ `OAuth token endpoint returned non-JSON response (${response.status})`,
62+ );
63+ }
64+
65+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
66+ throw new Error(
67+ `OAuth token endpoint returned invalid JSON payload (${response.status})`,
68+ );
69+ }
70+
71+ const record = parsed as Record<string, unknown>;
72+ const accessToken =
73+ typeof record.access_token === "string" && record.access_token.length > 0
74+ ? record.access_token
75+ : null;
76+
77+ if (!response.ok) {
78+ const description =
79+ typeof record.error_description === "string"
80+ ? record.error_description
81+ : typeof record.error === "string"
82+ ? record.error
83+ : `status ${response.status}`;
84+ throw new Error(`OAuth token exchange failed: ${description}`);
85+ }
86+
87+ if (accessToken === null) {
88+ throw new Error("OAuth token endpoint did not return an access_token");
89+ }
90+
91+ return {
92+ access_token: accessToken,
93+ token_type:
94+ typeof record.token_type === "string" ? record.token_type : undefined,
95+ refresh_token:
96+ typeof record.refresh_token === "string"
97+ ? record.refresh_token
98+ : undefined,
99+ expires_in:
100+ typeof record.expires_in === "number"
101+ ? record.expires_in
102+ : typeof record.expires_in === "string"
103+ ? Number(record.expires_in)
104+ : undefined,
105+ scope: typeof record.scope === "string" ? record.scope : undefined,
106+ };
107+};
108+
109+const postToTokenEndpoint = (body: URLSearchParams) =>
110+ Effect.tryPromise({
111+ try: async () => {
112+ const response = await fetch("https://oauth2.googleapis.com/token", {
113+ method: "POST",
114+ headers: {
115+ "content-type": "application/x-www-form-urlencoded",
116+ accept: "application/json",
117+ },
118+ body,
119+ signal: AbortSignal.timeout(20_000),
120+ });
121+ return decodeTokenResponse(response);
122+ },
123+ catch: (cause) =>
124+ new GoogleDiscoveryOAuthError({
125+ message: cause instanceof Error ? cause.message : String(cause),
126+ }),
127+ });
128+
129+export const exchangeAuthorizationCode = (input: {
130+ readonly clientId: string;
131+ readonly clientSecret?: string | null;
132+ readonly redirectUrl: string;
133+ readonly codeVerifier: string;
134+ readonly code: string;
135+}): Effect.Effect<OAuth2TokenResponse, GoogleDiscoveryOAuthError> =>
136+ Effect.gen(function* () {
137+ const body = new URLSearchParams({
138+ grant_type: "authorization_code",
139+ client_id: input.clientId,
140+ redirect_uri: input.redirectUrl,
141+ code_verifier: input.codeVerifier,
142+ code: input.code,
143+ });
144+ if (input.clientSecret) {
145+ body.set("client_secret", input.clientSecret);
146+ }
147+ return yield* postToTokenEndpoint(body);
148+ });
149+
150+export const refreshAccessToken = (input: {
151+ readonly clientId: string;
152+ readonly clientSecret?: string | null;
153+ readonly refreshToken: string;
154+ readonly scopes?: readonly string[];
155+}): Effect.Effect<OAuth2TokenResponse, GoogleDiscoveryOAuthError> =>
156+ Effect.gen(function* () {
157+ const body = new URLSearchParams({
158+ grant_type: "refresh_token",
159+ client_id: input.clientId,
160+ refresh_token: input.refreshToken,
161+ });
162+ if (input.clientSecret) {
163+ body.set("client_secret", input.clientSecret);
164+ }
165+ if (input.scopes && input.scopes.length > 0) {
166+ body.set("scope", input.scopes.join(" "));
167+ }
168+ return yield* postToTokenEndpoint(body);
169+ });
packages/plugins/google-discovery/src/sdk/plugin.test.ts+350−0
@@ -0,0 +1,350 @@
1+import { createServer } from "node:http";
2+import { readFileSync } from "node:fs";
3+import { resolve } from "node:path";
4+
5+import { Effect } from "effect";
6+import { describe, expect, it, vi } from "vitest";
7+
8+import {
9+ createExecutor,
10+ makeTestConfig,
11+ SecretId,
12+ type InvokeOptions,
13+} from "@executor/sdk";
14+import { googleDiscoveryPlugin } from "./plugin";
15+
16+const autoApprove: InvokeOptions = { onElicitation: "accept-all" };
17+const fixturePath = resolve(__dirname, "../../fixtures/drive.json");
18+const fixtureText = readFileSync(fixturePath, "utf8");
19+
20+const withGoogleDiscoveryServer = async <T>(
21+ run: (input: {
22+ baseUrl: string;
23+ discoveryUrl: string;
24+ requests: Array<{
25+ method: string;
26+ url: string;
27+ headers: Record<string, string | string[] | undefined>;
28+ body: string;
29+ }>;
30+ }) => Promise<T>,
31+): Promise<T> => {
32+ const requests: Array<{
33+ method: string;
34+ url: string;
35+ headers: Record<string, string | string[] | undefined>;
36+ body: string;
37+ }> = [];
38+
39+ const server = createServer(async (request, response) => {
40+ const chunks: Buffer[] = [];
41+ for await (const chunk of request) {
42+ chunks.push(Buffer.from(chunk));
43+ }
44+ const body = Buffer.concat(chunks).toString("utf8");
45+ const url = request.url ?? "/";
46+
47+ requests.push({
48+ method: request.method ?? "GET",
49+ url,
50+ headers: request.headers,
51+ body,
52+ });
53+
54+ if (url === "/$discovery/rest?version=v3") {
55+ const address = server.address();
56+ if (!address || typeof address === "string") {
57+ throw new Error("Failed to resolve test server address");
58+ }
59+ const dynamicFixture = JSON.stringify({
60+ ...JSON.parse(fixtureText),
61+ rootUrl: `http://127.0.0.1:${address.port}/`,
62+ });
63+ response.statusCode = 200;
64+ response.setHeader("content-type", "application/json");
65+ response.end(dynamicFixture);
66+ return;
67+ }
68+
69+ response.statusCode = 200;
70+ response.setHeader("content-type", "application/json");
71+ response.end(JSON.stringify({ id: "123", name: "Quarterly Plan" }));
72+ });
73+
74+ await new Promise<void>((resolvePromise, reject) => {
75+ server.listen(0, "127.0.0.1", (error?: Error) => {
76+ if (error) {
77+ reject(error);
78+ return;
79+ }
80+ resolvePromise();
81+ });
82+ });
83+
84+ try {
85+ const address = server.address();
86+ if (!address || typeof address === "string") {
87+ throw new Error("Failed to resolve test server address");
88+ }
89+ const baseUrl = `http://127.0.0.1:${address.port}`;
90+ return await run({
91+ baseUrl,
92+ discoveryUrl: `${baseUrl}/$discovery/rest?version=v3`,
93+ requests,
94+ });
95+ } finally {
96+ await new Promise<void>((resolvePromise, reject) => {
97+ server.close((error) => {
98+ if (error) {
99+ reject(error);
100+ return;
101+ }
102+ resolvePromise();
103+ });
104+ });
105+ }
106+};
107+
108+describe("Google Discovery plugin", () => {
109+ it("normalizes legacy googleapis discovery urls", async () => {
110+ const executor = await Effect.runPromise(
111+ createExecutor(
112+ makeTestConfig({
113+ plugins: [googleDiscoveryPlugin()] as const,
114+ }),
115+ ),
116+ );
117+
118+ const originalFetch = globalThis.fetch;
119+ const fetchMock = vi
120+ .spyOn(globalThis, "fetch")
121+ .mockImplementation(((
122+ input: RequestInfo | URL,
123+ init?: RequestInit,
124+ ) => {
125+ const url =
126+ typeof input === "string"
127+ ? input
128+ : input instanceof URL
129+ ? input.toString()
130+ : input.url;
131+
132+ if (url === "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest") {
133+ return Promise.resolve(
134+ new Response(fixtureText, {
135+ status: 200,
136+ headers: { "content-type": "application/json" },
137+ }),
138+ );
139+ }
140+
141+ return originalFetch(input, init);
142+ }) as typeof fetch);
143+
144+ try {
145+ const result = await Effect.runPromise(
146+ executor.googleDiscovery.probeDiscovery(
147+ "https://drive.googleapis.com/$discovery/rest?version=v3",
148+ ),
149+ );
150+
151+ expect(result.service).toBe("drive");
152+ expect(fetchMock).toHaveBeenCalledWith(
153+ "https://www.googleapis.com/discovery/v1/apis/drive/v3/rest",
154+ expect.objectContaining({
155+ signal: expect.any(AbortSignal),
156+ }),
157+ );
158+ } finally {
159+ fetchMock.mockRestore();
160+ await Effect.runPromise(executor.close());
161+ }
162+ });
163+
164+ it("starts oauth using discovery scopes", async () => {
165+ await withGoogleDiscoveryServer(async ({ discoveryUrl }) => {
166+ const executor = await Effect.runPromise(
167+ createExecutor(
168+ makeTestConfig({
169+ plugins: [googleDiscoveryPlugin()] as const,
170+ }),
171+ ),
172+ );
173+
174+ const result = await Effect.runPromise(
175+ executor.googleDiscovery.startOAuth({
176+ name: "Google Drive",
177+ discoveryUrl,
178+ clientId: "client-123",
179+ redirectUrl: "http://localhost/callback",
180+ }),
181+ );
182+
183+ const authorizationUrl = new URL(result.authorizationUrl);
184+ expect(result.scopes).toContain("https://www.googleapis.com/auth/drive");
185+ expect(authorizationUrl.searchParams.get("client_id")).toBe("client-123");
186+ expect(authorizationUrl.searchParams.get("access_type")).toBe("offline");
187+ expect(authorizationUrl.searchParams.get("prompt")).toBe("consent");
188+
189+ await Effect.runPromise(executor.close());
190+ });
191+ });
192+
193+ it("completes oauth and stores token secrets", async () => {
194+ await withGoogleDiscoveryServer(async ({ discoveryUrl }) => {
195+ const executor = await Effect.runPromise(
196+ createExecutor(
197+ makeTestConfig({
198+ plugins: [googleDiscoveryPlugin()] as const,
199+ }),
200+ ),
201+ );
202+
203+ await Effect.runPromise(
204+ executor.secrets.set({
205+ id: SecretId.make("google-client-secret"),
206+ name: "Google Client Secret",
207+ value: "client-secret-value",
208+ purpose: "google_oauth_client_secret",
209+ }),
210+ );
211+
212+ const originalFetch = globalThis.fetch;
213+ const fetchMock = vi
214+ .spyOn(globalThis, "fetch")
215+ .mockImplementation(((
216+ input: RequestInfo | URL,
217+ init?: RequestInit,
218+ ) => {
219+ const url =
220+ typeof input === "string"
221+ ? input
222+ : input instanceof URL
223+ ? input.toString()
224+ : input.url;
225+ if (url === "https://oauth2.googleapis.com/token") {
226+ expect(init?.method).toBe("POST");
227+ return Promise.resolve(
228+ new Response(
229+ JSON.stringify({
230+ access_token: "access-token-value",
231+ refresh_token: "refresh-token-value",
232+ token_type: "Bearer",
233+ expires_in: 3600,
234+ scope: "https://www.googleapis.com/auth/drive",
235+ }),
236+ {
237+ status: 200,
238+ headers: { "content-type": "application/json" },
239+ },
240+ ),
241+ );
242+ }
243+ return originalFetch(input, init);
244+ }) as typeof fetch);
245+
246+ try {
247+ const started = await Effect.runPromise(
248+ executor.googleDiscovery.startOAuth({
249+ name: "Google Drive",
250+ discoveryUrl,
251+ clientId: "client-123",
252+ clientSecretSecretId: "google-client-secret",
253+ redirectUrl: "http://localhost/callback",
254+ }),
255+ );
256+
257+ const auth = await Effect.runPromise(
258+ executor.googleDiscovery.completeOAuth({
259+ state: started.sessionId,
260+ code: "code-123",
261+ }),
262+ );
263+
264+ expect(auth.kind).toBe("oauth2");
265+ expect(auth.clientId).toBe("client-123");
266+ expect(auth.refreshTokenSecretId).not.toBeNull();
267+
268+ const accessToken = await Effect.runPromise(
269+ executor.secrets.resolve(SecretId.make(auth.accessTokenSecretId)),
270+ );
271+ const refreshToken = await Effect.runPromise(
272+ executor.secrets.resolve(SecretId.make(auth.refreshTokenSecretId!)),
273+ );
274+ expect(accessToken).toBe("access-token-value");
275+ expect(refreshToken).toBe("refresh-token-value");
276+ } finally {
277+ fetchMock.mockRestore();
278+ await Effect.runPromise(executor.close());
279+ }
280+ });
281+ });
282+
283+ it("registers and invokes google discovery tools with oauth headers", async () => {
284+ await withGoogleDiscoveryServer(async ({ discoveryUrl, requests }) => {
285+ const executor = await Effect.runPromise(
286+ createExecutor(
287+ makeTestConfig({
288+ plugins: [googleDiscoveryPlugin()] as const,
289+ }),
290+ ),
291+ );
292+
293+ try {
294+ await Effect.runPromise(
295+ executor.secrets.set({
296+ id: SecretId.make("drive-access-token"),
297+ name: "Drive Access Token",
298+ value: "secret-token",
299+ purpose: "google_oauth_access_token",
300+ }),
301+ );
302+
303+ const result = await Effect.runPromise(
304+ executor.googleDiscovery.addSource({
305+ name: "Google Drive",
306+ discoveryUrl,
307+ namespace: "drive",
308+ auth: {
309+ kind: "oauth2",
310+ clientId: "client-123",
311+ clientSecretSecretId: null,
312+ accessTokenSecretId: "drive-access-token",
313+ refreshTokenSecretId: null,
314+ tokenType: "Bearer",
315+ expiresAt: null,
316+ scope: null,
317+ scopes: ["https://www.googleapis.com/auth/drive.readonly"],
318+ },
319+ }),
320+ );
321+
322+ expect(result.toolCount).toBe(2);
323+
324+ const invocation = await Effect.runPromise(
325+ executor.tools.invoke(
326+ "drive.files.get",
327+ { fileId: "123", fields: "id,name", prettyPrint: true },
328+ autoApprove,
329+ ),
330+ );
331+
332+ expect(invocation.error).toBeNull();
333+ expect(invocation.data).toEqual({
334+ id: "123",
335+ name: "Quarterly Plan",
336+ });
337+
338+ const apiRequest = requests.find((request) =>
339+ request.url.startsWith("/drive/v3/files/123"),
340+ );
341+ expect(apiRequest).toBeDefined();
342+ expect(apiRequest!.headers.authorization).toBe("Bearer secret-token");
343+ expect(apiRequest!.url).toContain("fields=id%2Cname");
344+ expect(apiRequest!.url).toContain("prettyPrint=true");
345+ } finally {
346+ await Effect.runPromise(executor.close());
347+ }
348+ });
349+ });
350+});
packages/plugins/google-discovery/src/sdk/plugin.ts+538−0
@@ -0,0 +1,538 @@
1+import { randomUUID } from "node:crypto";
2+
3+import { Effect, Option } from "effect";
4+
5+import {
6+ Source,
7+ definePlugin,
8+ type ExecutorPlugin,
9+ type PluginContext,
10+ SecretId,
11+ ToolId,
12+ type ToolRegistration,
13+} from "@executor/sdk";
14+
15+import type {
16+ GoogleDiscoveryBindingStore,
17+ GoogleDiscoverySourceMeta,
18+} from "./binding-store";
19+import { makeInMemoryBindingStore } from "./binding-store";
20+import { extractGoogleDiscoveryManifest } from "./document";
21+import { makeGoogleDiscoveryInvoker } from "./invoke";
22+import {
23+ GoogleDiscoveryParseError,
24+ GoogleDiscoveryOAuthError,
25+ GoogleDiscoverySourceError,
26+} from "./errors";
27+import {
28+ buildGoogleAuthorizationUrl,
29+ createPkceCodeVerifier,
30+ exchangeAuthorizationCode,
31+} from "./oauth";
32+import type {
33+ GoogleDiscoveryAuth,
34+ GoogleDiscoveryManifest,
35+ GoogleDiscoveryManifestMethod,
36+ GoogleDiscoveryOAuthSession,
37+ GoogleDiscoveryStoredSourceData,
38+} from "./types";
39+import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types";
40+
41+export interface GoogleDiscoveryProbeResult {
42+ readonly name: string;
43+ readonly title: string | null;
44+ readonly service: string;
45+ readonly version: string;
46+ readonly toolCount: number;
47+ readonly scopes: readonly string[];
48+}
49+
50+export interface GoogleDiscoveryAddSourceInput {
51+ readonly name: string;
52+ readonly discoveryUrl: string;
53+ readonly namespace?: string;
54+ readonly auth: GoogleDiscoveryAuth;
55+}
56+
57+export interface GoogleDiscoveryOAuthStartInput {
58+ readonly name: string;
59+ readonly discoveryUrl: string;
60+ readonly clientId: string;
61+ readonly clientSecretSecretId?: string | null;
62+ readonly redirectUrl: string;
63+ readonly scopes?: readonly string[];
64+}
65+
66+export interface GoogleDiscoveryOAuthStartResponse {
67+ readonly sessionId: string;
68+ readonly authorizationUrl: string;
69+ readonly scopes: readonly string[];
70+}
71+
72+export interface GoogleDiscoveryOAuthCompleteInput {
73+ readonly state: string;
74+ readonly code?: string;
75+ readonly error?: string;
76+}
77+
78+export interface GoogleDiscoveryOAuthAuthResult {
79+ readonly kind: "oauth2";
80+ readonly clientId: string;
81+ readonly clientSecretSecretId: string | null;
82+ readonly accessTokenSecretId: string;
83+ readonly refreshTokenSecretId: string | null;
84+ readonly tokenType: string;
85+ readonly expiresAt: number | null;
86+ readonly scope: string | null;
87+ readonly scopes: readonly string[];
88+}
89+
90+export interface GoogleDiscoveryPluginExtension {
91+ readonly probeDiscovery: (
92+ discoveryUrl: string,
93+ ) => Effect.Effect<
94+ GoogleDiscoveryProbeResult,
95+ GoogleDiscoveryParseError | GoogleDiscoverySourceError
96+ >;
97+ readonly addSource: (
98+ input: GoogleDiscoveryAddSourceInput,
99+ ) => Effect.Effect<
100+ { readonly toolCount: number; readonly namespace: string },
101+ GoogleDiscoveryParseError | GoogleDiscoverySourceError
102+ >;
103+ readonly removeSource: (namespace: string) => Effect.Effect<void>;
104+ readonly startOAuth: (
105+ input: GoogleDiscoveryOAuthStartInput,
106+ ) => Effect.Effect<
107+ GoogleDiscoveryOAuthStartResponse,
108+ GoogleDiscoveryParseError | GoogleDiscoverySourceError | GoogleDiscoveryOAuthError
109+ >;
110+ readonly completeOAuth: (
111+ input: GoogleDiscoveryOAuthCompleteInput,
112+ ) => Effect.Effect<GoogleDiscoveryOAuthAuthResult, GoogleDiscoveryOAuthError>;
113+}
114+
115+const DISCOVERY_SERVICE_HOST = "https://www.googleapis.com/discovery/v1/apis";
116+
117+const normalizeDiscoveryUrl = (discoveryUrl: string): string => {
118+ const trimmed = discoveryUrl.trim();
119+ if (trimmed.length === 0) return trimmed;
120+
121+ let parsed: URL;
122+ try {
123+ parsed = new URL(trimmed);
124+ } catch {
125+ return trimmed;
126+ }
127+
128+ if (parsed.pathname !== "/$discovery/rest") {
129+ return trimmed;
130+ }
131+
132+ const version = parsed.searchParams.get("version")?.trim();
133+ if (!version) {
134+ return trimmed;
135+ }
136+
137+ const host = parsed.hostname.toLowerCase();
138+ if (!host.endsWith(".googleapis.com")) {
139+ return trimmed;
140+ }
141+
142+ const rawService = host.slice(0, -".googleapis.com".length);
143+ const service =
144+ rawService === "calendar-json" ? "calendar"
145+ : rawService.endsWith("-json") ? rawService.slice(0, -5)
146+ : rawService;
147+
148+ if (!service) {
149+ return trimmed;
150+ }
151+
152+ return `${DISCOVERY_SERVICE_HOST}/${service}/${version}/rest`;
153+};
154+
155+const fetchDiscoveryDocument = (discoveryUrl: string) =>
156+ Effect.tryPromise({
157+ try: async () => {
158+ const response = await fetch(normalizeDiscoveryUrl(discoveryUrl), {
159+ signal: AbortSignal.timeout(20_000),
160+ });
161+ if (!response.ok) {
162+ throw new GoogleDiscoverySourceError({
163+ message: `Google Discovery fetch failed with status ${response.status}`,
164+ });
165+ }
166+ return response.text();
167+ },
168+ catch: (cause) =>
169+ cause instanceof GoogleDiscoverySourceError
170+ ? cause
171+ : new GoogleDiscoverySourceError({
172+ message: cause instanceof Error ? cause.message : String(cause),
173+ }),
174+ });
175+
176+const normalizeSlug = (value: string): string =>
177+ value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
178+
179+const deriveNamespace = (input: {
180+ name: string;
181+ service: string;
182+ version: string;
183+}): string =>
184+ normalizeSlug(
185+ input.name ||
186+ `google_${input.service}_${input.version.replace(/[^a-zA-Z0-9]+/g, "_")}`,
187+ ) || `google_${input.service}`;
188+
189+const registerManifest = (
190+ ctx: PluginContext,
191+ bindingStore: GoogleDiscoveryBindingStore,
192+ namespace: string,
193+ manifest: GoogleDiscoveryManifest,
194+ sourceData: GoogleDiscoveryStoredSourceData,
195+) =>
196+ Effect.gen(function* () {
197+ const existingToolIds = yield* bindingStore.listByNamespace(namespace);
198+ if (existingToolIds.length > 0) {
199+ yield* ctx.tools.unregister(existingToolIds);
200+ yield* bindingStore.removeByNamespace(namespace);
201+ }
202+
203+ if (Object.keys(manifest.schemaDefinitions).length > 0) {
204+ yield* ctx.tools.registerDefinitions(manifest.schemaDefinitions);
205+ }
206+
207+ const registrations: ToolRegistration[] = manifest.methods.map(
208+ (method: GoogleDiscoveryManifestMethod) => ({
209+ id: ToolId.make(`${namespace}.${method.toolPath}`),
210+ pluginKey: "googleDiscovery",
211+ sourceId: namespace,
212+ name: method.toolPath,
213+ description: Option.getOrUndefined(method.description),
214+ inputSchema: Option.getOrUndefined(method.inputSchema),
215+ outputSchema: Option.getOrUndefined(method.outputSchema),
216+ }),
217+ );
218+
219+ yield* Effect.forEach(
220+ manifest.methods,
221+ (method) =>
222+ bindingStore.put(
223+ ToolId.make(`${namespace}.${method.toolPath}`),
224+ namespace,
225+ method.binding,
226+ ),
227+ { discard: true },
228+ );
229+
230+ yield* ctx.tools.register(registrations);
231+ yield* bindingStore.putSourceMeta({
232+ namespace,
233+ name: sourceData.name,
234+ } satisfies GoogleDiscoverySourceMeta);
235+ yield* bindingStore.putSourceData(namespace, sourceData);
236+
237+ return registrations.length;
238+ });
239+
240+const storeSecret = (ctx: PluginContext, input: {
241+ readonly idPrefix: string;
242+ readonly name: string;
243+ readonly value: string;
244+ readonly purpose: string;
245+}) =>
246+ ctx.secrets
247+ .set({
248+ id: SecretId.make(`${input.idPrefix}_${randomUUID().slice(0, 8)}`),
249+ scopeId: ctx.scope.id,
250+ name: input.name,
251+ value: input.value,
252+ purpose: input.purpose,
253+ })
254+ .pipe(
255+ Effect.mapError(
256+ (error) =>
257+ new GoogleDiscoveryOAuthError({
258+ message: error.message,
259+ }),
260+ ),
261+ );
262+
263+export const googleDiscoveryPlugin = (options?: {
264+ readonly bindingStore?: GoogleDiscoveryBindingStore;
265+}): ExecutorPlugin<"googleDiscovery", GoogleDiscoveryPluginExtension> => {
266+ const bindingStore = options?.bindingStore ?? makeInMemoryBindingStore();
267+ const oauthSessions = new Map<string, GoogleDiscoveryOAuthSession>();
268+
269+ return definePlugin({
270+ key: "googleDiscovery",
271+ init: (ctx: PluginContext) =>
272+ Effect.gen(function* () {
273+ yield* ctx.tools.registerInvoker(
274+ "googleDiscovery",
275+ makeGoogleDiscoveryInvoker({
276+ bindingStore,
277+ secrets: ctx.secrets,
278+ scopeId: ctx.scope.id,
279+ }),
280+ );
281+
282+ yield* ctx.sources.addManager({
283+ kind: "googleDiscovery",
284+ list: () =>
285+ bindingStore.listSourceMeta().pipe(
286+ Effect.map((metas) =>
287+ metas.map(
288+ (meta) =>
289+ new Source({
290+ id: meta.namespace,
291+ name: meta.name,
292+ kind: "googleDiscovery",
293+ runtime: false,
294+ canRemove: true,
295+ canRefresh: true,
296+ }),
297+ ),
298+ ),
299+ ),
300+ remove: (sourceId) =>
301+ Effect.gen(function* () {
302+ const toolIds = yield* bindingStore.removeByNamespace(sourceId);
303+ if (toolIds.length > 0) {
304+ yield* ctx.tools.unregister(toolIds);
305+ }
306+ yield* bindingStore.removeSourceMeta(sourceId);
307+ yield* bindingStore.removeSourceData(sourceId);
308+ }),
309+ refresh: (sourceId) =>
310+ Effect.gen(function* () {
311+ const sourceData = yield* bindingStore.getSourceData(sourceId);
312+ if (!sourceData) return;
313+ const discoveryText = yield* fetchDiscoveryDocument(
314+ sourceData.discoveryUrl,
315+ );
316+ const manifest = yield* extractGoogleDiscoveryManifest(
317+ discoveryText,
318+ );
319+ const nextSourceData = new GoogleDiscoveryStoredSourceDataSchema({
320+ ...sourceData,
321+ service: manifest.service,
322+ version: manifest.version,
323+ rootUrl: manifest.rootUrl,
324+ servicePath: manifest.servicePath,
325+ });
326+ yield* registerManifest(
327+ ctx,
328+ bindingStore,
329+ sourceId,
330+ manifest,
331+ nextSourceData,
332+ );
333+ }).pipe(Effect.orDie),
334+ });
335+
336+ const extension: GoogleDiscoveryPluginExtension = {
337+ probeDiscovery: (discoveryUrl: string) =>
338+ Effect.gen(function* () {
339+ const discoveryText = yield* fetchDiscoveryDocument(discoveryUrl);
340+ const manifest = yield* extractGoogleDiscoveryManifest(
341+ discoveryText,
342+ );
343+ const scopes = Object.keys(
344+ manifest.oauthScopes._tag === "Some"
345+ ? manifest.oauthScopes.value
346+ : {},
347+ ).sort();
348+ return {
349+ name:
350+ manifest.title._tag === "Some"
351+ ? manifest.title.value
352+ : `${manifest.service} ${manifest.version}`,
353+ title:
354+ manifest.title._tag === "Some"
355+ ? manifest.title.value
356+ : null,
357+ service: manifest.service,
358+ version: manifest.version,
359+ toolCount: manifest.methods.length,
360+ scopes,
361+ };
362+ }),
363+
364+ addSource: (input) =>
365+ Effect.gen(function* () {
366+ const discoveryText = yield* fetchDiscoveryDocument(
367+ input.discoveryUrl,
368+ );
369+ const manifest = yield* extractGoogleDiscoveryManifest(
370+ discoveryText,
371+ );
372+ const namespace =
373+ input.namespace ??
374+ deriveNamespace({
375+ name: input.name,
376+ service: manifest.service,
377+ version: manifest.version,
378+ });
379+ const sourceData = new GoogleDiscoveryStoredSourceDataSchema({
380+ name: input.name,
381+ discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl),
382+ service: manifest.service,
383+ version: manifest.version,
384+ rootUrl: manifest.rootUrl,
385+ servicePath: manifest.servicePath,
386+ auth: input.auth,
387+ });
388+ const toolCount = yield* registerManifest(
389+ ctx,
390+ bindingStore,
391+ namespace,
392+ manifest,
393+ sourceData,
394+ );
395+ return { toolCount, namespace };
396+ }),
397+
398+ removeSource: (namespace: string) =>
399+ Effect.gen(function* () {
400+ const toolIds = yield* bindingStore.removeByNamespace(namespace);
401+ if (toolIds.length > 0) {
402+ yield* ctx.tools.unregister(toolIds);
403+ }
404+ yield* bindingStore.removeSourceMeta(namespace);
405+ yield* bindingStore.removeSourceData(namespace);
406+ }),
407+
408+ startOAuth: (input) =>
409+ Effect.gen(function* () {
410+ const discoveryText = yield* fetchDiscoveryDocument(
411+ input.discoveryUrl,
412+ );
413+ const manifest = yield* extractGoogleDiscoveryManifest(
414+ discoveryText,
415+ );
416+ const scopes =
417+ input.scopes && input.scopes.length > 0
418+ ? [...input.scopes]
419+ : Object.keys(
420+ manifest.oauthScopes._tag === "Some"
421+ ? manifest.oauthScopes.value
422+ : {},
423+ ).sort();
424+ if (scopes.length === 0) {
425+ return yield* new GoogleDiscoveryOAuthError({
426+ message:
427+ "This Google Discovery document does not declare any OAuth scopes",
428+ });
429+ }
430+ const sessionId = randomUUID();
431+ const codeVerifier = createPkceCodeVerifier();
432+ oauthSessions.set(sessionId, {
433+ discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl),
434+ name: input.name,
435+ clientId: input.clientId,
436+ clientSecretSecretId: input.clientSecretSecretId ?? null,
437+ redirectUrl: input.redirectUrl,
438+ scopes,
439+ codeVerifier,
440+ });
441+ return {
442+ sessionId,
443+ authorizationUrl: buildGoogleAuthorizationUrl({
444+ clientId: input.clientId,
445+ redirectUrl: input.redirectUrl,
446+ scopes,
447+ state: sessionId,
448+ codeVerifier,
449+ }),
450+ scopes,
451+ };
452+ }),
453+
454+ completeOAuth: (input) =>
455+ Effect.gen(function* () {
456+ const session = oauthSessions.get(input.state);
457+ if (!session) {
458+ return yield* new GoogleDiscoveryOAuthError({
459+ message: "OAuth session not found or has expired",
460+ });
461+ }
462+ oauthSessions.delete(input.state);
463+
464+ if (input.error) {
465+ return yield* new GoogleDiscoveryOAuthError({
466+ message: input.error,
467+ });
468+ }
469+ if (!input.code) {
470+ return yield* new GoogleDiscoveryOAuthError({
471+ message: "OAuth callback did not include an authorization code",
472+ });
473+ }
474+
475+ const tokenResponse = yield* exchangeAuthorizationCode({
476+ clientId: session.clientId,
477+ clientSecret:
478+ session.clientSecretSecretId === null
479+ ? null
480+ : yield* ctx.secrets
481+ .resolve(
482+ SecretId.make(session.clientSecretSecretId),
483+ ctx.scope.id,
484+ )
485+ .pipe(
486+ Effect.mapError(
487+ (error) =>
488+ new GoogleDiscoveryOAuthError({
489+ message: error.message,
490+ }),
491+ ),
492+ ),
493+ redirectUrl: session.redirectUrl,
494+ codeVerifier: session.codeVerifier,
495+ code: input.code,
496+ });
497+
498+ const accessTokenRef = yield* storeSecret(ctx, {
499+ idPrefix: `${normalizeSlug(session.name)}_google_access_token`,
500+ name: `${session.name} Access Token`,
501+ value: tokenResponse.access_token,
502+ purpose: "google_oauth_access_token",
503+ });
504+ const refreshTokenRef = tokenResponse.refresh_token
505+ ? yield* storeSecret(ctx, {
506+ idPrefix: `${normalizeSlug(session.name)}_google_refresh_token`,
507+ name: `${session.name} Refresh Token`,
508+ value: tokenResponse.refresh_token,
509+ purpose: "google_oauth_refresh_token",
510+ })
511+ : null;
512+ return {
513+ kind: "oauth2" as const,
514+ clientId: session.clientId,
515+ clientSecretSecretId: session.clientSecretSecretId,
516+ accessTokenSecretId: accessTokenRef.id,
517+ refreshTokenSecretId: refreshTokenRef?.id ?? null,
518+ tokenType: tokenResponse.token_type ?? "Bearer",
519+ expiresAt:
520+ typeof tokenResponse.expires_in === "number"
521+ ? Date.now() + tokenResponse.expires_in * 1000
522+ : null,
523+ scope: tokenResponse.scope ?? null,
524+ scopes: [...session.scopes],
525+ };
526+ }),
527+ };
528+
529+ return {
530+ extension,
531+ close: () =>
532+ Effect.sync(() => {
533+ oauthSessions.clear();
534+ }),
535+ };
536+ }),
537+ });
538+};
packages/plugins/google-discovery/src/sdk/types.ts+121−0
@@ -0,0 +1,121 @@
1+import { Schema } from "effect";
2+
3+export const GoogleDiscoveryHttpMethod = Schema.Literal(
4+ "get",
5+ "put",
6+ "post",
7+ "delete",
8+ "patch",
9+ "head",
10+ "options",
11+);
12+export type GoogleDiscoveryHttpMethod = typeof GoogleDiscoveryHttpMethod.Type;
13+
14+export const GoogleDiscoveryParameterLocation = Schema.Literal(
15+ "path",
16+ "query",
17+ "header",
18+);
19+export type GoogleDiscoveryParameterLocation =
20+ typeof GoogleDiscoveryParameterLocation.Type;
21+
22+export class GoogleDiscoveryParameter extends Schema.Class<GoogleDiscoveryParameter>(
23+ "GoogleDiscoveryParameter",
24+)({
25+ name: Schema.String,
26+ location: GoogleDiscoveryParameterLocation,
27+ required: Schema.Boolean,
28+ repeated: Schema.Boolean,
29+ description: Schema.optionalWith(Schema.String, { as: "Option" }),
30+ schema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
31+}) {}
32+
33+export class GoogleDiscoveryMethodBinding extends Schema.Class<GoogleDiscoveryMethodBinding>(
34+ "GoogleDiscoveryMethodBinding",
35+)({
36+ method: GoogleDiscoveryHttpMethod,
37+ pathTemplate: Schema.String,
38+ parameters: Schema.Array(GoogleDiscoveryParameter),
39+ hasBody: Schema.Boolean,
40+}) {}
41+
42+export class GoogleDiscoveryManifestMethod extends Schema.Class<GoogleDiscoveryManifestMethod>(
43+ "GoogleDiscoveryManifestMethod",
44+)({
45+ toolPath: Schema.String,
46+ description: Schema.optionalWith(Schema.String, { as: "Option" }),
47+ binding: GoogleDiscoveryMethodBinding,
48+ inputSchema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
49+ outputSchema: Schema.optionalWith(Schema.Unknown, { as: "Option" }),
50+ scopes: Schema.Array(Schema.String),
51+}) {}
52+
53+export class GoogleDiscoveryManifest extends Schema.Class<GoogleDiscoveryManifest>(
54+ "GoogleDiscoveryManifest",
55+)({
56+ title: Schema.optionalWith(Schema.String, { as: "Option" }),
57+ service: Schema.String,
58+ version: Schema.String,
59+ rootUrl: Schema.String,
60+ servicePath: Schema.String,
61+ oauthScopes: Schema.optionalWith(
62+ Schema.Record({ key: Schema.String, value: Schema.String }),
63+ { as: "Option" },
64+ ),
65+ schemaDefinitions: Schema.Record({ key: Schema.String, value: Schema.Unknown }),
66+ methods: Schema.Array(GoogleDiscoveryManifestMethod),
67+}) {}
68+
69+export const GoogleDiscoveryAuth = Schema.Union(
70+ Schema.Struct({
71+ kind: Schema.Literal("none"),
72+ }),
73+ Schema.Struct({
74+ kind: Schema.Literal("oauth2"),
75+ clientId: Schema.String,
76+ clientSecretSecretId: Schema.NullOr(Schema.String),
77+ accessTokenSecretId: Schema.String,
78+ refreshTokenSecretId: Schema.NullOr(Schema.String),
79+ tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }),
80+ expiresAt: Schema.NullOr(Schema.Number),
81+ scope: Schema.NullOr(Schema.String),
82+ scopes: Schema.Array(Schema.String),
83+ }),
84+);
85+export type GoogleDiscoveryAuth = typeof GoogleDiscoveryAuth.Type;
86+
87+export class GoogleDiscoveryStoredSourceData extends Schema.Class<GoogleDiscoveryStoredSourceData>(
88+ "GoogleDiscoveryStoredSourceData",
89+)({
90+ name: Schema.String,
91+ discoveryUrl: Schema.String,
92+ service: Schema.String,
93+ version: Schema.String,
94+ rootUrl: Schema.String,
95+ servicePath: Schema.String,
96+ auth: GoogleDiscoveryAuth,
97+}) {}
98+
99+export class GoogleDiscoveryInvocationResult extends Schema.Class<GoogleDiscoveryInvocationResult>(
100+ "GoogleDiscoveryInvocationResult",
101+)({
102+ status: Schema.Number,
103+ headers: Schema.Record({ key: Schema.String, value: Schema.String }),
104+ data: Schema.NullOr(Schema.Unknown),
105+ error: Schema.NullOr(Schema.Unknown),
106+}) {}
107+
108+export interface GoogleDiscoverySourceMeta {
109+ readonly namespace: string;
110+ readonly name: string;
111+}
112+
113+export interface GoogleDiscoveryOAuthSession {
114+ readonly discoveryUrl: string;
115+ readonly name: string;
116+ readonly clientId: string;
117+ readonly clientSecretSecretId: string | null;
118+ readonly redirectUrl: string;
119+ readonly scopes: readonly string[];
120+ readonly codeVerifier: string;
121+}
packages/plugins/google-discovery/tsconfig.json+27−0
@@ -0,0 +1,27 @@
1+{
2+ "compilerOptions": {
3+ "target": "ES2022",
4+ "module": "ESNext",
5+ "moduleResolution": "Bundler",
6+ "strict": true,
7+ "skipLibCheck": true,
8+ "noEmit": true,
9+ "lib": ["ES2022", "DOM"],
10+ "types": ["bun-types", "node"],
11+ "noUnusedLocals": true,
12+ "noImplicitOverride": true,
13+ "jsx": "react-jsx",
14+ "plugins": [
15+ {
16+ "name": "@effect/language-service",
17+ "diagnostics": {
18+ "preferSchemaOverJson": "off"
19+ }
20+ }
21+ ]
22+ },
23+ "include": [
24+ "src/**/*.ts",
25+ "src/**/*.tsx"
26+ ]
27+}
packages/plugins/onepassword/src/react/OnePasswordSettings.tsx+2−2
@@ -107,7 +107,7 @@ function VaultPicker(props: { · 106 unmodified lines
107107 )}
108108 {error && (
109109 <div className="rounded-md border border-destructive/20 bg-destructive/5 px-2.5 py-1.5">
110− <p className="text-[11px] text-destructive leading-relaxed">{error}</p>
110+ <p className="text-[11px] text-destructive leading-relaxed whitespace-pre-line">{error}</p>
111111 </div>
112112 )}
113113 </div>
@@ -246,7 +246,7 @@ function ConfigDialog(props: { · 132 unmodified lines
246246
247247 {error && (
248248 <div className="rounded-md border border-destructive/20 bg-destructive/5 px-3 py-2">
249− <p className="text-[12px] text-destructive">{error}</p>
249+ <p className="text-[12px] text-destructive whitespace-pre-line">{error}</p>
250250 </div>
251251 )}
252252 </div>
packages/plugins/onepassword/src/sdk/client.ts+18−2
@@ -67,7 +67,15 @@ export const makeOnePasswordClient = ( · 66 unmodified lines
6767 onTimeout: () =>
6868 new OnePasswordError({
6969 operation: "client setup",
70− message: `timed out after ${Math.floor(timeoutMs / 1000)}s — approve the request in the 1Password desktop app and try again`,
70+ message: [
71+ `timed out after ${Math.floor(timeoutMs / 1000)}s.`,
72+ "Troubleshooting:",
73+ "1. Make sure the 1Password desktop app is open and unlocked",
74+ "2. Check for an approval prompt in the 1Password app — it may be behind other windows",
75+ "3. Ensure 'Developer > Connect with 1Password CLI' is enabled in 1Password Settings",
76+ "4. Make sure no other app or terminal is waiting for 1Password approval (only one prompt at a time)",
77+ "5. Try quitting 1Password completely and reopening it, then retry",
78+ ].join("\n"),
7179 }),
7280 }),
7381 );
@@ -89,7 +97,15 @@ export const makeOnePasswordClient = ( · 15 unmodified lines
8997 onTimeout: () =>
9098 new OnePasswordError({
9199 operation,
92− message: `timed out after ${Math.floor(timeoutMs / 1000)}s — approve the request in the 1Password desktop app and try again`,
100+ message: [
101+ `timed out after ${Math.floor(timeoutMs / 1000)}s.`,
102+ "Troubleshooting:",
103+ "1. Make sure the 1Password desktop app is open and unlocked",
104+ "2. Check for an approval prompt in the 1Password app — it may be behind other windows",
105+ "3. Ensure 'Developer > Connect with 1Password CLI' is enabled in 1Password Settings",
106+ "4. Make sure no other app or terminal is waiting for 1Password approval (only one prompt at a time)",
107+ "5. Try quitting 1Password completely and reopening it, then retry",
108+ ].join("\n"),
93109 }),
94110 }),
95111 Effect.withSpan(`onepassword.${operation}`),
packages/plugins/openapi/src/react/AddOpenApiSource.tsx+13−145
@@ -2,25 +2,20 @@ import { useState } from "react"; · 1 unmodified line
22 import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";
33 import { Option } from "effect";
44
5−import { secretsAtom, setSecret, resolveSecret, ScopeId } from "@executor/react";
5+import {
6+ secretsAtom,
7+ setSecret,
8+ resolveSecret,
9+ ScopeId,
10+ SecretPicker,
11+ type SecretPickerSecret,
12+} from "@executor/react";
613 import { SecretId } from "@executor/sdk";
714 import { Button } from "@executor/ui/components/button";
815 import { Input } from "@executor/ui/components/input";
916 import { Label } from "@executor/ui/components/label";
1017 import { Textarea } from "@executor/ui/components/textarea";
1118 import { Badge } from "@executor/ui/components/badge";
12−import {
13− Command,
14− CommandList,
15− CommandEmpty,
16− CommandGroup,
17− CommandItem,
18−} from "@executor/ui/components/command";
19−import {
20− Popover,
21− PopoverAnchor,
22− PopoverContent,
23−} from "@executor/ui/components/popover";
2419 import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group";
2520 import { Separator } from "@executor/ui/components/separator";
2621 import { Spinner } from "@executor/ui/components/spinner";
@@ -28,133 +23,6 @@ import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; · 1 unmodified line
2823 import type { SpecPreview, HeaderPreset } from "../sdk/preview";
2924 import type { HeaderValue } from "../sdk/types";
3025
31−// ---------------------------------------------------------------------------
32−// Types
33−// ---------------------------------------------------------------------------
34−
35−interface SecretEntry {
36− id: string;
37− name: string;
38− provider?: string;
39−}
40−
41−// ---------------------------------------------------------------------------
42−// Provider display names
43−// ---------------------------------------------------------------------------
44−
45−const PROVIDER_LABELS: Record<string, string> = {
46− keychain: "Keychain",
47− file: "Local",
48− memory: "Memory",
49− onepassword: "1Password",
50−};
51−
52−function providerLabel(key: string | undefined): string {
53− if (!key) return "Local";
54− return PROVIDER_LABELS[key] ?? key;
55−}
56−
57−// ---------------------------------------------------------------------------
58−// Secret picker — combobox grouped by provider
59−// ---------------------------------------------------------------------------
60−
61−function SecretPicker(props: {
62− value: string | null;
63− onSelect: (secretId: string) => void;
64− secrets: readonly SecretEntry[];
65− placeholder?: string;
66−}) {
67− const { value, onSelect, secrets, placeholder = "Search secrets…" } = props;
68− const [open, setOpen] = useState(false);
69− const [query, setQuery] = useState("");
70−
71− const selected = secrets.find((s) => s.id === value) ?? null;
72−
73− // Group by provider, sorted alphabetically
74− const grouped = new Map<string, SecretEntry[]>();
75− for (const s of secrets) {
76− const key = providerLabel(s.provider);
77− const group = grouped.get(key);
78− if (group) {
79− group.push(s);
80− } else {
81− grouped.set(key, [s]);
82− }
83− }
84− const groups: [string, SecretEntry[]][] = [...grouped.entries()]
85− .map(([label, items]): [string, SecretEntry[]] => [label, [...items].sort((a, b) => a.name.localeCompare(b.name))])
86− .sort(([a], [b]) => a.localeCompare(b));
87−
88− return (
89− <div className="relative w-full">
90− <Popover open={open} onOpenChange={setOpen} modal={false}>
91− <PopoverAnchor asChild>
92− <Input
93− value={open ? query : (selected ? selected.name : "")}
94− onChange={(e) => {
95− setQuery((e.target as HTMLInputElement).value);
96− if (!open) setOpen(true);
97− }}
98− onFocus={() => setOpen(true)}
99− onBlur={(e) => {
100− // Don't close if focus moved into the popover
101− const related = (e as React.FocusEvent).relatedTarget as HTMLElement | null;
102− if (related?.closest("[data-slot=popover-content]")) return;
103− setOpen(false);
104− }}
105− placeholder={placeholder}
106− className="text-sm"
107− />
108− </PopoverAnchor>
109− <PopoverContent
110− className="w-(--radix-popover-trigger-width) p-0"
111− align="start"
112− onOpenAutoFocus={(e) => e.preventDefault()}
113− onCloseAutoFocus={(e) => e.preventDefault()}
114− onInteractOutside={(e) => {
115− // Don't close if interacting with the input
116− const target = e.target as HTMLElement | null;
117− if (target?.closest("[data-slot=popover-anchor]")) {
118− e.preventDefault();
119− }
120− }}
121− >
122− <Command shouldFilter={false}>
123− <CommandList>
124− <CommandEmpty>No secrets found</CommandEmpty>
125− {groups.map(([label, items]) => {
126− const lq = query.toLowerCase();
127− const filtered = lq
128− ? items.filter((s) => s.name.toLowerCase().includes(lq) || s.id.toLowerCase().includes(lq))
129− : items;
130− if (filtered.length === 0) return null;
131− return (
132− <CommandGroup key={label} heading={label}>
133− {filtered.map((s) => (
134− <CommandItem
135− key={s.id}
136− value={`${s.name} ${s.id}`}
137− onSelect={() => {
138− onSelect(s.id);
139− setOpen(false);
140− setQuery("");
141− }}
142− >
143− <span className="truncate">{s.name}</span>
144− <span className="ml-auto text-[10px] font-mono text-muted-foreground truncate">{s.id}</span>
145− </CommandItem>
146− ))}
147− </CommandGroup>
148− );
149− })}
150− </CommandList>
151− </Command>
152− </PopoverContent>
153− </Popover>
154− </div>
155− );
156−}
157−
15826 // ---------------------------------------------------------------------------
15927 // Inline secret creation
16028 // ---------------------------------------------------------------------------
@@ -333,7 +201,7 @@ function HeaderSecretRow(props: { · 172 unmodified lines
333201 prefix?: string;
334202 selectedSecretId: string | null;
335203 onSelect: (secretId: string) => void;
336− existingSecrets: readonly SecretEntry[];
204+ existingSecrets: readonly SecretPickerSecret[];
337205 }) {
338206 const [creating, setCreating] = useState(false);
339207 const { headerName, prefix, selectedSecretId, onSelect, existingSecrets } = props;
@@ -419,7 +287,7 @@ function CustomHeaderRow(props: { · 79 unmodified lines
419287 onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void;
420288 onSelectSecret: (secretId: string) => void;
421289 onRemove: () => void;
422− existingSecrets: readonly SecretEntry[];
290+ existingSecrets: readonly SecretPickerSecret[];
423291 }) {
424292 const [creating, setCreating] = useState(false);
425293 const { name, prefix, presetKey, secretId, onChange, onSelectSecret, onRemove, existingSecrets } = props;
@@ -568,9 +436,9 @@ export default function AddOpenApiSource(props: { · 142 unmodified lines
568436 const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });
569437 const secrets = useAtomValue(secretsAtom());
570438
571− const secretList: readonly SecretEntry[] = Result.match(secrets, {
572− onInitial: () => [] as SecretEntry[],
573− onFailure: () => [] as SecretEntry[],
439+ const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {
440+ onInitial: () => [] as SecretPickerSecret[],
441+ onFailure: () => [] as SecretPickerSecret[],
574442 onSuccess: ({ value }) =>
575443 value.map((s) => ({
576444 id: s.id,

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t