From 1372f3b235fa495d79db32e95e22861020b9ef45 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 13 May 2026 10:46:34 -0700 Subject: [PATCH 01/15] feat(sdk): add built-in core-tools plugin with agent secret-create flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduces `coreToolsPlugin` in @executor-js/sdk exposing three static tools agents can call directly: - `scopes.list` — enumerate visible scopes by name; agents pick a scope by asking the user, not by guessing or defaulting. - `secrets.list` — visible secrets as `{id, name, provider}`; values never cross the agent boundary. - `secrets.create` — pre-allocates a secret id and returns a URL to the existing /secrets web form, pre-filled with name + id. The user enters the value in the browser; the agent confirms by re-listing. The plugin auto-registers when `coreTools: { webBaseUrl }` is set on ExecutorConfig, so hosts opt in with one line. apps/local wires it to the daemon's own port — same host as the API, no separate UI server. The /secrets web side reads the prefill params via TanStack Router `validateSearch`, opens the add modal pre-filled, and locks the id via a new `initialIdOverride` prop on SecretForm. URL prefilling works fully through the dev:cli vite proxy. --- apps/local/src/server/executor.ts | 10 ++ packages/app/src/routes/secrets.tsx | 21 ++- packages/core/sdk/src/core-tools.ts | 190 +++++++++++++++++++++ packages/core/sdk/src/executor.ts | 43 ++++- packages/core/sdk/src/index.ts | 6 + packages/react/src/pages/secrets.tsx | 21 ++- packages/react/src/plugins/secret-form.tsx | 7 +- 7 files changed, 285 insertions(+), 13 deletions(-) create mode 100644 packages/core/sdk/src/core-tools.ts diff --git a/apps/local/src/server/executor.ts b/apps/local/src/server/executor.ts index 363404ebc..3d9675813 100644 --- a/apps/local/src/server/executor.ts +++ b/apps/local/src/server/executor.ts @@ -653,6 +653,16 @@ const createLocalExecutorLayer = () => { plugins, onElicitation: "accept-all", oauthEndpointUrlPolicy: { allowHttp: true }, + // Built-in agent-facing tools (scopes.list, secrets.list, + // secrets.create). webBaseUrl is where the executor's web UI + // listens — same port as the daemon API since the daemon serves + // both. Mirrors serve.ts's port resolution so a custom $PORT + // flows through. EXECUTOR_WEB_BASE_URL overrides entirely for + // deployments where the UI is on a different host. + coreTools: { + webBaseUrl: + process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`, + }, }); return { executor, plugins }; diff --git a/packages/app/src/routes/secrets.tsx b/packages/app/src/routes/secrets.tsx index 0b69f0b50..a8b7cc00d 100644 --- a/packages/app/src/routes/secrets.tsx +++ b/packages/app/src/routes/secrets.tsx @@ -1,6 +1,25 @@ +import { Schema } from "effect"; import { createFileRoute } from "@tanstack/react-router"; import { SecretsPage } from "@executor-js/react/pages/secrets"; +// Query params supported by the agent-facing `secrets.create` static tool: +// it builds a URL like `/secrets?name=…&scope=…&secretId=…` and hands +// it to the user. The page opens the add modal pre-filled when any +// prefill field is present so the user only has to type the value. +const SearchParams = Schema.toStandardSchemaV1( + Schema.Struct({ + name: Schema.optional(Schema.String), + secretId: Schema.optional(Schema.String), + provider: Schema.optional(Schema.String), + scope: Schema.optional(Schema.String), + }), +); + export const Route = createFileRoute("/secrets")({ - component: SecretsPage, + validateSearch: SearchParams, + component: () => { + const { name, secretId, provider } = Route.useSearch(); + const hasPrefill = name != null || secretId != null; + return ; + }, }); diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts new file mode 100644 index 000000000..8fb6281e4 --- /dev/null +++ b/packages/core/sdk/src/core-tools.ts @@ -0,0 +1,190 @@ +// --------------------------------------------------------------------------- +// core-tools plugin +// +// Built-in plugin that contributes agent-facing static tools for managing +// executor-level primitives (scopes, secrets). Auto-registered by +// `createExecutor`, so callers don't need to wire it in. +// +// Today's surface: +// - scopes.list — enumerate visible scopes by name +// - secrets.list — list visible secrets (collapsed across scopes) +// - secrets.create — agent supplies scope + name; tool returns a URL +// that opens the existing /secrets web page with the +// add-modal pre-filled. User enters the value in +// that form (writes via the existing secrets HTTP +// endpoint). Agent confirms by calling secrets.list. +// +// No elicitation suspension, no cross-request coordination. Works on +// Cloudflare Workers because the tool's return value is just a URL. +// +// The agent never sees plaintext secret values. The agent never picks a +// default scope on the user's behalf — every write tool requires an +// explicit scope name, and `scopes.list` exists so the agent can +// enumerate options before asking. +// --------------------------------------------------------------------------- + +import { Effect, Schema } from "effect"; + +import { definePlugin, tool } from "./plugin"; + +// --------------------------------------------------------------------------- +// Tool input/output schemas +// --------------------------------------------------------------------------- + +const ScopesListOutput = Schema.Struct({ + scopes: Schema.Array( + Schema.Struct({ + name: Schema.String, + }), + ), +}); + +const SecretsListOutput = Schema.Struct({ + secrets: Schema.Array( + Schema.Struct({ + id: Schema.String, + name: Schema.String, + provider: Schema.String, + }), + ), +}); + +const SecretsCreateInput = Schema.Struct({ + /** Display name shown in the secrets UI and used to reference this + * secret in subsequent tool calls. */ + name: Schema.String, + /** Name of the scope (from `scopes.list`) that should own this + * secret. Required — there is no default. */ + scope: Schema.String, + /** Optional provider override. If omitted, the executor picks the + * first writable provider in registration order. */ + provider: Schema.optional(Schema.String), +}); + +const SecretsCreateOutput = Schema.Struct({ + /** Pre-allocated id the secret will receive when the user submits the + * form. The agent can pass this to other tools that need a secret + * reference; it materializes in `secrets.list` once the user saves. */ + id: Schema.String, + /** URL to hand to the user. Opens the /secrets page with the add + * modal pre-filled with name, scope, and the pre-allocated id. */ + url: Schema.String, +}); + +const ScopesListOutputStd = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(ScopesListOutput), +); +const SecretsListOutputStd = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(SecretsListOutput), +); +const SecretsCreateInputStd = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(SecretsCreateInput), +); +const SecretsCreateOutputStd = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(SecretsCreateOutput), +); + +// --------------------------------------------------------------------------- +// Options +// --------------------------------------------------------------------------- + +export interface CoreToolsPluginOptions { + /** Base URL of the executor's web UI. Used to build the URL handed to + * the user for secret-value entry, e.g. `${webBaseUrl}/secrets?...`. + * If omitted, secrets.create is registered but will fail at invoke + * time — the host must supply a URL it can route back to. */ + readonly webBaseUrl?: string; +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = {}) => ({ + id: "core-tools" as const, + packageName: "@executor-js/sdk/core-tools", + storage: () => ({}), + extension: () => ({}), + + staticSources: () => [ + { + id: "core-tools", + kind: "executor", + name: "Executor", + tools: [ + tool({ + name: "scopes.list", + description: + "List the scopes visible to this executor. Use this before any tool that takes a `scope` argument so you can ask the user which scope to use.", + outputSchema: ScopesListOutputStd, + execute: (_args, { ctx }) => + Effect.succeed({ + scopes: ctx.scopes.map((s) => ({ name: s.name })), + }), + }), + + tool({ + name: "secrets.list", + description: + "List secrets visible to this executor. Returns id, display name, and provider — never values. Use the returned id when other tools ask for a secret reference.", + outputSchema: SecretsListOutputStd, + execute: (_args, { ctx }) => + Effect.gen(function* () { + const refs = yield* ctx.secrets.list(); + return { + secrets: refs.map((r) => ({ + id: r.id, + name: r.name, + provider: r.provider, + })), + }; + }), + }), + + tool({ + name: "secrets.create", + description: + "Create a new secret. Returns a URL the user should open to enter the value securely; the agent never sees plaintext. The secret materializes once the user submits the form — confirm by calling `secrets.list` and looking for the returned id.", + inputSchema: SecretsCreateInputStd, + outputSchema: SecretsCreateOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const webBaseUrl = options.webBaseUrl; + if (!webBaseUrl) { + return yield* Effect.die( + new Error( + "core-tools secrets.create requires webBaseUrl. Pass it to coreToolsPlugin({ webBaseUrl }) at executor construction.", + ), + ); + } + + const targetScope = ctx.scopes.find((s) => s.name === input.scope); + if (!targetScope) { + return yield* Effect.die( + new Error( + `secrets.create: unknown scope "${input.scope}". Call scopes.list to see valid names.`, + ), + ); + } + + const secretId = crypto.randomUUID(); + + const url = new URL(`${webBaseUrl.replace(/\/$/, "")}/secrets`); + // Page reads these and opens the add modal pre-filled. + // Final value is collected from the user and written via + // the existing /scopes/:id/secrets POST. The presence of + // `name` is the open-modal signal (no separate flag). + url.searchParams.set("scope", String(targetScope.id)); + url.searchParams.set("name", input.name); + url.searchParams.set("secretId", secretId); + if (input.provider) url.searchParams.set("provider", input.provider); + + return { id: secretId, url: url.toString() }; + }), + }), + ], + }, + ], +})); + +export default coreToolsPlugin; diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 5674d7f23..4c488ce9a 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -28,6 +28,7 @@ import { } from "./fuma-runtime"; import { makeFumaBlobStore, pluginBlobStore } from "./blob"; +import { coreToolsPlugin } from "./core-tools"; import { ConnectionProviderState, ConnectionRef, @@ -410,6 +411,20 @@ export interface ExecutorConfig collectTables(plugins), catch: (cause) => storageFailureFromUnknown("Failed to collect executor tables", cause), @@ -1051,14 +1084,6 @@ export const createExecutor = storageFailureFromUnknown("Failed to validate executor tables", cause), }); - - if (scopes.length === 0) { - return yield* new StorageError({ - message: "createExecutor requires a non-empty scopes array", - cause: undefined, - }); - } - const scopeIds = scopes.map((s) => String(s.id)); const rootDb = withQueryContext(rootDbUntyped, { allowedScopeIds: new Set(scopeIds), diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 1fc5000a8..32a7f2b3d 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -328,6 +328,12 @@ export { collectTables, } from "./executor"; +// Built-in core-tools plugin (scopes.list, secrets.list, secrets.create +// with URL elicitation). Auto-registered by createExecutor when +// `coreTools` is set on the config; also exportable for callers who +// want to register it manually. +export { coreToolsPlugin, type CoreToolsPluginOptions } from "./core-tools"; + // CLI / runtime config export { defineExecutorConfig, diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index 73ca5cece..dc07db75f 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -62,6 +62,12 @@ const isSecretInUseError = Schema.is(SecretInUseError); // state always starts fresh — no manual reset. // --------------------------------------------------------------------------- +interface SecretPrefill { + readonly name?: string; + readonly secretId?: string; + readonly provider?: string; +} + function AddSecretDialog(props: { open: boolean; onOpenChange: (v: boolean) => void; @@ -69,6 +75,7 @@ function AddSecretDialog(props: { storageOptions: readonly SecretStorageOption[]; existingSecretIds: readonly string[]; scopeId: ScopeId; + prefill?: SecretPrefill; }) { return ( @@ -79,6 +86,7 @@ function AddSecretDialog(props: { storageOptions={props.storageOptions} existingSecretIds={props.existingSecretIds} scopeId={props.scopeId} + prefill={props.prefill} onClose={() => props.onOpenChange(false)} /> )} @@ -91,13 +99,16 @@ function AddSecretDialogContent(props: { storageOptions: readonly SecretStorageOption[]; existingSecretIds: readonly string[]; scopeId: ScopeId; + prefill?: SecretPrefill; onClose: () => void; }) { - const initialProvider = props.storageOptions[0]?.value ?? "auto"; + const initialProvider = props.prefill?.provider ?? props.storageOptions[0]?.value ?? "auto"; return ( diff --git a/packages/react/src/plugins/secret-form.tsx b/packages/react/src/plugins/secret-form.tsx index 27256453d..89f40c292 100644 --- a/packages/react/src/plugins/secret-form.tsx +++ b/packages/react/src/plugins/secret-form.tsx @@ -90,6 +90,10 @@ interface SecretFormProviderProps { readonly existingSecretIds: readonly string[]; readonly suggestedName?: string; readonly fallbackId?: string; + /** Force the secret id to a pre-allocated value (e.g. when the agent's + * `secrets.create` tool generated a UUID up front and the user is + * just here to provide the value). */ + readonly initialIdOverride?: string; readonly initialProvider?: string; readonly scopeId: ScopeId; readonly onCreated: (secretId: string) => void; @@ -101,6 +105,7 @@ function SecretFormProvider(props: SecretFormProviderProps) { existingSecretIds, suggestedName = "", fallbackId = "secret", + initialIdOverride, initialProvider = "auto", scopeId, onCreated, @@ -112,7 +117,7 @@ function SecretFormProvider(props: SecretFormProviderProps) { const [state, setState] = useState(() => ({ name: suggestedName, value: "", - idOverride: null, + idOverride: initialIdOverride ?? null, provider: initialProvider, revealed: false, status: { kind: "idle" }, From f586acbe6dd2150932baf953dac1530e42795475 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sat, 16 May 2026 22:29:32 -0700 Subject: [PATCH 02/15] fix(sdk): use typed core tools errors --- packages/core/sdk/src/core-tools.ts | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 8fb6281e4..6a2294075 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -23,7 +23,7 @@ // enumerate options before asking. // --------------------------------------------------------------------------- -import { Effect, Schema } from "effect"; +import { Data, Effect, Schema } from "effect"; import { definePlugin, tool } from "./plugin"; @@ -96,6 +96,15 @@ export interface CoreToolsPluginOptions { readonly webBaseUrl?: string; } +class CoreToolsConfigurationError extends Data.TaggedError("CoreToolsConfigurationError")<{ + readonly message: string; +}> {} + +class CoreToolsScopeNotFoundError extends Data.TaggedError("CoreToolsScopeNotFoundError")<{ + readonly scope: string; + readonly message: string; +}> {} + // --------------------------------------------------------------------------- // Plugin // --------------------------------------------------------------------------- @@ -151,20 +160,18 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { Effect.gen(function* () { const webBaseUrl = options.webBaseUrl; if (!webBaseUrl) { - return yield* Effect.die( - new Error( + return yield* new CoreToolsConfigurationError({ + message: "core-tools secrets.create requires webBaseUrl. Pass it to coreToolsPlugin({ webBaseUrl }) at executor construction.", - ), - ); + }); } const targetScope = ctx.scopes.find((s) => s.name === input.scope); if (!targetScope) { - return yield* Effect.die( - new Error( - `secrets.create: unknown scope "${input.scope}". Call scopes.list to see valid names.`, - ), - ); + return yield* new CoreToolsScopeNotFoundError({ + scope: input.scope, + message: `secrets.create: unknown scope "${input.scope}". Call scopes.list to see valid names.`, + }); } const secretId = crypto.randomUUID(); From 8d4e47516050cac25fb54bd74e1cae46b3b5091a Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 22:12:00 -0700 Subject: [PATCH 03/15] feat: expand agent executor configuration tools --- .gitignore | 1 + apps/cli/src/main.ts | 54 +- apps/cloud/src/routes/secrets.tsx | 30 +- apps/cloud/src/services/executor.ts | 3 + apps/local/executor.config.ts | 5 +- apps/local/package.json | 2 +- apps/local/src/server/executor.ts | 21 +- apps/local/vite.config.ts | 2 + package.json | 3 +- packages/app/src/routes/secrets.tsx | 4 +- packages/app/vite.config.ts | 2 + packages/core/sdk/src/core-tools.ts | 938 ++++++++++++++++-- packages/core/sdk/src/executor.test.ts | 378 ++++++- packages/core/sdk/src/executor.ts | 92 +- packages/core/sdk/src/index.ts | 1 + packages/core/sdk/src/plugin.ts | 77 +- .../plugins/desktop-settings/package.json | 1 + .../desktop-settings/src/server.test.ts | 28 + .../plugins/desktop-settings/src/server.ts | 53 +- .../plugins/desktop-settings/vitest.config.ts | 7 + .../google-discovery/src/sdk/plugin.test.ts | 17 + .../google-discovery/src/sdk/plugin.ts | 174 +++- .../plugins/graphql/src/sdk/plugin.test.ts | 40 +- packages/plugins/graphql/src/sdk/plugin.ts | 54 +- packages/plugins/mcp/src/sdk/plugin.test.ts | 72 +- packages/plugins/mcp/src/sdk/plugin.ts | 211 +++- .../onepassword/src/sdk/plugin.test.ts | 33 + .../plugins/onepassword/src/sdk/plugin.ts | 126 ++- .../plugins/openapi/src/sdk/plugin.test.ts | 55 +- packages/plugins/openapi/src/sdk/plugin.ts | 76 +- .../src/components/mcp-install-card.test.ts | 14 + .../react/src/components/mcp-install-card.tsx | 11 +- packages/react/src/pages/secrets.test.ts | 18 + packages/react/src/pages/secrets.tsx | 9 +- scripts/agent-config-smoke.ts | 340 +++++++ 35 files changed, 2756 insertions(+), 196 deletions(-) create mode 100644 packages/plugins/desktop-settings/src/server.test.ts create mode 100644 packages/plugins/desktop-settings/vitest.config.ts create mode 100644 packages/react/src/pages/secrets.test.ts create mode 100644 scripts/agent-config-smoke.ts diff --git a/.gitignore b/.gitignore index 60820a211..3d826ac00 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ personal-notes/ *.har.executor executor.har .executor/ +apps/local/.executor-dev/ # desktop app build artifacts apps/desktop/resources/ diff --git a/apps/cli/src/main.ts b/apps/cli/src/main.ts index 94a248af7..33fe8519c 100644 --- a/apps/cli/src/main.ts +++ b/apps/cli/src/main.ts @@ -748,20 +748,50 @@ const withStdoutReroutedToStderr = async (body: () => Promise): Promise const runStdioMcpSession = (input: { readonly elicitationMode: "browser" | "model" }) => Effect.gen(function* () { - const executor = yield* Effect.promise(() => withStdoutReroutedToStderr(() => getExecutor())); - yield* Effect.promise(() => - runMcpStdioServer({ - executor, - codeExecutor: makeQuickJsExecutor(), - elicitationMode: - input.elicitationMode === "browser" - ? { - mode: "browser" as const, - approvalUrl: (executionId) => `/resume/${encodeURIComponent(executionId)}`, - } - : { mode: input.elicitationMode }, + const web = yield* Effect.promise(() => + withStdoutReroutedToStderr(async () => { + const host = "127.0.0.1"; + const port = await Effect.runPromise( + chooseDaemonPort({ preferredPort: DEFAULT_PORT, hostname: host }), + ); + const baseUrl = `http://localhost:${port}`; + const restoreWebBaseUrl = installDefaultExecutorWebBaseUrl(baseUrl); + + try { + const executor = await getExecutor(); + const server = await startServer({ + port, + hostname: host, + embeddedWebUI, + }); + const serverBaseUrl = `http://localhost:${server.port}`; + return { executor, server, baseUrl: serverBaseUrl, restoreWebBaseUrl }; + } catch (cause) { + restoreWebBaseUrl(); + throw cause; + } }), ); + + try { + yield* Effect.promise(() => + runMcpStdioServer({ + executor: web.executor, + codeExecutor: makeQuickJsExecutor(), + elicitationMode: + input.elicitationMode === "browser" + ? { + mode: "browser" as const, + approvalUrl: (executionId) => + `${web.baseUrl}/resume/${encodeURIComponent(executionId)}`, + } + : { mode: input.elicitationMode }, + }), + ); + } finally { + web.restoreWebBaseUrl(); + yield* Effect.promise(() => web.server.stop()); + } }); const scope = Options.string("scope").pipe( diff --git a/apps/cloud/src/routes/secrets.tsx b/apps/cloud/src/routes/secrets.tsx index aff164652..f67981f43 100644 --- a/apps/cloud/src/routes/secrets.tsx +++ b/apps/cloud/src/routes/secrets.tsx @@ -1,12 +1,28 @@ +import { Schema } from "effect"; import { createFileRoute } from "@tanstack/react-router"; import { SecretsPage } from "@executor-js/react/pages/secrets"; +const SearchParams = Schema.toStandardSchemaV1( + Schema.Struct({ + name: Schema.optional(Schema.String), + secretId: Schema.optional(Schema.String), + provider: Schema.optional(Schema.String), + scope: Schema.optional(Schema.String), + }), +); + export const Route = createFileRoute("/secrets")({ - component: () => ( - - ), + validateSearch: SearchParams, + component: () => { + const { name, secretId, provider, scope } = Route.useSearch(); + const hasPrefill = name != null || secretId != null; + return ( + + ); + }, }); diff --git a/apps/cloud/src/services/executor.ts b/apps/cloud/src/services/executor.ts index fa15ca222..049ea1444 100644 --- a/apps/cloud/src/services/executor.ts +++ b/apps/cloud/src/services/executor.ts @@ -95,5 +95,8 @@ export const createScopedExecutor = ( plugins, httpClientLayer, onElicitation: "accept-all", + coreTools: { + webBaseUrl: env.VITE_PUBLIC_SITE_URL ?? "https://executor.sh", + }, }); }); diff --git a/apps/local/executor.config.ts b/apps/local/executor.config.ts index 04b0772bd..2ab9827f4 100644 --- a/apps/local/executor.config.ts +++ b/apps/local/executor.config.ts @@ -27,6 +27,9 @@ export default defineExecutorConfig({ keychainPlugin(), fileSecretsPlugin(), onepasswordHttpPlugin(), - desktopSettingsPlugin(), + desktopSettingsPlugin({ + webBaseUrl: + process.env.EXECUTOR_WEB_BASE_URL ?? `http://localhost:${process.env.PORT ?? "4788"}`, + }), ] as const, }); diff --git a/apps/local/package.json b/apps/local/package.json index 69cd85936..a7ba87765 100644 --- a/apps/local/package.json +++ b/apps/local/package.json @@ -9,7 +9,7 @@ "scripts": { "dev": "bun run dev:proxy && bun run dev:vite", "dev:proxy": "portless proxy start --multiplex --port 1355 || true", - "dev:vite": "portless --name executor-local bunx --bun vite dev", + "dev:vite": "EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-.executor-dev} portless --name executor-local bunx --bun vite dev", "build": "turbo run build --filter @executor-js/vite-plugin && bunx --bun vite build", "start": "bun run src/serve.ts", "db:generate": "drizzle-kit generate", diff --git a/apps/local/src/server/executor.ts b/apps/local/src/server/executor.ts index 3d9675813..96cd7e5a3 100644 --- a/apps/local/src/server/executor.ts +++ b/apps/local/src/server/executor.ts @@ -252,6 +252,12 @@ const removeSqliteFileSet = (path: string) => { } }; +const removeSqliteSidecars = (path: string) => { + for (const suffix of ["-wal", "-shm"]) { + fs.rmSync(`${path}${suffix}`, { force: true }); + } +}; + const moveSqliteFileSet = (source: string, target: string) => { fs.renameSync(source, target); for (const suffix of ["-wal", "-shm"]) { @@ -335,6 +341,7 @@ const replaceSqliteFileSetWithRollback = (input: { readonly targetPath: string; }): string => { const backupPath = moveSqliteFileSetToBackup(input.sourcePath); + removeSqliteSidecars(backupPath); // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: local DB replacement must restore the original file set if the swap fails halfway try { moveSqliteFileSet(input.targetPath, input.sourcePath); @@ -401,6 +408,8 @@ const prepareLegacySqliteForFumaImport = (input: { `Skipping legacy Drizzle replay and importing the existing schema as-is.`, ); } + sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + sqlite.exec("PRAGMA journal_mode = DELETE"); return { legacySecrets: [] }; } finally { sqlite.close(); @@ -446,8 +455,10 @@ const importMissingMarkedTables = async (input: { tables: pickedTables, scopeId: input.scopeId, }); - target.sqlite.exec("PRAGMA wal_checkpoint(FULL)"); + target.sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + target.sqlite.exec("PRAGMA journal_mode = DELETE"); await target.close(); + removeSqliteSidecars(input.storage.sqlitePath); if (result.imported) { const importedTables = [ @@ -511,9 +522,11 @@ export const importLegacySqliteIfNeeded = async (options: { await withQueryContext(target.db, { allowedScopeIds: new Set([scopeId]), }).createMany("secret", createLegacySecretRows(scopeId, prepared.legacySecrets)); - target.sqlite.exec("PRAGMA wal_checkpoint(FULL)"); + target.sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + target.sqlite.exec("PRAGMA journal_mode = DELETE"); } finally { await target.close(); + removeSqliteSidecars(storage.sqlitePath); } } writeSqliteImportMarker(storage.importMarkerPath, { @@ -576,8 +589,10 @@ export const importLegacySqliteIfNeeded = async (options: { tables, scopeId, }); - target.sqlite.exec("PRAGMA wal_checkpoint(FULL)"); + target.sqlite.exec("PRAGMA wal_checkpoint(TRUNCATE)"); + target.sqlite.exec("PRAGMA journal_mode = DELETE"); await target.close(); + removeSqliteSidecars(targetPath); if (result.imported) { const backupPath = replaceSqliteFileSetWithRollback({ diff --git a/apps/local/vite.config.ts b/apps/local/vite.config.ts index 3b50e92c1..3b42a7bd2 100644 --- a/apps/local/vite.config.ts +++ b/apps/local/vite.config.ts @@ -27,6 +27,7 @@ const EXECUTOR_GITHUB_URL = ( .replace(/^git\+/, "") .replace(/\.git$/, ""); +const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); const APP_ROOT = fileURLToPath(new URL("../../packages/app/", import.meta.url)); /** @@ -113,6 +114,7 @@ export default defineConfig({ define: { "import.meta.env.VITE_APP_VERSION": JSON.stringify(EXECUTOR_VERSION), "import.meta.env.VITE_GITHUB_URL": JSON.stringify(EXECUTOR_GITHUB_URL), + "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT), "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV ?? "development"), }, resolve: { diff --git a/package.json b/package.json index 676df90c7..dbf619b51 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "scripts": { "dev": "turbo run dev --filter='!@executor-js/desktop' --filter='!@executor-js/cloud'", "dev:desktop": "turbo run dev", - "dev:cli": "EXECUTOR_DEV=1 bun run apps/cli/src/main.ts", + "dev:cli": "EXECUTOR_DEV=1 EXECUTOR_DATA_DIR=${EXECUTOR_DATA_DIR:-apps/local/.executor-dev} bun run apps/cli/src/main.ts", "test": "turbo run test", "test:release:bootstrap": "vitest run tests/release-bootstrap-smoke.test.ts", "build:packages": "bun run --filter='fumadb' build && bun run --filter='@executor-js/codemode-core' build && bun run --filter='@executor-js/runtime-quickjs' build && bun run --filter='@executor-js/sdk' build && bun run --filter='@executor-js/config' build && bun run --filter='@executor-js/execution' build && bun run --filter='@executor-js/cli' build && bun run --filter='@executor-js/plugin-*' build", @@ -44,6 +44,7 @@ "lint:fix": "oxlint -c .oxlintrc.jsonc --fix .", "docs:snippets": "bun run scripts/generate-doc-snippets.ts", "docs:smoke:install": "bun run scripts/smoke-docs-install.ts", + "smoke:agent-config": "bun run scripts/agent-config-smoke.ts", "format": "oxfmt .", "format:check": "oxfmt --check .", "pull:references": "bun run scripts/pull-references.ts", diff --git a/packages/app/src/routes/secrets.tsx b/packages/app/src/routes/secrets.tsx index a8b7cc00d..cdf46a221 100644 --- a/packages/app/src/routes/secrets.tsx +++ b/packages/app/src/routes/secrets.tsx @@ -18,8 +18,8 @@ const SearchParams = Schema.toStandardSchemaV1( export const Route = createFileRoute("/secrets")({ validateSearch: SearchParams, component: () => { - const { name, secretId, provider } = Route.useSearch(); + const { name, secretId, provider, scope } = Route.useSearch(); const hasPrefill = name != null || secretId != null; - return ; + return ; }, }); diff --git a/packages/app/vite.config.ts b/packages/app/vite.config.ts index df9d02bc5..fcff0014b 100644 --- a/packages/app/vite.config.ts +++ b/packages/app/vite.config.ts @@ -7,11 +7,13 @@ import appPlugin from "./vite"; // here unless a separate server is running. const LOCAL_CONFIG = fileURLToPath(new URL("../../apps/local/executor.config.ts", import.meta.url)); const LOCAL_JSONC = fileURLToPath(new URL("../../apps/local/executor.jsonc", import.meta.url)); +const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url)); export default defineConfig({ plugins: [appPlugin({ executorConfigPath: LOCAL_CONFIG, executorJsoncPath: LOCAL_JSONC })], define: { "import.meta.env.VITE_APP_VERSION": JSON.stringify("0.0.0-dev"), "import.meta.env.VITE_GITHUB_URL": JSON.stringify("https://github.com/RhysSullivan/executor"), + "import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD": JSON.stringify(REPO_ROOT), }, }); diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 6a2294075..f75f39156 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -1,98 +1,379 @@ // --------------------------------------------------------------------------- // core-tools plugin // -// Built-in plugin that contributes agent-facing static tools for managing -// executor-level primitives (scopes, secrets). Auto-registered by -// `createExecutor`, so callers don't need to wire it in. -// -// Today's surface: -// - scopes.list — enumerate visible scopes by name -// - secrets.list — list visible secrets (collapsed across scopes) -// - secrets.create — agent supplies scope + name; tool returns a URL -// that opens the existing /secrets web page with the -// add-modal pre-filled. User enters the value in -// that form (writes via the existing secrets HTTP -// endpoint). Agent confirms by calling secrets.list. -// -// No elicitation suspension, no cross-request coordination. Works on -// Cloudflare Workers because the tool's return value is just a URL. -// -// The agent never sees plaintext secret values. The agent never picks a -// default scope on the user's behalf — every write tool requires an -// explicit scope name, and `scopes.list` exists so the agent can -// enumerate options before asking. +// Built-in plugin that contributes agent-facing static tools for configuring +// executor-level primitives. The important boundary: sensitive values never +// travel through tool arguments. Agents create secret placeholders and OAuth +// sessions, then hand the returned browser URL to the user. // --------------------------------------------------------------------------- import { Data, Effect, Schema } from "effect"; -import { definePlugin, tool } from "./plugin"; +import { ConnectionRef } from "./connections"; +import { CredentialBindingRef, CredentialBindingValue } from "./credential-bindings"; +import { ConnectionId, ScopeId, SecretId } from "./ids"; +import { OAuthStrategy as OAuthStrategySchema } from "./oauth"; +import { definePlugin, tool, type StaticToolSchema } from "./plugin"; +import { ToolPolicyActionSchema } from "./policies"; +import { ToolResult } from "./tool-result"; +import { SourceDetectionResult } from "./types"; +import { Usage } from "./usages"; -// --------------------------------------------------------------------------- -// Tool input/output schemas -// --------------------------------------------------------------------------- +const schemaToStandard = (schema: Schema.Decoder): StaticToolSchema => + Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema< + A, + I + >; + +const UnknownRecord = Schema.Record(Schema.String, Schema.Unknown); + +const ScopeName = Schema.String; const ScopesListOutput = Schema.Struct({ scopes: Schema.Array( Schema.Struct({ + id: Schema.String, name: Schema.String, }), ), }); +const SecretRefOutput = Schema.Struct({ + id: Schema.String, + scopeId: Schema.String, + name: Schema.String, + provider: Schema.String, +}); + const SecretsListOutput = Schema.Struct({ - secrets: Schema.Array( - Schema.Struct({ - id: Schema.String, - name: Schema.String, - provider: Schema.String, - }), - ), + secrets: Schema.Array(SecretRefOutput), }); const SecretsCreateInput = Schema.Struct({ - /** Display name shown in the secrets UI and used to reference this - * secret in subsequent tool calls. */ name: Schema.String, - /** Name of the scope (from `scopes.list`) that should own this - * secret. Required — there is no default. */ - scope: Schema.String, - /** Optional provider override. If omitted, the executor picks the - * first writable provider in registration order. */ + scope: Schema.optional(ScopeName), provider: Schema.optional(Schema.String), }); const SecretsCreateOutput = Schema.Struct({ - /** Pre-allocated id the secret will receive when the user submits the - * form. The agent can pass this to other tools that need a secret - * reference; it materializes in `secrets.list` once the user saves. */ id: Schema.String, - /** URL to hand to the user. Opens the /secrets page with the add - * modal pre-filled with name, scope, and the pre-allocated id. */ url: Schema.String, }); -const ScopesListOutputStd = Schema.toStandardSchemaV1( - Schema.toStandardJSONSchemaV1(ScopesListOutput), -); -const SecretsListOutputStd = Schema.toStandardSchemaV1( - Schema.toStandardJSONSchemaV1(SecretsListOutput), -); -const SecretsCreateInputStd = Schema.toStandardSchemaV1( - Schema.toStandardJSONSchemaV1(SecretsCreateInput), -); -const SecretsCreateOutputStd = Schema.toStandardSchemaV1( - Schema.toStandardJSONSchemaV1(SecretsCreateOutput), -); +const SecretPointerInput = Schema.Struct({ + id: Schema.String, +}); -// --------------------------------------------------------------------------- -// Options -// --------------------------------------------------------------------------- +const SecretScopedPointerInput = Schema.Struct({ + id: Schema.String, + targetScope: Schema.String, +}); + +const SecretStatusOutput = Schema.Struct({ + id: Schema.String, + status: Schema.Literals(["resolved", "missing"]), +}); + +const SecretUsagesOutput = Schema.Struct({ + usages: Schema.Array(Usage), +}); + +const ProvidersOutput = Schema.Struct({ + providers: Schema.Array(Schema.String), +}); + +const RemovedOutput = Schema.Struct({ + removed: Schema.Boolean, +}); + +const RefreshedOutput = Schema.Struct({ + refreshed: Schema.Boolean, +}); + +const SourceOutput = Schema.Struct({ + id: Schema.String, + scopeId: Schema.optional(Schema.String), + kind: Schema.String, + name: Schema.String, + url: Schema.optional(Schema.String), + pluginId: Schema.String, + canRemove: Schema.Boolean, + canRefresh: Schema.Boolean, + canEdit: Schema.Boolean, + runtime: Schema.Boolean, +}); + +const SourcesListOutput = Schema.Struct({ + sources: Schema.Array(SourceOutput), +}); + +const SourcesDetectInput = Schema.Struct({ + url: Schema.String, +}); + +const SourcesDetectOutput = Schema.Struct({ + results: Schema.Array(SourceDetectionResult), +}); + +const SourcesConfigureSchemasOutput = Schema.Struct({ + schemas: Schema.Array( + Schema.Struct({ + pluginId: Schema.String, + type: Schema.String, + schema: Schema.optional(Schema.Unknown), + }), + ), +}); + +const SourcePointer = Schema.Struct({ + id: Schema.String, + scope: Schema.String, +}); + +const SourcesConfigureInput = Schema.Struct({ + source: SourcePointer, + scope: Schema.String, + type: Schema.optional(Schema.String), + config: Schema.Unknown, +}); + +const SourcesConfigureOutput = Schema.Struct({ + result: Schema.Unknown, +}); + +const SourceLifecycleInput = Schema.Struct({ + id: Schema.String, + targetScope: Schema.String, +}); + +const SourceBindingsListInput = Schema.Struct({ + source: SourcePointer, +}); + +const SourceBindingsResolveInput = Schema.Struct({ + source: SourcePointer, + slotKey: Schema.String, +}); + +const SourceBindingsSetInput = Schema.Struct({ + scope: Schema.String, + source: SourcePointer, + slotKey: Schema.String, + value: CredentialBindingValue, +}); + +const SourceBindingsRemoveInput = Schema.Struct({ + scope: Schema.String, + source: SourcePointer, + slotKey: Schema.String, +}); + +const SourceBindingsListOutput = Schema.Struct({ + bindings: Schema.Array(CredentialBindingRef), +}); + +const SourceBindingsResolveOutput = Schema.Struct({ + binding: Schema.NullOr(CredentialBindingRef), +}); + +const SourceBindingsSetOutput = Schema.Struct({ + binding: CredentialBindingRef, +}); + +const ConnectionsListOutput = Schema.Struct({ + connections: Schema.Array(ConnectionRef), +}); + +const ConnectionPointerInput = Schema.Struct({ + id: Schema.String, +}); + +const ConnectionScopedPointerInput = Schema.Struct({ + id: Schema.String, + targetScope: Schema.String, +}); + +const ConnectionUsagesOutput = Schema.Struct({ + usages: Schema.Array(Usage), +}); + +const PolicyOutput = Schema.Struct({ + id: Schema.String, + scopeId: Schema.String, + pattern: Schema.String, + action: ToolPolicyActionSchema, + position: Schema.String, + createdAt: Schema.Number, + updatedAt: Schema.Number, +}); + +const PoliciesListOutput = Schema.Struct({ + policies: Schema.Array(PolicyOutput), +}); + +const PolicyCreateInput = Schema.Struct({ + targetScope: Schema.String, + pattern: Schema.String, + action: ToolPolicyActionSchema, + position: Schema.optional(Schema.String), +}); + +const PolicyUpdateInput = Schema.Struct({ + id: Schema.String, + targetScope: Schema.String, + pattern: Schema.optional(Schema.String), + action: Schema.optional(ToolPolicyActionSchema), + position: Schema.optional(Schema.String), +}); + +const PolicyRemoveInput = Schema.Struct({ + id: Schema.String, + targetScope: Schema.String, +}); + +const PolicyMutationOutput = Schema.Struct({ + policy: PolicyOutput, +}); + +const OAuthProbeInput = Schema.Struct({ + endpoint: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}); + +const OAuthProbeOutput = Schema.Struct({ + resourceMetadata: Schema.NullOr(UnknownRecord), + resourceMetadataUrl: Schema.NullOr(Schema.String), + authorizationServerMetadata: Schema.NullOr(UnknownRecord), + authorizationServerMetadataUrl: Schema.NullOr(Schema.String), + authorizationServerUrl: Schema.NullOr(Schema.String), + supportsDynamicRegistration: Schema.Boolean, + isBearerChallengeEndpoint: Schema.Boolean, +}); + +const OAuthStartInput = Schema.Struct({ + scope: Schema.String, + endpoint: Schema.String, + connectionId: Schema.String, + pluginId: Schema.String, + identityLabel: Schema.optional(Schema.String), + redirectUrl: Schema.optional(Schema.String), + headers: Schema.optional(Schema.Record(Schema.String, Schema.String)), + queryParams: Schema.optional(Schema.Record(Schema.String, Schema.String)), + strategy: OAuthStrategySchema, +}); + +const OAuthStartOutput = Schema.Struct({ + sessionId: Schema.String, + authorizationUrl: Schema.NullOr(Schema.String), + completedConnection: Schema.NullOr(Schema.Struct({ connectionId: Schema.String })), +}); + +const OAuthCancelInput = Schema.Struct({ + scope: Schema.String, + sessionId: Schema.String, +}); + +const OAuthCancelOutput = Schema.Struct({ + cancelled: Schema.Boolean, +}); + +const ScopesListOutputStd = schemaToStandard(ScopesListOutput); +const SecretsListOutputStd = schemaToStandard(SecretsListOutput); +const SecretsCreateInputStd = schemaToStandard< + typeof SecretsCreateInput.Type, + typeof SecretsCreateInput.Encoded +>(SecretsCreateInput); +const SecretsCreateOutputStd = schemaToStandard(SecretsCreateOutput); +const SecretPointerInputStd = schemaToStandard< + typeof SecretPointerInput.Type, + typeof SecretPointerInput.Encoded +>(SecretPointerInput); +const SecretScopedPointerInputStd = schemaToStandard< + typeof SecretScopedPointerInput.Type, + typeof SecretScopedPointerInput.Encoded +>(SecretScopedPointerInput); +const SecretStatusOutputStd = schemaToStandard(SecretStatusOutput); +const SecretUsagesOutputStd = schemaToStandard(SecretUsagesOutput); +const ProvidersOutputStd = schemaToStandard(ProvidersOutput); +const RemovedOutputStd = schemaToStandard(RemovedOutput); +const RefreshedOutputStd = schemaToStandard(RefreshedOutput); +const SourcesListOutputStd = schemaToStandard(SourcesListOutput); +const SourcesDetectInputStd = schemaToStandard< + typeof SourcesDetectInput.Type, + typeof SourcesDetectInput.Encoded +>(SourcesDetectInput); +const SourcesDetectOutputStd = schemaToStandard(SourcesDetectOutput); +const SourcesConfigureSchemasOutputStd = schemaToStandard(SourcesConfigureSchemasOutput); +const SourcesConfigureInputStd = schemaToStandard< + typeof SourcesConfigureInput.Type, + typeof SourcesConfigureInput.Encoded +>(SourcesConfigureInput); +const SourcesConfigureOutputStd = schemaToStandard(SourcesConfigureOutput); +const SourceLifecycleInputStd = schemaToStandard< + typeof SourceLifecycleInput.Type, + typeof SourceLifecycleInput.Encoded +>(SourceLifecycleInput); +const SourceBindingsListInputStd = schemaToStandard< + typeof SourceBindingsListInput.Type, + typeof SourceBindingsListInput.Encoded +>(SourceBindingsListInput); +const SourceBindingsResolveInputStd = schemaToStandard< + typeof SourceBindingsResolveInput.Type, + typeof SourceBindingsResolveInput.Encoded +>(SourceBindingsResolveInput); +const SourceBindingsSetInputStd = schemaToStandard< + typeof SourceBindingsSetInput.Type, + typeof SourceBindingsSetInput.Encoded +>(SourceBindingsSetInput); +const SourceBindingsRemoveInputStd = schemaToStandard< + typeof SourceBindingsRemoveInput.Type, + typeof SourceBindingsRemoveInput.Encoded +>(SourceBindingsRemoveInput); +const SourceBindingsListOutputStd = schemaToStandard(SourceBindingsListOutput); +const SourceBindingsResolveOutputStd = schemaToStandard(SourceBindingsResolveOutput); +const SourceBindingsSetOutputStd = schemaToStandard(SourceBindingsSetOutput); +const ConnectionsListOutputStd = schemaToStandard(ConnectionsListOutput); +const ConnectionPointerInputStd = schemaToStandard< + typeof ConnectionPointerInput.Type, + typeof ConnectionPointerInput.Encoded +>(ConnectionPointerInput); +const ConnectionScopedPointerInputStd = schemaToStandard< + typeof ConnectionScopedPointerInput.Type, + typeof ConnectionScopedPointerInput.Encoded +>(ConnectionScopedPointerInput); +const ConnectionUsagesOutputStd = schemaToStandard(ConnectionUsagesOutput); +const PoliciesListOutputStd = schemaToStandard(PoliciesListOutput); +const PolicyCreateInputStd = schemaToStandard< + typeof PolicyCreateInput.Type, + typeof PolicyCreateInput.Encoded +>(PolicyCreateInput); +const PolicyUpdateInputStd = schemaToStandard< + typeof PolicyUpdateInput.Type, + typeof PolicyUpdateInput.Encoded +>(PolicyUpdateInput); +const PolicyRemoveInputStd = schemaToStandard< + typeof PolicyRemoveInput.Type, + typeof PolicyRemoveInput.Encoded +>(PolicyRemoveInput); +const PolicyMutationOutputStd = schemaToStandard(PolicyMutationOutput); +const OAuthProbeInputStd = schemaToStandard< + typeof OAuthProbeInput.Type, + typeof OAuthProbeInput.Encoded +>(OAuthProbeInput); +const OAuthProbeOutputStd = schemaToStandard(OAuthProbeOutput); +const OAuthStartInputStd = schemaToStandard< + typeof OAuthStartInput.Type, + typeof OAuthStartInput.Encoded +>(OAuthStartInput); +const OAuthStartOutputStd = schemaToStandard(OAuthStartOutput); +const OAuthCancelInputStd = schemaToStandard< + typeof OAuthCancelInput.Type, + typeof OAuthCancelInput.Encoded +>(OAuthCancelInput); +const OAuthCancelOutputStd = schemaToStandard(OAuthCancelOutput); export interface CoreToolsPluginOptions { - /** Base URL of the executor's web UI. Used to build the URL handed to - * the user for secret-value entry, e.g. `${webBaseUrl}/secrets?...`. - * If omitted, secrets.create is registered but will fail at invoke - * time — the host must supply a URL it can route back to. */ readonly webBaseUrl?: string; } @@ -105,9 +386,93 @@ class CoreToolsScopeNotFoundError extends Data.TaggedError("CoreToolsScopeNotFou readonly message: string; }> {} -// --------------------------------------------------------------------------- -// Plugin -// --------------------------------------------------------------------------- +const findScopeByNameOrId = ( + scopes: readonly { readonly id: ScopeId; readonly name: string }[], + value: string, +) => scopes.find((scope) => scope.name === value || String(scope.id) === value); + +const resolveScopeInput = ( + scopes: readonly { readonly id: ScopeId; readonly name: string }[], + value: string | undefined, +) => { + if (value === undefined) { + const [onlyScope] = scopes; + return onlyScope && scopes.length === 1 + ? Effect.succeed(String(onlyScope.id)) + : Effect.fail( + new CoreToolsScopeNotFoundError({ + scope: "", + message: + scopes.length === 0 + ? "No visible scopes are available." + : "Multiple scopes are visible. Call scopes.list and pass the target scope id or name.", + }), + ); + } + + const scope = findScopeByNameOrId(scopes, value); + return scope + ? Effect.succeed(String(scope.id)) + : Effect.fail( + new CoreToolsScopeNotFoundError({ + scope: value, + message: `Unknown scope "${value}". Call scopes.list to see valid scope ids and names.`, + }), + ); +}; + +const normalizeCredentialBindingValue = ( + value: typeof CredentialBindingValue.Encoded, +): CredentialBindingValue => { + if (value.kind === "text") { + return value; + } + if (value.kind === "secret") { + return { + kind: "secret", + secretId: SecretId.make(value.secretId), + ...(value.secretScopeId ? { secretScopeId: ScopeId.make(value.secretScopeId) } : {}), + }; + } + return { + kind: "connection", + connectionId: ConnectionId.make(value.connectionId), + }; +}; + +const oauthToolFailure = (code: string, message: string, details?: unknown) => + ToolResult.fail({ + code, + message, + ...(details === undefined ? {} : { details }), + }); + +const requireWebBaseUrl = (value: string | undefined) => + value + ? Effect.succeed(value.replace(/\/$/, "")) + : Effect.fail( + new CoreToolsConfigurationError({ + message: "This executor did not provide a webBaseUrl for browser handoff flows.", + }), + ); + +const policyOutput = (policy: { + readonly id: string; + readonly scopeId: string; + readonly pattern: string; + readonly action: typeof ToolPolicyActionSchema.Type; + readonly position: string; + readonly createdAt: Date; + readonly updatedAt: Date; +}) => ({ + id: String(policy.id), + scopeId: String(policy.scopeId), + pattern: policy.pattern, + action: policy.action, + position: policy.position, + createdAt: policy.createdAt.getTime(), + updatedAt: policy.updatedAt.getTime(), +}); export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = {}) => ({ id: "core-tools" as const, @@ -117,78 +482,441 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { staticSources: () => [ { - id: "core-tools", + id: "coreTools", kind: "executor", name: "Executor", tools: [ tool({ name: "scopes.list", description: - "List the scopes visible to this executor. Use this before any tool that takes a `scope` argument so you can ask the user which scope to use.", + "List visible executor scopes. Call this before write tools when more than one scope is visible; single-scope local executors can usually omit scope inputs.", outputSchema: ScopesListOutputStd, execute: (_args, { ctx }) => Effect.succeed({ - scopes: ctx.scopes.map((s) => ({ name: s.name })), + scopes: ctx.scopes.map((s) => ({ id: String(s.id), name: s.name })), }), }), - tool({ name: "secrets.list", description: - "List secrets visible to this executor. Returns id, display name, and provider — never values. Use the returned id when other tools ask for a secret reference.", + "List visible secrets by id, name, and provider. This never returns values. Use returned ids in source configuration or OAuth client credential strategies.", outputSchema: SecretsListOutputStd, execute: (_args, { ctx }) => - Effect.gen(function* () { - const refs = yield* ctx.secrets.list(); - return { - secrets: refs.map((r) => ({ - id: r.id, - name: r.name, - provider: r.provider, - })), - }; - }), + Effect.map(ctx.secrets.list(), (refs) => ({ + secrets: refs.map((r) => ({ + id: String(r.id), + scopeId: String(r.scopeId), + name: r.name, + provider: r.provider, + })), + })), }), - tool({ name: "secrets.create", description: - "Create a new secret. Returns a URL the user should open to enter the value securely; the agent never sees plaintext. The secret materializes once the user submits the form — confirm by calling `secrets.list` and looking for the returned id.", + "Create a secret placeholder and return a browser URL for the user to enter the sensitive value. Never ask the user to paste passwords, tokens, client secrets, or API keys into chat. In a single-scope local executor, omit `scope`; otherwise call `scopes.list` and pass the target scope id or name. After the user saves, call `secrets.status` for this id, then pass the secret id to `sources.configure` or `oauth.start`.", inputSchema: SecretsCreateInputStd, outputSchema: SecretsCreateOutputStd, execute: (input, { ctx }) => Effect.gen(function* () { - const webBaseUrl = options.webBaseUrl; - if (!webBaseUrl) { - return yield* new CoreToolsConfigurationError({ - message: - "core-tools secrets.create requires webBaseUrl. Pass it to coreToolsPlugin({ webBaseUrl }) at executor construction.", - }); - } - - const targetScope = ctx.scopes.find((s) => s.name === input.scope); - if (!targetScope) { - return yield* new CoreToolsScopeNotFoundError({ - scope: input.scope, - message: `secrets.create: unknown scope "${input.scope}". Call scopes.list to see valid names.`, - }); - } + const webBaseUrl = yield* requireWebBaseUrl(options.webBaseUrl); + const targetScope = yield* resolveScopeInput(ctx.scopes, input.scope); const secretId = crypto.randomUUID(); - - const url = new URL(`${webBaseUrl.replace(/\/$/, "")}/secrets`); - // Page reads these and opens the add modal pre-filled. - // Final value is collected from the user and written via - // the existing /scopes/:id/secrets POST. The presence of - // `name` is the open-modal signal (no separate flag). - url.searchParams.set("scope", String(targetScope.id)); + const url = new URL(`${webBaseUrl}/secrets`); + url.searchParams.set("scope", targetScope); url.searchParams.set("name", input.name); url.searchParams.set("secretId", secretId); if (input.provider) url.searchParams.set("provider", input.provider); - return { id: secretId, url: url.toString() }; }), }), + tool({ + name: "secrets.status", + description: + "Check whether a user-visible secret id has a backing value without revealing that value. Use this after a browser handoff from `secrets.create` before wiring the secret into a source.", + inputSchema: SecretPointerInputStd, + outputSchema: SecretStatusOutputStd, + execute: (input, { ctx }) => + Effect.map(ctx.secrets.status(input.id), (status) => ({ id: input.id, status })), + }), + tool({ + name: "secrets.usages", + description: + "List sources and credential slots that reference a secret. Call this before removing a secret so the user can detach it first if needed.", + inputSchema: SecretPointerInputStd, + outputSchema: SecretUsagesOutputStd, + execute: (input, { ctx }) => + Effect.map(ctx.secrets.usages(input.id), (usages) => ({ usages })), + }), + tool({ + name: "secrets.providers", + description: + "List registered secret storage providers. Use this to choose the optional provider for `secrets.create`; sensitive values still must be entered through the returned browser URL.", + outputSchema: ProvidersOutputStd, + execute: (_args, { ctx }) => + Effect.map(ctx.secrets.providers(), (providers) => ({ providers })), + }), + tool({ + name: "secrets.remove", + description: + "Remove a user-visible secret from a target scope. Call `secrets.usages` first; removal is refused while sources still reference the secret. Connection-owned token secrets cannot be removed here; remove the connection instead.", + annotations: { + requiresApproval: true, + approvalDescription: "Remove an Executor secret", + }, + inputSchema: SecretScopedPointerInputStd, + outputSchema: RemovedOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); + return yield* Effect.as( + ctx.secrets.remove({ + id: SecretId.make(input.id), + targetScope: ScopeId.make(targetScope), + }), + { removed: true }, + ); + }), + }), + tool({ + name: "sources.list", + description: + "List configured and built-in sources. Use this to find source ids/scopes before calling `sources.configure`, `sources.bindings.*`, refresh, remove, or tool discovery.", + outputSchema: SourcesListOutputStd, + execute: (_args, { ctx }) => + Effect.map(ctx.core.sources.list(), (sources) => ({ sources })), + }), + tool({ + name: "sources.detect", + description: + "Detect which plugin can add or configure a URL. Use this when the user gives a URL but not a source type; then call the matching plugin add tool such as `openapi.previewSpec` + `openapi.addSource`, `graphql.addSource`, or `mcp.addSource`.", + inputSchema: SourcesDetectInputStd, + outputSchema: SourcesDetectOutputStd, + execute: (input, { ctx }) => + Effect.map(ctx.core.sources.detect(input.url), (results) => ({ results })), + }), + tool({ + name: "sources.configureSchemas", + description: + "Return the plugin-specific payload schemas accepted by `sources.configure`. Use this before configuring a source unless you already know the plugin flow. The `type` field should be passed back to `sources.configure`.", + outputSchema: SourcesConfigureSchemasOutputStd, + execute: (_args, { ctx }) => + Effect.succeed({ schemas: ctx.core.sources.configureSchemas() }), + }), + tool({ + name: "sources.configure", + description: + 'Configure an existing source through its owning plugin. Use `sources.list` to get source id/scope, `sources.configureSchemas` to inspect the config schema, and `secrets.create`/`oauth.start` first for sensitive inputs. Pass secret refs as `{kind:"secret", secretId}` and OAuth connections as `{kind:"connection", connectionId}` when the plugin schema supports them.', + annotations: { + requiresApproval: true, + approvalDescription: "Configure an Executor source", + }, + inputSchema: SourcesConfigureInputStd, + outputSchema: SourcesConfigureOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const sourceScope = yield* resolveScopeInput(ctx.scopes, input.source.scope); + const targetScope = yield* resolveScopeInput(ctx.scopes, input.scope); + const result = yield* ctx.core.sources.configure({ + ...input, + source: { ...input.source, scope: sourceScope }, + scope: targetScope, + }); + return { result }; + }), + }), + tool({ + name: "sources.refresh", + description: + "Refresh a configurable source's registered tools from its backing spec/server. Use `sources.list` first to get the source id and owning scope, then refresh the owning scope.", + annotations: { + requiresApproval: true, + approvalDescription: "Refresh an Executor source", + }, + inputSchema: SourceLifecycleInputStd, + outputSchema: RefreshedOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); + return yield* Effect.as(ctx.core.sources.refresh({ ...input, targetScope }), { + refreshed: true, + }); + }), + }), + tool({ + name: "sources.remove", + description: + "Remove a configurable source and its registered tools from a target scope. Use `sources.list` and, when credentials are involved, `sources.bindings.list` first so the user can confirm exactly what will be removed.", + annotations: { + requiresApproval: true, + approvalDescription: "Remove an Executor source", + }, + inputSchema: SourceLifecycleInputStd, + outputSchema: RemovedOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); + return yield* Effect.as(ctx.core.sources.remove({ ...input, targetScope }), { + removed: true, + }); + }), + }), + tool({ + name: "sources.bindings.list", + description: + "List credential bindings for a source. Use this to verify that secrets or OAuth connections were bound after `sources.configure`.", + inputSchema: SourceBindingsListInputStd, + outputSchema: SourceBindingsListOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const sourceScope = yield* resolveScopeInput(ctx.scopes, input.source.scope); + const bindings = yield* ctx.core.sources.listBindings({ + source: { id: input.source.id, scope: ScopeId.make(sourceScope) }, + }); + return { bindings }; + }), + }), + tool({ + name: "sources.bindings.resolve", + description: + "Resolve the effective credential binding for one source slot, accounting for scope shadowing. Values are references only; plaintext is never returned.", + inputSchema: SourceBindingsResolveInputStd, + outputSchema: SourceBindingsResolveOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const sourceScope = yield* resolveScopeInput(ctx.scopes, input.source.scope); + const binding = yield* ctx.core.sources.resolveBinding({ + source: { id: input.source.id, scope: ScopeId.make(sourceScope) }, + slotKey: input.slotKey, + }); + return { binding }; + }), + }), + tool({ + name: "sources.bindings.set", + description: + "Set one credential binding for a source slot. Prefer `sources.configure` for normal flows because plugin schemas name the right slots. Use this low-level tool only when a plugin or status output has given an exact slot key.", + annotations: { + requiresApproval: true, + approvalDescription: "Set a source credential binding", + }, + inputSchema: SourceBindingsSetInputStd, + outputSchema: SourceBindingsSetOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const scope = yield* resolveScopeInput(ctx.scopes, input.scope); + const sourceScope = yield* resolveScopeInput(ctx.scopes, input.source.scope); + const binding = yield* ctx.core.sources.setBinding({ + scope: ScopeId.make(scope), + source: { id: input.source.id, scope: ScopeId.make(sourceScope) }, + slotKey: input.slotKey, + value: normalizeCredentialBindingValue(input.value), + }); + return { binding }; + }), + }), + tool({ + name: "sources.bindings.remove", + description: + "Remove one credential binding from a source slot at a target scope. Use `sources.bindings.list` first so the user can confirm the exact binding being removed.", + annotations: { + requiresApproval: true, + approvalDescription: "Remove a source credential binding", + }, + inputSchema: SourceBindingsRemoveInputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const scope = yield* resolveScopeInput(ctx.scopes, input.scope); + const sourceScope = yield* resolveScopeInput(ctx.scopes, input.source.scope); + return yield* Effect.asVoid( + ctx.core.sources.removeBinding({ + scope: ScopeId.make(scope), + source: { id: input.source.id, scope: ScopeId.make(sourceScope) }, + slotKey: input.slotKey, + }), + ); + }), + }), + tool({ + name: "connections.list", + description: + "List OAuth/sign-in connections. This returns metadata and token secret ids, never token values. Use it to verify that `oauth.start` completed, then bind the connection id with `sources.configure`.", + outputSchema: ConnectionsListOutputStd, + execute: (_args, { ctx }) => + Effect.map(ctx.connections.list(), (connections) => ({ connections })), + }), + tool({ + name: "connections.usages", + description: + "List sources and credential slots that reference an OAuth/sign-in connection. Call this before removing a connection so the user can detach it first if needed.", + inputSchema: ConnectionPointerInputStd, + outputSchema: ConnectionUsagesOutputStd, + execute: (input, { ctx }) => + Effect.map(ctx.connections.usages(input.id), (usages) => ({ usages })), + }), + tool({ + name: "connections.providers", + description: + "List registered connection providers. Use this to understand which OAuth/sign-in connection kinds this executor can mint and refresh.", + outputSchema: ProvidersOutputStd, + execute: (_args, { ctx }) => + Effect.map(ctx.connections.providers(), (providers) => ({ providers })), + }), + tool({ + name: "connections.remove", + description: + "Remove an OAuth/sign-in connection and its owned token secrets from a target scope. Call `connections.usages` first; removal is refused while sources still reference the connection.", + annotations: { + requiresApproval: true, + approvalDescription: "Remove an Executor connection", + }, + inputSchema: ConnectionScopedPointerInputStd, + outputSchema: RemovedOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); + return yield* Effect.as( + ctx.connections.remove({ + id: ConnectionId.make(input.id), + targetScope: ScopeId.make(targetScope), + }), + { removed: true }, + ); + }), + }), + tool({ + name: "policies.list", + description: + "List tool approval policies visible to this executor, sorted in evaluation order. Use this before creating, updating, reordering, or removing policies.", + outputSchema: PoliciesListOutputStd, + execute: (_args, { ctx }) => + Effect.map(ctx.core.policies.list(), (policies) => ({ + policies: policies.map(policyOutput), + })), + }), + tool({ + name: "policies.create", + description: + 'Create a tool approval policy. Patterns are exact tool ids, a trailing wildcard such as `executor.openapi.*`, or `*`. Actions are `"approve"`, `"require_approval"`, or `"block"`. Omit `position` to place the policy at the top of the target scope.', + annotations: { + requiresApproval: true, + approvalDescription: "Create an Executor tool policy", + }, + inputSchema: PolicyCreateInputStd, + outputSchema: PolicyMutationOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); + const policy = yield* ctx.core.policies.create({ ...input, targetScope }); + return { policy: policyOutput(policy) }; + }), + }), + tool({ + name: "policies.update", + description: + "Update or reorder an approval policy. Use `policies.list` first; preserve fields you are not changing, and use the listed `position` values when computing a new order.", + annotations: { + requiresApproval: true, + approvalDescription: "Update an Executor tool policy", + }, + inputSchema: PolicyUpdateInputStd, + outputSchema: PolicyMutationOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); + const policy = yield* ctx.core.policies.update({ ...input, targetScope }); + return { policy: policyOutput(policy) }; + }), + }), + tool({ + name: "policies.remove", + description: + "Remove an approval policy from a target scope. Use `policies.list` first so the user can confirm the exact rule id and pattern.", + annotations: { + requiresApproval: true, + approvalDescription: "Remove an Executor tool policy", + }, + inputSchema: PolicyRemoveInputStd, + outputSchema: RemovedOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const targetScope = yield* resolveScopeInput(ctx.scopes, input.targetScope); + return yield* Effect.as(ctx.core.policies.remove({ ...input, targetScope }), { + removed: true, + }); + }), + }), + tool({ + name: "oauth.probe", + description: + 'Probe an OAuth-protected endpoint before starting OAuth. For dynamic MCP-style OAuth, call this first; if `supportsDynamicRegistration` is true, call `oauth.start` with strategy `{kind:"dynamic-dcr"}`. If false, create client id/secret secrets in the browser and use an `authorization-code` strategy.', + inputSchema: OAuthProbeInputStd, + outputSchema: OAuthProbeOutputStd, + execute: (input, { ctx }) => + ctx.oauth + .probe(input) + .pipe( + Effect.catchTag("OAuthProbeError", ({ message }) => + Effect.succeed(oauthToolFailure("oauth_probe_failed", message)), + ), + ), + }), + tool({ + name: "oauth.start", + description: + "Start an OAuth flow and return the authorization URL the user must open in a browser. Never put OAuth passwords, authorization codes, or client secrets in chat. For confidential clients, first call `secrets.create` for client id/secret and pass those secret ids in the strategy. After the browser callback completes, call `connections.list`, then configure the source with the returned connection id.", + inputSchema: OAuthStartInputStd, + outputSchema: OAuthStartOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const webBaseUrl = yield* requireWebBaseUrl(options.webBaseUrl); + const tokenScope = yield* resolveScopeInput(ctx.scopes, input.scope); + return yield* ctx.oauth.start({ + endpoint: input.endpoint, + headers: input.headers, + queryParams: input.queryParams, + redirectUrl: input.redirectUrl ?? `${webBaseUrl}/api/oauth/callback`, + connectionId: input.connectionId, + tokenScope, + strategy: input.strategy, + pluginId: input.pluginId, + identityLabel: input.identityLabel, + }); + }).pipe( + Effect.catchTags({ + CoreToolsConfigurationError: ({ message }) => + Effect.succeed(oauthToolFailure("oauth_start_not_configured", message)), + CoreToolsScopeNotFoundError: ({ message, scope }) => + Effect.succeed(oauthToolFailure("scope_not_found", message, { scope })), + OAuthStartError: ({ message, error, errorDescription }) => + Effect.succeed( + oauthToolFailure("oauth_start_failed", message, { + ...(error ? { error } : {}), + ...(errorDescription ? { errorDescription } : {}), + }), + ), + }), + ), + }), + tool({ + name: "oauth.cancel", + description: + "Cancel a pending OAuth browser handoff if the user declines or the wrong flow was started.", + inputSchema: OAuthCancelInputStd, + outputSchema: OAuthCancelOutputStd, + execute: (input, { ctx }) => + Effect.gen(function* () { + const scope = yield* resolveScopeInput(ctx.scopes, input.scope); + return yield* Effect.as(ctx.oauth.cancel(input.sessionId, scope), { + cancelled: true, + }); + }).pipe( + Effect.catchTag("CoreToolsScopeNotFoundError", ({ message, scope }) => + Effect.succeed(oauthToolFailure("scope_not_found", message, { scope })), + ), + ), + }), ], }, ], diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index f36055a37..3024dccf2 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -6,11 +6,16 @@ import { scopedExecutorTable, textColumn } from "./core-schema"; import { ElicitationResponse } from "./elicitation"; import { ToolNotFoundError } from "./errors"; import { createExecutor } from "./executor"; -import { ScopeId } from "./ids"; +import { ScopeId, SecretId } from "./ids"; import { definePlugin } from "./plugin"; import { Scope } from "./scope"; import { SourceDetectionResult } from "./types"; -import { makeTestConfig, makeTestExecutor } from "./testing"; +import { + makeTestConfig, + makeTestExecutor, + memorySecretsPlugin, + serveOAuthTestServer, +} from "./testing"; class TestPluginError extends Data.TaggedError("TestPluginError")<{ readonly message: string; @@ -473,4 +478,373 @@ describe("createExecutor", () => { expect(visibleConfig?.data).toEqual({ header: "user-token", sourceScope: "org" }); }), ); + + it.effect("core tools configure sources through agent-visible tool calls", () => + Effect.gen(function* () { + const orgScope = Scope.make({ + id: ScopeId.make("org"), + name: "Org", + createdAt: new Date(), + }); + const userScope = Scope.make({ + id: ScopeId.make("user"), + name: "User", + createdAt: new Date(), + }); + const config = makeTestConfig({ + scopes: [userScope, orgScope], + plugins: [configurableSourcePlugin] as const, + }); + const executor = yield* createExecutor({ + ...config, + coreTools: { webBaseUrl: "http://executor.test" }, + }); + + yield* executor.configurable.registerSource("org"); + + const schemas = yield* executor.tools.invoke( + "executor.coreTools.sources.configureSchemas", + {}, + ); + expect(schemas).toMatchObject({ + schemas: expect.arrayContaining([ + expect.objectContaining({ pluginId: "configurable", type: "configurable" }), + ]), + }); + + yield* executor.tools.invoke("executor.coreTools.sources.configure", { + source: { id: "configured-source", scope: "org" }, + scope: "user", + type: "configurable", + config: { header: "agent-token" }, + }); + + const visibleConfig = yield* executor.configurable.getVisibleConfig(); + expect(visibleConfig?.data).toEqual({ header: "agent-token", sourceScope: "org" }); + + yield* executor.close(); + yield* Effect.promise(() => config.testDb.close()); + }), + ); + + it.effect("core tools generate browser handoff URLs for secret values", () => + Effect.gen(function* () { + const config = makeTestConfig({ plugins: [memorySecretsPlugin()] as const }); + const executor = yield* createExecutor({ + ...config, + coreTools: { webBaseUrl: "http://executor.test" }, + }); + + const result = yield* executor.tools.invoke("executor.coreTools.secrets.create", { + name: "api-token", + provider: "memory", + }); + + expect(result).toMatchObject({ id: expect.any(String), url: expect.any(String) }); + const url = new URL((result as { readonly url: string }).url); + expect(url.origin).toBe("http://executor.test"); + expect(url.pathname).toBe("/secrets"); + expect(url.searchParams.get("scope")).toBe("test-scope"); + expect(url.searchParams.get("name")).toBe("api-token"); + expect(url.searchParams.get("provider")).toBe("memory"); + expect(url.searchParams.get("secretId")).toBe((result as { readonly id: string }).id); + + const idResult = yield* executor.tools.invoke("executor.coreTools.secrets.create", { + scope: "test-scope", + name: "api-token-by-id", + provider: "memory", + }); + const idUrl = new URL((idResult as { readonly url: string }).url); + expect(idUrl.searchParams.get("scope")).toBe("test-scope"); + expect(idUrl.searchParams.get("name")).toBe("api-token-by-id"); + + yield* executor.close(); + yield* Effect.promise(() => config.testDb.close()); + }), + ); + + it.effect("core tools require an explicit secret scope when multiple scopes are visible", () => + Effect.gen(function* () { + const orgScope = Scope.make({ + id: ScopeId.make("org"), + name: "Org", + createdAt: new Date(), + }); + const userScope = Scope.make({ + id: ScopeId.make("user"), + name: "User", + createdAt: new Date(), + }); + const config = makeTestConfig({ + scopes: [userScope, orgScope], + plugins: [memorySecretsPlugin()] as const, + }); + const executor = yield* createExecutor({ + ...config, + coreTools: { webBaseUrl: "http://executor.test" }, + }); + + const error = yield* executor.tools + .invoke("executor.coreTools.secrets.create", { + name: "api-token", + }) + .pipe(Effect.flip); + + expect(error).toMatchObject({ + message: + "Multiple scopes are visible. Call scopes.list and pass the target scope id or name.", + }); + + yield* executor.close(); + yield* Effect.promise(() => config.testDb.close()); + }), + ); + + it.effect("core tools cover web UI source secret and policy management flows", () => + Effect.gen(function* () { + const config = makeTestConfig({ + plugins: [memorySecretsPlugin(), configurableSourcePlugin] as const, + }); + const executor = yield* createExecutor({ + ...config, + coreTools: { webBaseUrl: "http://executor.test" }, + }); + + yield* executor.configurable.registerSource("test-scope"); + yield* executor.secrets.set({ + id: SecretId.make("agent-secret"), + name: "Agent secret", + value: "secret-value", + scope: ScopeId.make("test-scope"), + provider: "memory", + }); + + expect( + yield* executor.tools.invoke("executor.coreTools.secrets.providers", {}), + ).toMatchObject({ + providers: expect.arrayContaining(["memory"]), + }); + expect( + yield* executor.tools.invoke("executor.coreTools.secrets.status", { + id: "agent-secret", + }), + ).toEqual({ id: "agent-secret", status: "resolved" }); + expect( + yield* executor.tools.invoke("executor.coreTools.secrets.usages", { + id: "agent-secret", + }), + ).toEqual({ usages: [] }); + + const createdPolicy = yield* executor.tools.invoke("executor.coreTools.policies.create", { + targetScope: "test-scope", + pattern: "configured-source.*", + action: "require_approval", + }); + const policyId = (createdPolicy as { readonly policy: { readonly id: string } }).policy.id; + expect(createdPolicy).toMatchObject({ + policy: { + id: expect.any(String), + scopeId: "test-scope", + pattern: "configured-source.*", + action: "require_approval", + }, + }); + expect(yield* executor.tools.invoke("executor.coreTools.policies.list", {})).toMatchObject({ + policies: [expect.objectContaining({ id: policyId })], + }); + expect( + yield* executor.tools.invoke("executor.coreTools.policies.update", { + id: policyId, + targetScope: "test-scope", + action: "approve", + }), + ).toMatchObject({ policy: { id: policyId, action: "approve" } }); + yield* executor.tools.invoke("executor.coreTools.policies.remove", { + id: policyId, + targetScope: "test-scope", + }); + expect(yield* executor.policies.list()).toEqual([]); + + yield* executor.tools.invoke("executor.coreTools.sources.refresh", { + id: "configured-source", + targetScope: "test-scope", + }); + yield* executor.tools.invoke("executor.coreTools.sources.remove", { + id: "configured-source", + targetScope: "test-scope", + }); + expect((yield* executor.sources.list()).map((source) => source.id)).not.toContain( + "configured-source", + ); + + yield* executor.tools.invoke("executor.coreTools.secrets.remove", { + id: "agent-secret", + targetScope: "test-scope", + }); + expect(yield* executor.secrets.list()).toEqual([]); + + yield* executor.close(); + yield* Effect.promise(() => config.testDb.close()); + }), + ); + + it.effect("core tools start OAuth and expose completed connections", () => + Effect.scoped( + Effect.gen(function* () { + const oauthServer = yield* serveOAuthTestServer(); + const config = makeTestConfig({ plugins: [memorySecretsPlugin()] as const }); + const executor = yield* createExecutor({ + ...config, + coreTools: { webBaseUrl: "http://executor.test" }, + oauthEndpointUrlPolicy: { allowHttp: true }, + }); + + yield* executor.secrets.set({ + id: SecretId.make("client-id"), + name: "OAuth client id", + value: "test-client", + scope: ScopeId.make("test-scope"), + provider: "memory", + }); + yield* executor.secrets.set({ + id: SecretId.make("client-secret"), + name: "OAuth client secret", + value: "test-secret", + scope: ScopeId.make("test-scope"), + provider: "memory", + }); + + const started = yield* executor.tools.invoke("executor.coreTools.oauth.start", { + scope: "test-scope", + endpoint: oauthServer.resourceUrl, + connectionId: "agent-oauth", + pluginId: "test-plugin", + strategy: { + kind: "client-credentials", + tokenEndpoint: oauthServer.tokenEndpoint, + clientIdSecretId: "client-id", + clientSecretSecretId: "client-secret", + scopes: ["read"], + }, + }); + + expect(started).toMatchObject({ + authorizationUrl: null, + completedConnection: { connectionId: "agent-oauth" }, + }); + + const listed = yield* executor.tools.invoke("executor.coreTools.connections.list", {}); + expect(listed).toMatchObject({ + connections: [expect.objectContaining({ id: "agent-oauth", provider: "oauth2" })], + }); + expect( + yield* executor.tools.invoke("executor.coreTools.connections.providers", {}), + ).toMatchObject({ + providers: expect.arrayContaining(["oauth2"]), + }); + expect( + yield* executor.tools.invoke("executor.coreTools.connections.usages", { + id: "agent-oauth", + }), + ).toEqual({ usages: [] }); + yield* executor.tools.invoke("executor.coreTools.connections.remove", { + id: "agent-oauth", + targetScope: "test-scope", + }); + expect(yield* executor.connections.list()).toEqual([]); + + yield* executor.close(); + yield* Effect.promise(() => config.testDb.close()); + }), + ), + ); + + it.effect("core OAuth tools return actionable tool failures for expected errors", () => + Effect.gen(function* () { + const config = makeTestConfig({ plugins: [memorySecretsPlugin()] as const }); + const executor = yield* createExecutor({ + ...config, + coreTools: { webBaseUrl: "http://executor.test" }, + oauthEndpointUrlPolicy: { allowHttp: true }, + }); + + const result = yield* executor.tools.invoke("executor.coreTools.oauth.probe", { + endpoint: "http://127.0.0.1:1/mcp", + }); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "oauth_probe_failed", + }, + }); + + yield* executor.close(); + yield* Effect.promise(() => config.testDb.close()); + }), + ); + + it.effect("core tools start browser OAuth and expose the completed connection", () => + Effect.scoped( + Effect.gen(function* () { + const oauthServer = yield* serveOAuthTestServer(); + const config = makeTestConfig({ plugins: [memorySecretsPlugin()] as const }); + const executor = yield* createExecutor({ + ...config, + coreTools: { webBaseUrl: "http://executor.test" }, + oauthEndpointUrlPolicy: { allowHttp: true }, + }); + + yield* executor.secrets.set({ + id: SecretId.make("browser-client-id"), + name: "OAuth client id", + value: "test-client", + scope: ScopeId.make("test-scope"), + provider: "memory", + }); + yield* executor.secrets.set({ + id: SecretId.make("browser-client-secret"), + name: "OAuth client secret", + value: "test-secret", + scope: ScopeId.make("test-scope"), + provider: "memory", + }); + + const started = yield* executor.tools.invoke("executor.coreTools.oauth.start", { + scope: "test", + endpoint: oauthServer.resourceUrl, + connectionId: "agent-browser-oauth", + pluginId: "test-plugin", + strategy: { + kind: "authorization-code", + authorizationEndpoint: oauthServer.authorizationEndpoint, + tokenEndpoint: oauthServer.tokenEndpoint, + clientIdSecretId: "browser-client-id", + clientSecretSecretId: "browser-client-secret", + scopes: ["read"], + }, + }); + expect(started).toMatchObject({ + authorizationUrl: expect.stringContaining(oauthServer.authorizationEndpoint), + completedConnection: null, + }); + + const authorizationUrl = (started as { authorizationUrl: string }).authorizationUrl; + const callback = yield* oauthServer.completeAuthorizationCodeFlow({ authorizationUrl }); + const completed = yield* executor.oauth.complete({ + state: callback.state, + code: callback.code, + }); + expect(completed.connectionId).toBe("agent-browser-oauth"); + + const listed = yield* executor.tools.invoke("executor.coreTools.connections.list", {}); + expect(listed).toMatchObject({ + connections: [expect.objectContaining({ id: "agent-browser-oauth", provider: "oauth2" })], + }); + + yield* executor.close(); + yield* Effect.promise(() => config.testDb.close()); + }), + ), + ); }); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 4c488ce9a..2aa4e2f39 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -5,6 +5,7 @@ import { Layer, Match, Option, + Predicate, Result, Schema, Semaphore, @@ -111,6 +112,7 @@ import type { Elicit, PluginCtx, PluginExtensions, + SourceConfigureSchema, StaticSourceDecl, StaticToolDecl, StaticToolSchema, @@ -577,21 +579,59 @@ const toToolJsonSchema = ( }); }; +const toConfigureJsonSchema = ( + schema: StaticToolSchema | Schema.Decoder | undefined, +): unknown => { + if (schema == null) return undefined; + const standard = schema as { + readonly "~standard"?: { + readonly validate?: unknown; + readonly jsonSchema?: StaticToolSchema["~standard"]["jsonSchema"]; + }; + }; + if (typeof standard["~standard"]?.validate !== "function") { + const jsonSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(schema as Schema.Decoder) as never, + ) as StaticToolSchema; + return toToolJsonSchema(jsonSchema); + } + return standard["~standard"].jsonSchema?.input({ + target: "draft-2020-12", + }); +}; + const decodeConfigureInput = ( schema: StaticToolSchema | Schema.Decoder | undefined, input: unknown, ): Effect.Effect => { if (schema == null) return Effect.succeed(input); - if (!("~standard" in schema)) { - return Schema.decodeUnknownEffect(schema)(input); + const standard = schema as { + readonly "~standard"?: { readonly validate?: unknown }; + }; + if (standard["~standard"] === undefined || typeof standard["~standard"].validate !== "function") { + return Schema.decodeUnknownEffect(schema as Schema.Decoder)(input); } - return Effect.promise(() => Promise.resolve(schema["~standard"].validate(input))).pipe( - Effect.flatMap((result) => - "value" in result ? Effect.succeed(result.value) : Effect.fail(result), - ), + return Effect.promise(() => + Promise.resolve((standard["~standard"]!.validate as (input: unknown) => unknown)(input)), + ).pipe( + Effect.flatMap((result) => { + const validationResult = result as { readonly value?: unknown }; + return "value" in validationResult + ? Effect.succeed(validationResult.value) + : Effect.fail(result); + }), ); }; +const sourceConfigureSchemaView = ( + pluginId: string, + configure: NonNullable, +): SourceConfigureSchema => ({ + pluginId, + type: configure.type, + schema: toConfigureJsonSchema(configure.schema), +}); + const EXECUTOR_SOURCE_ID = "executor"; const EXECUTOR_SOURCE: StaticSourceDecl = { id: EXECUTOR_SOURCE_ID, @@ -1686,6 +1726,7 @@ export const createExecutor = ({ id: String(ref.id), + scopeId: ref.scopeId, name: ref.name, provider: ref.provider, })); @@ -3076,6 +3117,29 @@ export const createExecutor = listSources(), + remove: (input) => removeSource(input), + refresh: (input) => refreshSource(input), + detect: (url) => detectSource(url), + configure: (input) => sourceConfigure(input), + listBindings: (input) => sourceBindingList(input), + resolveBinding: (input) => sourceBindingResolve(input), + setBinding: (input) => sourceBindingSet(input), + removeBinding: (input) => sourceBindingRemove(input), + configureSchemas: () => + Array.from(runtimes.values()) + .map(({ plugin }) => + plugin.sourceConfigure + ? sourceConfigureSchemaView(plugin.id, plugin.sourceConfigure) + : undefined, + ) + .filter(Predicate.isNotUndefined), + }, + policies: { + list: () => policiesList(), + create: (input) => policiesCreate(input), + update: (input) => policiesUpdate(input), + remove: (input) => policiesRemove(input), }, definitions: { register: (input: DefinitionsInput) => @@ -3086,6 +3150,10 @@ export const createExecutor = secretsGet(id), getAtScope: (id, scope) => secretsGetAtScope(id, scope), list: () => secretsListForCtx(), + status: (id) => secretsStatus(id), + usages: (id) => secretsUsages(id), + providers: () => + Effect.sync(() => Array.from(secretProviders.keys()) as readonly string[]), set: (input) => secretsSet(input), remove: (input) => secretsRemove(input), }, @@ -3093,6 +3161,9 @@ export const createExecutor = connectionsGet(id), getAtScope: (id, scope) => connectionsGetAtScope(id, scope), list: () => connectionsListForCtx(), + usages: (id) => connectionsUsages(id), + providers: () => + Effect.sync(() => Array.from(connectionProviders.keys()) as readonly string[]), create: (input) => connectionsCreate(input), updateTokens: (input) => connectionsUpdateTokens(input), setIdentityLabel: (id, label) => connectionsSetIdentityLabel(id, label), @@ -3117,11 +3188,14 @@ export const createExecutor = executor.openapi.addSource + // googleDiscovery.addSource -> executor.googleDiscovery.addSource const decls = plugin.staticSources ? plugin.staticSources(extension) : []; for (const source of decls) { - const mountUnderExecutor = source.kind === "executor" && source.id === plugin.id; + const mountUnderExecutor = source.kind === "executor"; const mountedSource = mountUnderExecutor ? EXECUTOR_SOURCE : source; if (mountUnderExecutor) { @@ -3145,7 +3219,7 @@ export const createExecutor = { readonly name?: string; readonly url?: string | null; }) => Effect.Effect; + readonly list: () => Effect.Effect; + readonly remove: ( + input: RemoveSourceInput, + ) => Effect.Effect; + readonly refresh: (input: RefreshSourceInput) => Effect.Effect; + readonly detect: ( + url: string, + ) => Effect.Effect; + readonly configure: (input: { + readonly source: { + readonly id: string; + readonly scope: ScopeId | string; + }; + readonly scope: ScopeId | string; + readonly type?: string; + readonly config: unknown; + }) => Effect.Effect; + readonly listBindings: ( + input: SourceCredentialBindingSourceInput, + ) => Effect.Effect; + readonly resolveBinding: ( + input: SourceCredentialBindingSlotInput, + ) => Effect.Effect; + readonly setBinding: ( + input: SetSourceCredentialBindingInput, + ) => Effect.Effect; + readonly removeBinding: ( + input: RemoveSourceCredentialBindingInput, + ) => Effect.Effect; + /** Source configuration declarations for every plugin that + * supports `executor.sources.configure`. Exposed to core tools + * so agent-facing configuration surfaces can describe the + * plugin-specific payload shape before dispatching a write. */ + readonly configureSchemas: () => readonly SourceConfigureSchema[]; + }; + readonly policies: { + readonly list: () => Effect.Effect; + readonly create: (input: CreateToolPolicyInput) => Effect.Effect; + readonly update: (input: UpdateToolPolicyInput) => Effect.Effect; + readonly remove: (input: RemoveToolPolicyInput) => Effect.Effect; }; /** Register shared JSON-schema `$defs` for a source. Tool * input/output schemas registered via `sources.register` can carry @@ -123,9 +178,17 @@ export interface PluginCtx { * `owned_by_connection_id` set) are filtered out so they don't * clutter the UI — users see the Connection instead. */ readonly list: () => Effect.Effect< - readonly { readonly id: string; readonly name: string; readonly provider: string }[], + readonly { + readonly id: string; + readonly scopeId: ScopeId; + readonly name: string; + readonly provider: string; + }[], StorageFailure >; + readonly status: (id: string) => Effect.Effect<"resolved" | "missing", StorageFailure>; + readonly usages: (id: string) => Effect.Effect; + readonly providers: () => Effect.Effect; /** Write a secret value through a provider. Used by plugins that * mint secrets on behalf of the user (OAuth2 token storage, * interactive onboarding flows). Normally writes go through @@ -156,6 +219,8 @@ export interface PluginCtx { scope: string, ) => Effect.Effect; readonly list: () => Effect.Effect; + readonly usages: (id: string) => Effect.Effect; + readonly providers: () => Effect.Effect; readonly create: ( input: CreateConnectionInput, ) => Effect.Effect; @@ -371,6 +436,12 @@ export interface SourceConfigureDecl { ) => Effect.Effect; } +export interface SourceConfigureSchema { + readonly pluginId: string; + readonly type: string; + readonly schema?: unknown; +} + // --------------------------------------------------------------------------- // PluginSpec — what a `definePlugin(factory)` call returns. // --------------------------------------------------------------------------- diff --git a/packages/plugins/desktop-settings/package.json b/packages/plugins/desktop-settings/package.json index 5a5a2c36d..4c572bad8 100644 --- a/packages/plugins/desktop-settings/package.json +++ b/packages/plugins/desktop-settings/package.json @@ -38,6 +38,7 @@ }, "scripts": { "build": "tsup", + "test": "vitest run", "typecheck": "tsgo --noEmit", "typecheck:slow": "tsc --noEmit" }, diff --git a/packages/plugins/desktop-settings/src/server.test.ts b/packages/plugins/desktop-settings/src/server.test.ts new file mode 100644 index 000000000..d62c66e2d --- /dev/null +++ b/packages/plugins/desktop-settings/src/server.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect } from "effect"; + +import { createExecutor } from "@executor-js/sdk"; +import { makeTestConfig } from "@executor-js/sdk/testing"; + +import { desktopSettingsPlugin } from "./server"; + +describe("desktopSettingsPlugin", () => { + it.effect("returns a browser handoff URL for Desktop-only settings", () => + Effect.gen(function* () { + const config = makeTestConfig({ + plugins: [desktopSettingsPlugin({ webBaseUrl: "http://executor.test/base/" })] as const, + }); + const executor = yield* createExecutor(config); + + const result = yield* executor.tools.invoke("executor.desktopSettings.openSettings", {}); + + expect(result).toEqual({ + url: "http://executor.test/base/plugins/desktop-settings/", + flow: "Open this URL in Executor Desktop. The user can change port/auth or regenerate the password there; then rerun discovery/list tools to observe the restarted server.", + }); + + yield* executor.close(); + yield* Effect.promise(() => config.testDb.close()); + }), + ); +}); diff --git a/packages/plugins/desktop-settings/src/server.ts b/packages/plugins/desktop-settings/src/server.ts index 7c31f25ae..331eb094e 100644 --- a/packages/plugins/desktop-settings/src/server.ts +++ b/packages/plugins/desktop-settings/src/server.ts @@ -3,15 +3,58 @@ * * Zero-server-state plugin. The Desktop Settings panel reads and writes * its configuration via Electron IPC (`window.executor.*`), not through - * the executor server. This file exists so the host can register the - * plugin's `packageName` and the vite plugin can find the `./client` - * bundle — that's the entire server contribution. + * the executor server. The server contribution exposes an agent-facing + * browser handoff tool so a chat flow can still route the user to the + * Desktop-only settings UI without moving the Basic-auth password + * through the model context. */ -import { definePlugin } from "@executor-js/sdk/core"; +import { Effect, Schema } from "effect"; -export const desktopSettingsPlugin = definePlugin(() => ({ +import { definePlugin, tool, type StaticToolSchema } from "@executor-js/sdk/core"; + +export interface DesktopSettingsPluginOptions { + readonly webBaseUrl?: string; +} + +const schemaToStaticToolSchema = (schema: Schema.Decoder): StaticToolSchema => + Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema< + A, + I + >; + +const DesktopSettingsOpenOutput = Schema.Struct({ + url: Schema.String, + flow: Schema.String, +}); + +const DesktopSettingsOpenOutputStd = schemaToStaticToolSchema(DesktopSettingsOpenOutput); + +const resolveWebBaseUrl = (configured: string | undefined): string => + (configured ?? "http://localhost:4788").replace(/\/$/, ""); + +export const desktopSettingsPlugin = definePlugin((options: DesktopSettingsPluginOptions = {}) => ({ id: "desktop-settings" as const, packageName: "@executor-js/plugin-desktop-settings", storage: () => ({}), + staticSources: () => [ + { + id: "desktopSettings", + kind: "executor", + name: "Desktop Settings", + tools: [ + tool({ + name: "openSettings", + description: + "Return the Desktop Settings browser URL for configuring the local sidecar port, Basic-auth requirement, and generated password. This flow must stay in the Desktop UI because password display/regeneration and server restart are Electron IPC operations; never ask the user to paste the password in chat.", + outputSchema: DesktopSettingsOpenOutputStd, + execute: () => + Effect.succeed({ + url: `${resolveWebBaseUrl(options.webBaseUrl)}/plugins/desktop-settings/`, + flow: "Open this URL in Executor Desktop. The user can change port/auth or regenerate the password there; then rerun discovery/list tools to observe the restarted server.", + }), + }), + ], + }, + ], })); diff --git a/packages/plugins/desktop-settings/vitest.config.ts b/packages/plugins/desktop-settings/vitest.config.ts new file mode 100644 index 000000000..ae847ff6d --- /dev/null +++ b/packages/plugins/desktop-settings/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +}); diff --git a/packages/plugins/google-discovery/src/sdk/plugin.test.ts b/packages/plugins/google-discovery/src/sdk/plugin.test.ts index 5b0b91eb1..bbdd4d105 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.test.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.test.ts @@ -425,6 +425,23 @@ describe("Google Discovery plugin", () => { }); expect(result.toolCount).toBe(2); + expect((yield* executor.tools.list()).map((tool) => tool.id)).toEqual( + expect.arrayContaining([ + "executor.googleDiscovery.probeDiscovery", + "executor.googleDiscovery.addSource", + "executor.googleDiscovery.getSource", + ]), + ); + + const inspected = yield* executor.tools.invoke( + "executor.googleDiscovery.getSource", + { namespace: "drive", scope: "test-scope" }, + autoApprove, + ); + expect(inspected).toMatchObject({ + ok: true, + data: { source: { namespace: "drive", scope: "test-scope" } }, + }); const invocation = (yield* executor.tools.invoke( "drive.files.get", diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts index 2f6c0f6e5..2b3671f56 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -6,8 +6,10 @@ import { ToolResult, Usage, definePlugin, + tool, resolveSecretBackedMap, type PluginCtx, + type StaticToolSchema, type StorageFailure, type ToolAnnotations, } from "@executor-js/sdk/core"; @@ -20,15 +22,15 @@ import { import { extractGoogleDiscoveryManifest } from "./document"; import { annotationsForOperation, invokeGoogleDiscoveryTool } from "./invoke"; import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "./errors"; -import type { +import { GoogleDiscoveryAuth, GoogleDiscoveryFetchCredentials, - GoogleDiscoveryManifest, - GoogleDiscoveryManifestMethod, - GoogleDiscoveryMethodBinding, - GoogleDiscoveryStoredSourceData, + GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema, + type GoogleDiscoveryManifest, + type GoogleDiscoveryManifestMethod, + type GoogleDiscoveryMethodBinding, } from "./types"; -import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types"; +import type { GoogleDiscoveryStoredSourceData } from "./types"; // --------------------------------------------------------------------------- // Upstream-error message extraction @@ -114,20 +116,6 @@ export interface GoogleDiscoveryProbeResult { readonly operations: readonly GoogleDiscoveryProbeOperation[]; } -export interface GoogleDiscoveryProbeInput { - readonly discoveryUrl: string; - readonly credentials?: GoogleDiscoveryFetchCredentials; -} - -export interface GoogleDiscoveryAddSourceInput { - readonly name: string; - readonly scope: string; - readonly discoveryUrl: string; - readonly credentials?: GoogleDiscoveryFetchCredentials; - readonly namespace?: string; - readonly auth: GoogleDiscoveryAuth; -} - export interface GoogleDiscoveryUpdateSourceInput { readonly name?: string; /** Rewrite the source's auth — typically after a successful @@ -135,6 +123,91 @@ export interface GoogleDiscoveryUpdateSourceInput { readonly auth?: GoogleDiscoveryAuth; } +const GoogleDiscoveryProbeInputSchema = Schema.Struct({ + discoveryUrl: Schema.String, + credentials: Schema.optional(GoogleDiscoveryFetchCredentials), +}); + +const GoogleDiscoveryProbeOutputSchema = Schema.Struct({ + name: Schema.String, + title: Schema.NullOr(Schema.String), + service: Schema.String, + version: Schema.String, + toolCount: Schema.Number, + scopes: Schema.Array(Schema.String), + operations: Schema.Array( + Schema.Struct({ + toolPath: Schema.String, + method: Schema.String, + pathTemplate: Schema.String, + description: Schema.NullOr(Schema.String), + }), + ), +}); + +const GoogleDiscoveryAddSourceInputSchema = Schema.Struct({ + name: Schema.String, + scope: Schema.String, + discoveryUrl: Schema.String, + credentials: Schema.optional(GoogleDiscoveryFetchCredentials), + namespace: Schema.optional(Schema.String), + auth: GoogleDiscoveryAuth, +}); +export type GoogleDiscoveryProbeInput = typeof GoogleDiscoveryProbeInputSchema.Type; +export type GoogleDiscoveryAddSourceInput = typeof GoogleDiscoveryAddSourceInputSchema.Type; + +const GoogleDiscoveryAddSourceOutputSchema = Schema.Struct({ + namespace: Schema.String, + toolCount: Schema.Number, +}); + +const GoogleDiscoveryGetSourceInputSchema = Schema.Struct({ + namespace: Schema.String, + scope: Schema.String, +}); + +const GoogleDiscoveryGetSourceOutputSchema = Schema.Struct({ + source: Schema.NullOr(Schema.Unknown), +}); + +const GoogleDiscoveryConfigureInputSchema = Schema.Struct({ + name: Schema.optional(Schema.String), + auth: Schema.optional(GoogleDiscoveryAuth), +}); + +const schemaToStaticToolSchema = (schema: Schema.Decoder): StaticToolSchema => + Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema< + A, + I + >; + +const GoogleDiscoveryProbeInputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryProbeInputSchema, +); +const GoogleDiscoveryProbeOutputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryProbeOutputSchema, +); +const GoogleDiscoveryAddSourceInputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryAddSourceInputSchema, +); +const GoogleDiscoveryAddSourceOutputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryAddSourceOutputSchema, +); +const GoogleDiscoveryGetSourceInputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryGetSourceInputSchema, +); +const GoogleDiscoveryGetSourceOutputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryGetSourceOutputSchema, +); + +const resolveStaticScopeInput = ( + ctx: { readonly scopes: readonly { readonly id: ScopeId; readonly name: string }[] }, + value: string, +): string => + String( + ctx.scopes.find((scope) => scope.name === value || String(scope.id) === value)?.id ?? value, + ); + /** * Errors any Google Discovery extension method may surface. */ @@ -444,6 +517,67 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ extension: makeGoogleDiscoveryPluginExtension, + staticSources: (self) => [ + { + id: "googleDiscovery", + kind: "executor", + name: "Google Discovery", + tools: [ + tool({ + name: "probeDiscovery", + description: + "Preview a Google Discovery document before adding it as a source. Use this to inspect available operations and OAuth scopes. Do not collect Google OAuth client secrets in chat; create them with `executor.coreTools.secrets.create`, then start sign-in with `executor.coreTools.oauth.start`.", + inputSchema: GoogleDiscoveryProbeInputStandardSchema, + outputSchema: GoogleDiscoveryProbeOutputStandardSchema, + execute: (input) => Effect.map(self.probeDiscovery(input), ToolResult.ok), + }), + tool({ + name: "addSource", + description: + 'Add a Google Discovery source and register its operations as tools. Recommended flow: call `probeDiscovery`, create any OAuth client id/client secret values through `secrets.create`, call `oauth.start` in the browser for OAuth sources, then pass `{kind:"oauth2", connectionId, clientIdSecretId, clientSecretSecretId, scopes}` or `{kind:"none"}` here.', + annotations: { + requiresApproval: true, + approvalDescription: "Add a Google Discovery source", + }, + inputSchema: GoogleDiscoveryAddSourceInputStandardSchema, + outputSchema: GoogleDiscoveryAddSourceOutputStandardSchema, + execute: (input, { ctx }) => { + const args = input as GoogleDiscoveryAddSourceInput; + return Effect.map( + self.addSource({ ...args, scope: resolveStaticScopeInput(ctx, args.scope) }), + ToolResult.ok, + ); + }, + }), + tool({ + name: "getSource", + description: + "Inspect an existing Google Discovery source, including discovery URL, service metadata, auth mode, OAuth scopes, connection id, and credential slots. Use this before repairing an existing source with `sources.configure`, `secrets.create`, or `oauth.start`.", + inputSchema: GoogleDiscoveryGetSourceInputStandardSchema, + outputSchema: GoogleDiscoveryGetSourceOutputStandardSchema, + execute: (input, { ctx }) => { + const args = input as typeof GoogleDiscoveryGetSourceInputSchema.Type; + return Effect.map( + self.getSource(args.namespace, resolveStaticScopeInput(ctx, args.scope)), + (source) => ToolResult.ok({ source }), + ); + }, + }), + ], + }, + ], + + sourceConfigure: { + type: "googleDiscovery", + schema: GoogleDiscoveryConfigureInputSchema, + configure: ({ ctx, sourceId, sourceScope, config }) => + makeGoogleDiscoveryPluginExtension(ctx as PluginCtx).updateSource( + sourceId, + sourceScope, + config as typeof GoogleDiscoveryConfigureInputSchema.Type, + ), + }, + invokeTool: ({ ctx, toolRow, args }) => Effect.gen(function* () { const result = yield* invokeGoogleDiscoveryTool({ diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index b84198b9f..240d1adf4 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -630,6 +630,7 @@ describe("graphqlPlugin", () => { expect(ids).toContain("test_api.query.hello"); expect(ids).toContain("test_api.mutation.setGreeting"); // static executor tool also present under the executor namespace + expect(ids).toContain("executor.graphql.getSource"); expect(ids).toContain("executor.graphql.addSource"); const queryTool = tools.find((t) => t.id === "test_api.query.hello"); @@ -770,7 +771,7 @@ describe("graphqlPlugin", () => { const result = yield* executor.tools.invoke( "executor.graphql.addSource", { - scope: String(orgScope), + scope: "org", endpoint: "http://localhost:4000/graphql", name: "Via Static", introspectionJson, @@ -783,12 +784,49 @@ describe("graphqlPlugin", () => { expect((yield* executor.graphql.getSource("via_static", String(orgScope)))?.scope).toBe( orgScope, ); + const inspected = yield* executor.tools.invoke( + "executor.graphql.getSource", + { namespace: "via_static", scope: "org" }, + { onElicitation: "accept-all" }, + ); + expect(inspected).toMatchObject({ + ok: true, + data: { source: { namespace: "via_static", scope: String(orgScope) } }, + }); const tools = yield* executor.tools.list(); expect(tools.filter((t) => t.sourceId === "via_static").length).toBe(2); }), ); + it.effect("static executor.graphql.addSource returns actionable tool failures", () => + Effect.gen(function* () { + const config = makeTestConfig({ plugins: [graphqlPlugin()] as const }); + const executor = yield* createExecutor(config); + + const result = yield* executor.tools.invoke( + "executor.graphql.addSource", + { + scope: TEST_SCOPE, + endpoint: "http://127.0.0.1:1/graphql", + name: "Broken GraphQL", + namespace: "broken_graphql", + }, + { onElicitation: "accept-all" }, + ); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "graphql_introspection_failed", + }, + }); + + yield* executor.close(); + yield* Effect.promise(() => config.testDb.close()); + }), + ); + it.effect("describes static addSource parameters from Standard Schema", () => Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({ plugins: [graphqlPlugin()] })); diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 1eb840e09..10a0d4e45 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -137,6 +137,13 @@ const SourceConfigureInputSchema = Schema.Struct({ queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)), auth: Schema.optional(GraphqlSourceAuthInputSchema), }); +const StaticGetSourceInputSchema = Schema.Struct({ + namespace: Schema.String, + scope: Schema.String, +}); +const StaticGetSourceOutputSchema = Schema.Struct({ + source: Schema.NullOr(Schema.Unknown), +}); const StaticAddSourceInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(StaticAddSourceInputSchema), @@ -144,6 +151,27 @@ const StaticAddSourceInputStandardSchema = Schema.toStandardSchemaV1( const StaticAddSourceOutputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(Schema.Struct({ toolCount: Schema.Number })), ); +const StaticGetSourceInputStandardSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(StaticGetSourceInputSchema), +); +const StaticGetSourceOutputStandardSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(StaticGetSourceOutputSchema), +); + +const graphqlToolFailure = (code: string, message: string, details?: unknown) => + ToolResult.fail({ + code, + message, + ...(details === undefined ? {} : { details }), + }); + +const resolveStaticScopeInput = ( + ctx: { readonly scopes: readonly { readonly id: ScopeId; readonly name: string }[] }, + value: string, +): string => + String( + ctx.scopes.find((scope) => scope.name === value || String(scope.id) === value)?.id ?? value, + ); // --------------------------------------------------------------------------- // Plugin extension @@ -922,16 +950,38 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { kind: "executor", name: "GraphQL", tools: [ + tool({ + name: "getSource", + description: + "Inspect an existing GraphQL source, including endpoint, auth mode, configured headers/query params, and credential slots. Use this before repairing an existing source with `sources.configure`, `secrets.create`, or `oauth.start`.", + inputSchema: StaticGetSourceInputStandardSchema, + outputSchema: StaticGetSourceOutputStandardSchema, + execute: (input, { ctx }) => + Effect.map( + self.getSource(input.namespace, resolveStaticScopeInput(ctx, input.scope)), + (source) => ToolResult.ok({ source }), + ), + }), tool({ name: "addSource", - description: "Add a GraphQL endpoint and register its operations as tools", + description: + "Add a GraphQL endpoint and register its operations as tools. For API keys or bearer tokens, first call `executor.coreTools.secrets.create` and pass secret refs through `credentials`. For OAuth, start the browser flow with `executor.coreTools.oauth.start`, verify completion with `connections.list`, then bind the connection via this input or `sources.configure`.", annotations: { requiresApproval: true, approvalDescription: "Add a GraphQL source", }, inputSchema: StaticAddSourceInputStandardSchema, outputSchema: StaticAddSourceOutputStandardSchema, - execute: (input) => Effect.map(self.addSource(input), ToolResult.ok), + execute: (input, { ctx }) => + self.addSource({ ...input, scope: resolveStaticScopeInput(ctx, input.scope) }).pipe( + Effect.map(ToolResult.ok), + Effect.catchTags({ + GraphqlIntrospectionError: ({ message }) => + Effect.succeed(graphqlToolFailure("graphql_introspection_failed", message)), + GraphqlExtractionError: ({ message }) => + Effect.succeed(graphqlToolFailure("graphql_extraction_failed", message)), + }), + ), }), ], }, diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 03720c674..ef7d814bf 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -215,24 +215,25 @@ describe("mcpPlugin", () => { expect(executor.mcp.removeSource).toBeTypeOf("function"); expect(executor.mcp.refreshSource).toBeTypeOf("function"); expect(executor.mcp.probeEndpoint).toBeTypeOf("function"); + expect(executor.mcp.getSource).toBeTypeOf("function"); expect(executor.oauth.start).toBeTypeOf("function"); expect(executor.oauth.complete).toBeTypeOf("function"); }), ); - it.effect("sources list is initially empty", () => + it.effect("sources list has no configured MCP sources initially", () => Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); const sources = yield* executor.sources.list(); - expect(sources).toHaveLength(0); + expect(sources.filter((source) => !source.runtime)).toHaveLength(0); }), ); - it.effect("tools list is initially empty", () => + it.effect("tools list has no configured MCP source tools initially", () => Effect.gen(function* () { const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); const tools = yield* executor.tools.list(); - expect(tools).toHaveLength(0); + expect(tools.filter((tool) => !tool.id.startsWith("executor.mcp."))).toHaveLength(0); }), ); @@ -268,6 +269,69 @@ describe("mcpPlugin", () => { const tools = yield* executor.tools.list(); expect(tools.filter((t) => t.sourceId === "broken_source")).toHaveLength(0); + const inspected = yield* executor.tools.invoke( + "executor.mcp.getSource", + { namespace: "broken_source", scope: "test-scope" }, + { onElicitation: "accept-all" }, + ); + expect(inspected).toMatchObject({ + ok: true, + data: { source: { namespace: "broken_source", scope: "test-scope" } }, + }); + }), + ); + + it.effect("static addSource reports saved remote source when discovery fails", () => + Effect.gen(function* () { + const executor = yield* createExecutor(makeTestConfig({ plugins: [mcpPlugin()] as const })); + + const result = yield* executor.tools.invoke( + "executor.mcp.addSource", + { + transport: "remote", + scope: "test-scope", + name: "broken static", + endpoint: "http://127.0.0.1:1/mcp", + remoteTransport: "auto", + namespace: "broken_static_source", + }, + { onElicitation: "accept-all" }, + ); + + expect(result).toMatchObject({ + ok: true, + data: { + namespace: "broken_static_source", + toolCount: 0, + discovery: { + status: "failed", + }, + }, + }); + + const source = yield* executor.mcp.getSource("broken_static_source", "test-scope"); + expect(source?.namespace).toBe("broken_static_source"); + }), + ); + + it.effect("static probeEndpoint returns actionable tool failures", () => + Effect.gen(function* () { + const config = makeTestConfig({ plugins: [mcpPlugin()] as const }); + const executor = yield* createExecutor(config); + + const result = yield* executor.tools.invoke("executor.mcp.probeEndpoint", { + endpoint: "http://127.0.0.1:1/mcp", + }); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "mcp_connection_failed", + }, + }); + + yield* executor.close(); + yield* Effect.promise(() => config.testDb.close()); }), ); diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index e437f30b6..253190f46 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -22,8 +22,10 @@ import { SourceDetectionResult, ToolResult, definePlugin, + tool, resolveSecretBackedMap as resolveSharedSecretBackedMap, type PluginCtx, + type StaticToolSchema, type StorageFailure, StorageError, type ToolAnnotations, @@ -52,13 +54,15 @@ import { MCP_OAUTH_CLIENT_SECRET_SLOT, MCP_OAUTH_CONNECTION_SLOT, McpConnectionAuthInput, + McpConfiguredValueInput, McpCredentialInput, + McpRemoteTransport, McpToolBinding, mcpHeaderSlot, mcpQueryParamSlot, type McpConnectionAuth, type McpConfiguredValueInput as McpConfiguredValueInputType, - type SecretBackedValue, + SecretBackedValue, type McpStoredSourceData, type ConfiguredMcpCredentialValue, } from "./types"; @@ -157,11 +161,101 @@ const McpInitialCredentialsInputSchema = Schema.Struct({ }); type McpInitialCredentialsInput = typeof McpInitialCredentialsInputSchema.Type; -export interface McpProbeEndpointInput { - readonly endpoint: string; - readonly headers?: Record; - readonly queryParams?: Record; -} +const McpRemoteAddSourceInputSchema = Schema.Struct({ + scope: Schema.String, + transport: Schema.Literal("remote"), + name: Schema.String, + endpoint: Schema.String, + remoteTransport: Schema.optional(McpRemoteTransport), + queryParams: Schema.optional(Schema.Record(Schema.String, McpConfiguredValueInput)), + headers: Schema.optional(Schema.Record(Schema.String, McpConfiguredValueInput)), + namespace: Schema.optional(Schema.String), + oauth2: Schema.optional(OAuth2SourceConfig), + credentials: Schema.optional(McpInitialCredentialsInputSchema), +}); + +const McpStdioAddSourceInputSchema = Schema.Struct({ + scope: Schema.String, + transport: Schema.Literal("stdio"), + name: Schema.String, + command: Schema.String, + args: Schema.optional(Schema.Array(Schema.String)), + env: Schema.optional(Schema.Record(Schema.String, Schema.String)), + cwd: Schema.optional(Schema.String), + namespace: Schema.optional(Schema.String), +}); + +const McpAddSourceInputSchema = Schema.Union([ + McpRemoteAddSourceInputSchema, + McpStdioAddSourceInputSchema, +]); + +const McpAddSourceOutputSchema = Schema.Struct({ + namespace: Schema.String, + toolCount: Schema.Number, + discovery: Schema.optional( + Schema.Struct({ + status: Schema.Literals(["ok", "failed"]), + message: Schema.optional(Schema.String), + stage: Schema.optional(Schema.String), + }), + ), +}); + +const McpProbeEndpointInputSchema = Schema.Struct({ + endpoint: Schema.String, + headers: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), + queryParams: Schema.optional(Schema.Record(Schema.String, SecretBackedValue)), +}); + +const McpProbeEndpointOutputSchema = Schema.Struct({ + connected: Schema.Boolean, + requiresOAuth: Schema.Boolean, + supportsDynamicRegistration: Schema.Boolean, + name: Schema.String, + namespace: Schema.String, + toolCount: Schema.NullOr(Schema.Number), + serverName: Schema.NullOr(Schema.String), +}); + +const McpGetSourceInputSchema = Schema.Struct({ + namespace: Schema.String, + scope: Schema.String, +}); + +const McpGetSourceOutputSchema = Schema.Struct({ + source: Schema.NullOr(Schema.Unknown), +}); + +const schemaToStaticToolSchema = (schema: Schema.Decoder): StaticToolSchema => + Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema< + A, + I + >; + +const mcpToolFailure = (code: string, message: string, details?: unknown) => + ToolResult.fail({ + code, + message, + ...(details === undefined ? {} : { details }), + }); + +const McpAddSourceInputStandardSchema = schemaToStaticToolSchema(McpAddSourceInputSchema); +const McpAddSourceOutputStandardSchema = schemaToStaticToolSchema(McpAddSourceOutputSchema); +const McpProbeEndpointInputStandardSchema = schemaToStaticToolSchema(McpProbeEndpointInputSchema); +const McpProbeEndpointOutputStandardSchema = schemaToStaticToolSchema(McpProbeEndpointOutputSchema); +const McpGetSourceInputStandardSchema = schemaToStaticToolSchema(McpGetSourceInputSchema); +const McpGetSourceOutputStandardSchema = schemaToStaticToolSchema(McpGetSourceOutputSchema); + +export type McpProbeEndpointInput = typeof McpProbeEndpointInputSchema.Type; + +const resolveStaticScopeInput = ( + ctx: { readonly scopes: readonly { readonly id: ScopeId; readonly name: string }[] }, + value: string, +): string => + String( + ctx.scopes.find((scope) => scope.name === value || String(scope.id) === value)?.id ?? value, + ); // --------------------------------------------------------------------------- // Helpers @@ -1579,6 +1673,111 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { }), }, + staticSources: (self) => [ + { + id: "mcp", + kind: "executor", + name: "MCP", + tools: [ + tool({ + name: "probeEndpoint", + description: + "Probe a remote MCP endpoint before adding it. If the result requires OAuth, call `executor.coreTools.oauth.probe` and `executor.coreTools.oauth.start` first, then pass the resulting connection through `addSource` credentials or `sources.configure`.", + inputSchema: McpProbeEndpointInputStandardSchema, + outputSchema: McpProbeEndpointOutputStandardSchema, + execute: (input) => + self.probeEndpoint(input).pipe( + Effect.map(ToolResult.ok), + Effect.catchTag("McpConnectionError", ({ message, transport }) => + Effect.succeed(mcpToolFailure("mcp_connection_failed", message, { transport })), + ), + ), + }), + tool({ + name: "getSource", + description: + "Inspect an existing MCP source, including transport, endpoint/command, auth mode, configured headers/query params, and credential slots. Use this before repairing an existing source with `sources.configure`, `secrets.create`, or `oauth.start`.", + inputSchema: McpGetSourceInputStandardSchema, + outputSchema: McpGetSourceOutputStandardSchema, + execute: (input, { ctx }) => { + const args = input as typeof McpGetSourceInputSchema.Type; + return Effect.map( + self.getSource(args.namespace, resolveStaticScopeInput(ctx, args.scope)), + (source) => ToolResult.ok({ source }), + ); + }, + }), + tool({ + name: "addSource", + description: + "Add an MCP source and register its tools. For remote OAuth-protected servers, first use `probeEndpoint` and the core OAuth browser handoff (`oauth.probe`, `oauth.start`), then bind the completed connection with `sources.configure` if needed. For header/API-key auth, first call `secrets.create` so the value is entered in the browser, then pass the secret reference in `credentials`. Remote sources are still saved if discovery fails; inspect the returned `discovery` field and use `sources.refresh` after credentials or network access are fixed.", + annotations: { + requiresApproval: true, + approvalDescription: "Add an MCP source", + }, + inputSchema: McpAddSourceInputStandardSchema, + outputSchema: McpAddSourceOutputStandardSchema, + execute: (rawInput, { ctx }) => { + const input = rawInput as McpSourceConfig; + const normalizedInput = { + ...input, + scope: resolveStaticScopeInput(ctx, input.scope), + } as McpSourceConfig; + const added = self + .addSource(normalizedInput) + .pipe( + Effect.map((result) => ToolResult.ok({ ...result, discovery: { status: "ok" } })), + ); + if (normalizedInput.transport !== "remote") return added; + + const savedWithDiscoveryFailure = (failure: { + readonly message: string; + readonly stage?: string; + }) => + Effect.succeed( + ToolResult.ok({ + namespace: + normalizedInput.namespace ?? + deriveMcpNamespace({ + name: normalizedInput.name, + endpoint: normalizedInput.endpoint, + }), + toolCount: 0, + discovery: { + status: "failed" as const, + message: failure.message, + ...(failure.stage ? { stage: failure.stage } : {}), + }, + }), + ); + + return added.pipe( + Effect.catchTags({ + McpToolDiscoveryError: savedWithDiscoveryFailure, + McpConnectionError: ({ message }) => + Effect.succeed( + ToolResult.ok({ + namespace: + normalizedInput.namespace ?? + deriveMcpNamespace({ + name: normalizedInput.name, + endpoint: normalizedInput.endpoint, + }), + toolCount: 0, + discovery: { + status: "failed" as const, + message, + }, + }), + ), + }), + ); + }, + }), + ], + }, + ], + invokeTool: ({ ctx, toolRow, args, elicit }) => Effect.gen(function* () { const runtime = yield* ensureRuntime(); diff --git a/packages/plugins/onepassword/src/sdk/plugin.test.ts b/packages/plugins/onepassword/src/sdk/plugin.test.ts index eb3cba6a0..f8795b97d 100644 --- a/packages/plugins/onepassword/src/sdk/plugin.test.ts +++ b/packages/plugins/onepassword/src/sdk/plugin.test.ts @@ -54,6 +54,39 @@ layer( }), ); + it.effect("exposes provider configuration as agent-callable static tools", () => + Effect.gen(function* () { + const { config: harnessConfig } = yield* TestWorkspace; + const executor = yield* createExecutor({ ...harnessConfig, plugins }); + + const configured = yield* executor.tools.invoke( + "executor.onepassword.configure", + { + scope: "test-scope", + auth: { kind: "desktop-app", accountName: "my.1password.com" }, + vaultId: "vault-123", + name: "Personal", + }, + { onElicitation: "accept-all" }, + ); + + expect(configured).toEqual({ ok: true, data: { configured: true } }); + expect(yield* executor.tools.invoke("executor.onepassword.getConfig", {})).toMatchObject({ + ok: true, + data: { config: { vaultId: "vault-123", name: "Personal" } }, + }); + + const removed = yield* executor.tools.invoke( + "executor.onepassword.removeConfig", + { targetScope: "test-scope" }, + { onElicitation: "accept-all" }, + ); + + expect(removed).toEqual({ ok: true, data: { removed: true } }); + expect(yield* executor.onepassword.getConfig()).toBeNull(); + }), + ); + it.effect("status reports not-configured before configure", () => Effect.gen(function* () { const { config: harnessConfig } = yield* TestWorkspace; diff --git a/packages/plugins/onepassword/src/sdk/plugin.ts b/packages/plugins/onepassword/src/sdk/plugin.ts index 606f5cae2..55d9adba9 100644 --- a/packages/plugins/onepassword/src/sdk/plugin.ts +++ b/packages/plugins/onepassword/src/sdk/plugin.ts @@ -3,14 +3,16 @@ import { Effect, Schema } from "effect"; import { definePlugin, StorageError, + ToolResult, + tool, type PluginCtx, type PluginBlobStore, type SecretProvider, + type StaticToolSchema, type StorageFailure, } from "@executor-js/sdk/core"; -import { OnePasswordConfig, Vault, ConnectionStatus } from "./types"; -import type { OnePasswordAuth } from "./types"; +import { OnePasswordAuth, OnePasswordConfig, Vault, ConnectionStatus } from "./types"; import { OnePasswordError } from "./errors"; import { makeOnePasswordService, type ResolvedAuth, type OnePasswordService } from "./service"; @@ -22,6 +24,61 @@ const CREDENTIAL_FIELD = "credential"; const DEFAULT_TIMEOUT_MS = 15_000; const CONFIG_KEY = "config"; +const schemaToStaticToolSchema = (schema: Schema.Decoder): StaticToolSchema => + Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema< + A, + I + >; + +const OnePasswordConfigureInput = Schema.Struct({ + scope: Schema.String, + auth: OnePasswordAuth, + vaultId: Schema.String, + name: Schema.String, +}); + +const OnePasswordConfigureOutput = Schema.Struct({ + configured: Schema.Boolean, +}); + +const OnePasswordGetConfigOutput = Schema.Struct({ + config: Schema.NullOr(OnePasswordConfig), +}); + +const OnePasswordListVaultsInput = OnePasswordAuth; + +const OnePasswordListVaultsOutput = Schema.Struct({ + vaults: Schema.Array(Vault), +}); + +const OnePasswordRemoveConfigInput = Schema.Struct({ + targetScope: Schema.String, +}); + +const OnePasswordRemoveConfigOutput = Schema.Struct({ + removed: Schema.Boolean, +}); + +const OnePasswordStatusOutput = ConnectionStatus; + +const OnePasswordConfigureInputStd = schemaToStaticToolSchema< + typeof OnePasswordConfigureInput.Type, + typeof OnePasswordConfigureInput.Encoded +>(OnePasswordConfigureInput); +const OnePasswordConfigureOutputStd = schemaToStaticToolSchema(OnePasswordConfigureOutput); +const OnePasswordGetConfigOutputStd = schemaToStaticToolSchema(OnePasswordGetConfigOutput); +const OnePasswordListVaultsInputStd = schemaToStaticToolSchema< + typeof OnePasswordListVaultsInput.Type, + typeof OnePasswordListVaultsInput.Encoded +>(OnePasswordListVaultsInput); +const OnePasswordListVaultsOutputStd = schemaToStaticToolSchema(OnePasswordListVaultsOutput); +const OnePasswordRemoveConfigInputStd = schemaToStaticToolSchema< + typeof OnePasswordRemoveConfigInput.Type, + typeof OnePasswordRemoveConfigInput.Encoded +>(OnePasswordRemoveConfigInput); +const OnePasswordRemoveConfigOutputStd = schemaToStaticToolSchema(OnePasswordRemoveConfigOutput); +const OnePasswordStatusOutputStd = schemaToStaticToolSchema(OnePasswordStatusOutput); + // --------------------------------------------------------------------------- // Shared failure alias. // @@ -304,6 +361,71 @@ export const onepasswordPlugin = definePlugin((options?: OnePasswordPluginOption extension: (ctx) => makeOnePasswordExtension(ctx, timeoutMs, preferSdk), + staticSources: (self) => [ + { + id: "onepassword", + kind: "executor", + name: "1Password", + tools: [ + tool({ + name: "status", + description: + "Check whether the 1Password secret provider is configured and can reach its selected vault. This returns status only, never secret values.", + outputSchema: OnePasswordStatusOutputStd, + execute: () => Effect.map(self.status(), ToolResult.ok), + }), + tool({ + name: "getConfig", + description: + "Read the current 1Password provider configuration. This returns account/vault metadata and secret ids only; service-account token values are never returned.", + outputSchema: OnePasswordGetConfigOutputStd, + execute: () => Effect.map(self.getConfig(), (config) => ToolResult.ok({ config })), + }), + tool({ + name: "listVaults", + description: + "List available 1Password vaults before configuring the provider. For service-account auth, first call `executor.coreTools.secrets.create` so the token is entered in the browser, then pass that token secret id here.", + inputSchema: OnePasswordListVaultsInputStd, + outputSchema: OnePasswordListVaultsOutputStd, + execute: (input) => + Effect.map(self.listVaults(input), (vaults) => ToolResult.ok({ vaults })), + }), + tool({ + name: "configure", + description: + "Configure the 1Password secret provider for a target executor scope. Use desktop-app auth for local biometric access, or service-account auth with a token secret id created through `executor.coreTools.secrets.create`; never ask the user to paste the token in chat.", + annotations: { + requiresApproval: true, + approvalDescription: "Configure the 1Password secret provider", + }, + inputSchema: OnePasswordConfigureInputStd, + outputSchema: OnePasswordConfigureOutputStd, + execute: (input) => + Effect.as( + self.configure( + { auth: input.auth, vaultId: input.vaultId, name: input.name }, + input.scope, + ), + ToolResult.ok({ configured: true }), + ), + }), + tool({ + name: "removeConfig", + description: + "Remove the 1Password provider configuration from a target scope. Existing secrets are not revealed; future 1Password secret resolution will stop until reconfigured.", + annotations: { + requiresApproval: true, + approvalDescription: "Remove the 1Password secret provider configuration", + }, + inputSchema: OnePasswordRemoveConfigInputStd, + outputSchema: OnePasswordRemoveConfigOutputStd, + execute: (input) => + Effect.as(self.removeConfig(input.targetScope), ToolResult.ok({ removed: true })), + }), + ], + }, + ], + secretProviders: (ctx) => [makeProvider(ctx, timeoutMs, preferSdk)], }; // HTTP transport (routes/handlers/extensionService) is layered on by diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 5adffef88..789f89120 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -237,6 +237,7 @@ describe("OpenAPI Plugin", () => { const tools = yield* executor.tools.list(); const ids = tools.map((t) => t.id); expect(ids).toContain("executor.openapi.previewSpec"); + expect(ids).toContain("executor.openapi.getSource"); expect(ids).toContain("executor.openapi.addSource"); }), ); @@ -288,6 +289,23 @@ describe("OpenAPI Plugin", () => { }), ); + it.effect("describes static previewSpec output from Standard Schema", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ + plugins: [openApiPlugin(), memorySecretsPlugin()] as const, + }), + ); + + const schema = yield* executor.tools.schema("executor.openapi.previewSpec"); + + expect(schema).not.toBeNull(); + expect(schema!.outputTypeScript).toContain("servers:"); + expect(schema!.outputTypeScript).toContain("securitySchemes:"); + expect(schema!.outputTypeScript).toContain("oauth2Presets:"); + }), + ); + it.effect("describes static addSource parameters from Standard Schema", () => Effect.gen(function* () { const executor = yield* createExecutor( @@ -330,7 +348,7 @@ describe("OpenAPI Plugin", () => { const result = unwrapInvocation( yield* executor.tools.invoke( "executor.openapi.addSource", - testApiSourceConfig({ scope: String(orgScope), namespace: "runtime" }), + testApiSourceConfig({ scope: "org", namespace: "runtime" }), autoApprove, ), ).data as { sourceId: string; toolCount: number }; @@ -340,10 +358,39 @@ describe("OpenAPI Plugin", () => { expect((yield* executor.openapi.getSource("runtime", String(orgScope)))?.scope).toBe( orgScope, ); + const inspected = unwrapInvocation( + yield* executor.tools.invoke( + "executor.openapi.getSource", + { namespace: "runtime", scope: "org" }, + autoApprove, + ), + ).data as { source: { namespace: string; scope: string } | null }; + expect(inspected.source).toMatchObject({ namespace: "runtime", scope: String(orgScope) }); expect((yield* executor.tools.list()).map((t) => t.id)).toContain("runtime.items.listItems"); }), ); + it.effect("static previewSpec returns actionable tool failures", () => + Effect.gen(function* () { + const config = makeTestConfig({ plugins: [openApiPlugin()] as const }); + const executor = yield* createExecutor(config); + + const result = yield* executor.tools.invoke("executor.openapi.previewSpec", { + spec: "not openapi", + }); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "openapi_parse_failed", + }, + }); + + yield* executor.close(); + yield* Effect.promise(() => config.testDb.close()); + }), + ); + it.effect("requires approval before adding a source through the runtime tool", () => Effect.gen(function* () { const executor = yield* createExecutor( @@ -872,7 +919,11 @@ describe("OpenAPI Plugin", () => { const remaining = yield* executor.tools.list(); const ids = remaining.map((t) => t.id).sort(); - expect(ids).toEqual(["executor.openapi.addSource", "executor.openapi.previewSpec"]); + expect(ids).toEqual([ + "executor.openapi.addSource", + "executor.openapi.getSource", + "executor.openapi.previewSpec", + ]); }), ); diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index c85ac60f1..9f885f5bc 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -324,16 +324,47 @@ const AddSourceOutputSchema = Schema.Struct({ sourceId: Schema.String, toolCount: Schema.Number, }); +const GetSourceInputSchema = Schema.Struct({ + namespace: Schema.String, + scope: Schema.String, +}); +const GetSourceOutputSchema = Schema.Struct({ + source: Schema.NullOr(Schema.Unknown), +}); const PreviewSpecInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(PreviewSpecInputSchema), ); +const PreviewSpecOutputStandardSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(SpecPreview), +); const AddSourceInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(AddSourceInputSchema), ); const AddSourceOutputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(AddSourceOutputSchema), ); +const GetSourceInputStandardSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(GetSourceInputSchema), +); +const GetSourceOutputStandardSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(GetSourceOutputSchema), +); + +const openApiToolFailure = (code: string, message: string, details?: unknown) => + ToolResult.fail({ + code, + message, + ...(details === undefined ? {} : { details }), + }); + +const resolveStaticScopeInput = ( + ctx: { readonly scopes: readonly { readonly id: ScopeId; readonly name: string }[] }, + value: string, +): string => + String( + ctx.scopes.find((scope) => scope.name === value || String(scope.id) === value)?.id ?? value, + ); // --------------------------------------------------------------------------- // Helpers @@ -1236,20 +1267,57 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { tools: [ tool({ name: "previewSpec", - description: "Preview an OpenAPI document before adding it as a source", + description: + "Preview an OpenAPI document before adding it as a source. Call this first when the user provides a spec URL/blob so you can inspect servers, auth schemes, operation count, and credential slots before `addSource`. Do not collect API keys or OAuth client secrets in chat; use `executor.coreTools.secrets.create` for those values.", inputSchema: PreviewSpecInputStandardSchema, - execute: (input) => Effect.map(self.previewSpec(input), ToolResult.ok), + outputSchema: PreviewSpecOutputStandardSchema, + execute: (input) => + self.previewSpec(input).pipe( + Effect.map(ToolResult.ok), + Effect.catchTags({ + OpenApiParseError: ({ message }) => + Effect.succeed(openApiToolFailure("openapi_parse_failed", message)), + OpenApiExtractionError: ({ message }) => + Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)), + OpenApiOAuthError: ({ message }) => + Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)), + }), + ), + }), + tool({ + name: "getSource", + description: + "Inspect an existing OpenAPI source, including effective base URL, configured headers/query params, OAuth settings, and stored credential slots. Use this before repairing an existing source with `sources.configure`, `secrets.create`, or `oauth.start`.", + inputSchema: GetSourceInputStandardSchema, + outputSchema: GetSourceOutputStandardSchema, + execute: (input, { ctx }) => + Effect.map( + self.getSource(input.namespace, resolveStaticScopeInput(ctx, input.scope)), + (source) => ToolResult.ok({ source }), + ), }), tool({ name: "addSource", - description: "Add an OpenAPI source and register its operations as tools", + description: + "Add an OpenAPI source and register its operations as tools. Recommended flow: call `previewSpec`, choose or confirm namespace/name/baseUrl from the preview (baseUrl is only needed when the spec cannot infer one or the user wants an override), use `secrets.create` for sensitive header/query/client-secret values, use `oauth.start` for browser OAuth sign-in, then call this tool and follow with `sources.configure` for per-scope credential bindings.", annotations: { requiresApproval: true, approvalDescription: "Add an OpenAPI source", }, inputSchema: AddSourceInputStandardSchema, outputSchema: AddSourceOutputStandardSchema, - execute: (input) => Effect.map(self.addSpec(input), ToolResult.ok), + execute: (input, { ctx }) => + self.addSpec({ ...input, scope: resolveStaticScopeInput(ctx, input.scope) }).pipe( + Effect.map(ToolResult.ok), + Effect.catchTags({ + OpenApiParseError: ({ message }) => + Effect.succeed(openApiToolFailure("openapi_parse_failed", message)), + OpenApiExtractionError: ({ message }) => + Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)), + OpenApiOAuthError: ({ message }) => + Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)), + }), + ), }), ], }, diff --git a/packages/react/src/components/mcp-install-card.test.ts b/packages/react/src/components/mcp-install-card.test.ts index ed173b13a..07ebb9996 100644 --- a/packages/react/src/components/mcp-install-card.test.ts +++ b/packages/react/src/components/mcp-install-card.test.ts @@ -72,6 +72,20 @@ describe("MCP install command rendering", () => { ).toBe("npx add-mcp 'executor mcp' --name executor"); }); + it("pins dev stdio install commands to the repo cwd", () => { + expect( + buildMcpInstallCommand({ + mode: "stdio", + isDev: true, + origin: null, + scopeDir: "/Users/rhyssullivan/src/executor/apps/local", + devCliCwd: "/Users/rhyssullivan/src/executor", + }), + ).toBe( + "npx add-mcp 'bun run --cwd /Users/rhyssullivan/src/executor dev:cli mcp --scope /Users/rhyssullivan/src/executor/apps/local' --name executor", + ); + }); + it("passes browser approval through stdio install commands when explicitly selected", () => { expect( buildMcpInstallCommand({ diff --git a/packages/react/src/components/mcp-install-card.tsx b/packages/react/src/components/mcp-install-card.tsx index 1e71f9e55..3502266db 100644 --- a/packages/react/src/components/mcp-install-card.tsx +++ b/packages/react/src/components/mcp-install-card.tsx @@ -21,6 +21,7 @@ const SUPPORTED_AGENTS = [ ] as const; const isDev = import.meta.env.DEV; +const devCliCwd = import.meta.env.VITE_EXECUTOR_DEV_CLI_CWD as string | undefined; const isLocal = typeof window !== "undefined" && (window.location.hostname === "localhost" || @@ -87,6 +88,7 @@ export const buildMcpInstallCommand = (input: { readonly password: string; } | null; readonly elicitationMode?: McpElicitationMode; + readonly devCliCwd?: string; }): string => { if (input.mode === "http") { const endpoint = buildMcpHttpEndpoint({ @@ -105,7 +107,11 @@ export const buildMcpInstallCommand = (input: { return parts.join(" "); } - const innerArgs = input.isDev ? ["bun", "run", "dev:cli", "mcp"] : ["executor", "mcp"]; + const innerArgs = input.isDev + ? input.devCliCwd + ? ["bun", "run", "--cwd", input.devCliCwd, "dev:cli", "mcp"] + : ["bun", "run", "dev:cli", "mcp"] + : ["executor", "mcp"]; if (input.scopeDir) { innerArgs.push("--scope", input.scopeDir); } @@ -149,12 +155,13 @@ export function McpInstallCard(props: { className?: string }) { scopeDir: scopeInfo.dir, desktop, elicitationMode, + devCliCwd, }); const subtitle = mode === "stdio" ? isDev - ? "Uses the repo-local dev CLI. Run from the repository root." + ? "Uses the repo-local dev CLI from any agent working directory." : "Requires the executor CLI on your PATH." : "Connect to executor as a remote MCP server over streamable HTTP."; diff --git a/packages/react/src/pages/secrets.test.ts b/packages/react/src/pages/secrets.test.ts new file mode 100644 index 000000000..281f357f3 --- /dev/null +++ b/packages/react/src/pages/secrets.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ScopeId } from "@executor-js/sdk/shared"; + +import { secretFormScopeId } from "./secrets"; + +describe("secretFormScopeId", () => { + it("uses the current route scope without an agent handoff target", () => { + const current = ScopeId.make("current-scope"); + + expect(secretFormScopeId(current)).toBe(current); + }); + + it("uses the agent handoff scope when one is present", () => { + expect(secretFormScopeId(ScopeId.make("current-scope"), { scope: "target-scope" })).toBe( + "target-scope", + ); + }); +}); diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index dc07db75f..cb1c47930 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -8,7 +8,7 @@ import { toast } from "sonner"; import { removeSecretOptimistic, secretsOptimisticAtom, secretUsagesAtom } from "../api/atoms"; import { secretWriteKeys } from "../api/reactivity-keys"; import { useSecretProviderPlugins } from "@executor-js/sdk/client"; -import { SecretId, SecretInUseError, type ScopeId } from "@executor-js/sdk/shared"; +import { ScopeId, SecretId, SecretInUseError } from "@executor-js/sdk/shared"; import { SecretForm } from "../plugins/secret-form"; import { useScope } from "../hooks/use-scope"; import { useScopeStack } from "../api/scope-context"; @@ -66,8 +66,12 @@ interface SecretPrefill { readonly name?: string; readonly secretId?: string; readonly provider?: string; + readonly scope?: string; } +export const secretFormScopeId = (currentScopeId: ScopeId, prefill?: SecretPrefill): ScopeId => + prefill?.scope ? ScopeId.make(prefill.scope) : currentScopeId; + function AddSecretDialog(props: { open: boolean; onOpenChange: (v: boolean) => void; @@ -257,6 +261,7 @@ export function SecretsPage(props: { const secretProviderPlugins = useSecretProviderPlugins(); const [addOpen, setAddOpen] = useState(props.prefill != null); const scopeId = useScope(); + const formScopeId = secretFormScopeId(scopeId, props.prefill); const scopeStack = useScopeStack(); const secrets = useAtomValue(secretsOptimisticAtom(scopeId)); const scopeLabel = (secretScopeId: ScopeId): string => { @@ -410,7 +415,7 @@ export function SecretsPage(props: { description={addSecretDescription} storageOptions={storageOptions} existingSecretIds={existingSecretIds} - scopeId={scopeId} + scopeId={formScopeId} prefill={props.prefill} /> diff --git a/scripts/agent-config-smoke.ts b/scripts/agent-config-smoke.ts new file mode 100644 index 000000000..5bab28383 --- /dev/null +++ b/scripts/agent-config-smoke.ts @@ -0,0 +1,340 @@ +#!/usr/bin/env bun +/** + * End-to-end smoke for agent-driven Executor configuration. + * + * This intentionally drives the public dev CLI for agent actions, while using + * the local HTTP browser/API boundary for sensitive values and OAuth callback + * completion. Fake test credentials are used, but the shape matches the real + * flow: agents get handoff URLs, users/browsers enter secrets and complete + * OAuth outside the context window, then agents verify and bind the resulting + * ids. + */ +import { mkdtemp, mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { serveOAuthTestServer, type OAuthTestServerShape } from "../packages/core/sdk/src/testing"; +import { + type GraphqlTestServerShape, + makeGreetingGraphqlSchema, + serveGraphqlTestServer, +} from "../packages/plugins/graphql/src/testing/index"; +import { Effect } from "effect"; + +const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const cliEntrypoint = join(repoRoot, "apps/cli/src/main.ts"); + +type CliContext = { + readonly dataDir: string; + readonly scopeDir: string; + readonly baseUrl: string; +}; + +type CliResult = { + readonly stdout: string; + readonly stderr: string; + readonly text: string; + readonly exitCode: number; +}; + +const assert = (condition: unknown, message: string): asserts condition => { + if (!condition) throw new Error(message); +}; + +const runCli = async ( + ctx: CliContext, + args: readonly string[], + options: { readonly allowFailure?: boolean } = {}, +): Promise => { + const proc = Bun.spawn([process.execPath, "run", cliEntrypoint, ...args], { + cwd: repoRoot, + env: { + ...process.env, + EXECUTOR_DEV: "1", + EXECUTOR_DATA_DIR: ctx.dataDir, + EXECUTOR_SCOPE_DIR: ctx.scopeDir, + }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + const result = { stdout, stderr, text: `${stdout}${stderr}`, exitCode }; + + if (exitCode !== 0 && !options.allowFailure) { + throw new Error(`CLI failed (${exitCode}) for ${args.join(" ")}\n${result.text}`); + } + + return result; +}; + +const parseJsonOutput = (result: CliResult): T => { + const start = result.stdout.indexOf("{"); + const end = result.stdout.lastIndexOf("}"); + assert(start >= 0 && end > start, `CLI output did not contain JSON:\n${result.text}`); + return JSON.parse(result.stdout.slice(start, end + 1)) as T; +}; + +const toolData = (value: unknown): T => { + const result = value as { readonly ok?: boolean; readonly data?: T; readonly error?: unknown }; + assert(result.ok === true, `Tool returned failure: ${JSON.stringify(value)}`); + return result.data as T; +}; + +const extractExecutionId = (text: string): string => { + const match = /executionId:\s*(exec_[A-Za-z0-9_]+)/.exec(text); + assert(match?.[1], `CLI output did not contain an execution id:\n${text}`); + return match[1]; +}; + +const callTool = (ctx: CliContext, path: readonly string[], args: unknown = {}) => + runCli(ctx, [ + "call", + ...path, + JSON.stringify(args), + "--base-url", + ctx.baseUrl, + "--scope", + ctx.scopeDir, + ]); + +const approvePausedCall = async (ctx: CliContext, paused: CliResult): Promise => { + const executionId = extractExecutionId(paused.text); + return await runCli(ctx, [ + "resume", + "--execution-id", + executionId, + "--base-url", + ctx.baseUrl, + "--scope", + ctx.scopeDir, + ]); +}; + +const postJson = async (url: string, payload: unknown): Promise => { + const response = await fetch(url, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + }); + const bodyText = await response.text(); + if (!response.ok) { + throw new Error(`POST ${url} failed with HTTP ${response.status}: ${bodyText}`); + } + return bodyText.length > 0 ? JSON.parse(bodyText) : null; +}; + +const firstScope = (value: unknown): { readonly id: string; readonly name: string } => { + const data = toolData<{ + readonly scopes: readonly { readonly id: string; readonly name: string }[]; + }>(value); + const scopes = data.scopes; + const scope = scopes?.[0]; + assert(scope, `scopes.list returned no scopes: ${JSON.stringify(value)}`); + return scope; +}; + +const createSecretPlaceholder = async ( + ctx: CliContext, + scope: string, + expectedHandoffScope: string, + name: string, +) => { + const result = toolData<{ readonly id: string; readonly url: string }>( + parseJsonOutput( + await callTool(ctx, ["executor", "coreTools", "secrets", "create"], { name, scope }), + ), + ); + const handoff = new URL(result.url); + assert(handoff.pathname === "/secrets", `Expected /secrets handoff URL, got ${result.url}`); + assert( + handoff.searchParams.get("scope") === expectedHandoffScope, + `Secret handoff omitted scope: ${result.url}`, + ); + return result; +}; + +const setSecretViaBrowserBoundary = async ( + ctx: CliContext, + browserBaseUrl: string, + scopeId: string, + input: { readonly id: string; readonly name: string; readonly value: string }, +) => { + await postJson(`${browserBaseUrl}/api/scopes/${encodeURIComponent(scopeId)}/secrets`, input); + const status = toolData<{ readonly status: "resolved" | "missing" }>( + parseJsonOutput( + await callTool(ctx, ["executor", "coreTools", "secrets", "status"], { id: input.id }), + ), + ); + assert(status.status === "resolved", `Secret ${input.id} was not resolved`); +}; + +const runSmoke = async (oauth: OAuthTestServerShape, graph: GraphqlTestServerShape) => { + const root = await mkdtemp(join(tmpdir(), "executor-agent-config-smoke-")); + const ctx: CliContext = { + dataDir: join(root, "data"), + scopeDir: join(root, "scope"), + baseUrl: `http://127.0.0.1:${64180 + Math.floor(Math.random() * 1000)}`, + }; + await mkdir(ctx.dataDir, { recursive: true }); + await mkdir(ctx.scopeDir, { recursive: true }); + + try { + console.log("[agent-config-smoke] start dev CLI daemon"); + const port = new URL(ctx.baseUrl).port; + await runCli(ctx, ["daemon", "run", "--port", port, "--scope", ctx.scopeDir]); + + console.log("[agent-config-smoke] discover scope"); + const scope = firstScope( + parseJsonOutput(await callTool(ctx, ["executor", "coreTools", "scopes", "list"])), + ); + + console.log("[agent-config-smoke] create browser secret handoffs"); + const clientId = await createSecretPlaceholder(ctx, ctx.scopeDir, scope.id, "OAuth client id"); + const clientSecret = await createSecretPlaceholder( + ctx, + ctx.scopeDir, + scope.id, + "OAuth client secret", + ); + const browserBaseUrl = new URL(clientId.url).origin; + await setSecretViaBrowserBoundary(ctx, browserBaseUrl, scope.id, { + id: clientId.id, + name: "OAuth client id", + value: "test-client", + }); + await setSecretViaBrowserBoundary(ctx, browserBaseUrl, scope.id, { + id: clientSecret.id, + name: "OAuth client secret", + value: "test-secret", + }); + + console.log("[agent-config-smoke] start OAuth handoff through core tool"); + const oauthStart = toolData<{ + readonly sessionId: string; + readonly authorizationUrl: string | null; + }>( + parseJsonOutput( + await callTool(ctx, ["executor", "coreTools", "oauth", "start"], { + scope: ctx.scopeDir, + endpoint: oauth.resourceUrl, + connectionId: "agent-smoke-oauth", + pluginId: "graphql", + identityLabel: "Agent Smoke OAuth", + strategy: { + kind: "authorization-code", + authorizationEndpoint: oauth.authorizationEndpoint, + tokenEndpoint: oauth.tokenEndpoint, + clientIdSecretId: clientId.id, + clientSecretSecretId: clientSecret.id, + scopes: ["read"], + }, + }), + ), + ); + assert(oauthStart.authorizationUrl, "OAuth start did not return an authorization URL"); + + console.log("[agent-config-smoke] complete OAuth callback through browser URL"); + const callback = await Effect.runPromise( + oauth.completeAuthorizationCodeFlow({ + authorizationUrl: oauthStart.authorizationUrl, + }), + ); + const callbackResponse = await fetch(callback.callbackUrl); + const callbackHtml = await callbackResponse.text(); + assert(callbackResponse.ok, `OAuth callback failed: ${callbackHtml}`); + assert( + callbackHtml.includes("Authentication complete"), + "OAuth callback did not render success", + ); + + const callbackBaseUrl = new URL(callback.callbackUrl).origin; + const pollResponse = await fetch( + `${callbackBaseUrl}/api/oauth/await/${encodeURIComponent(callback.state)}`, + ); + const pollResult = (await pollResponse.json()) as { readonly ok?: boolean }; + assert(pollResult.ok === true, `OAuth await result was not ok: ${JSON.stringify(pollResult)}`); + + const connections = toolData<{ + readonly connections: readonly { readonly id: string }[]; + }>(parseJsonOutput(await callTool(ctx, ["executor", "coreTools", "connections", "list"]))); + assert( + connections.connections.some((connection) => connection.id === "agent-smoke-oauth"), + `OAuth connection was not listed: ${JSON.stringify(connections)}`, + ); + + console.log("[agent-config-smoke] add OAuth-backed GraphQL source through approval flow"); + const addSource = await approvePausedCall( + ctx, + await callTool(ctx, ["executor", "graphql", "addSource"], { + scope: ctx.scopeDir, + endpoint: graph.endpoint, + name: "Agent Smoke GraphQL", + namespace: "agent_smoke_graphql", + oauth2: { + kind: "oauth2", + securitySchemeName: "OAuth2", + flow: "authorizationCode", + tokenUrl: oauth.tokenEndpoint, + authorizationUrl: oauth.authorizationEndpoint, + clientIdSlot: "auth:oauth2:client-id", + clientSecretSlot: null, + connectionSlot: "auth:oauth2:connection", + scopes: ["read"], + }, + credentials: { + scope: scope.id, + auth: { + oauth2: { + connection: { kind: "connection", connectionId: "agent-smoke-oauth" }, + }, + }, + }, + }), + ); + const added = parseJsonOutput<{ readonly ok: boolean }>(addSource); + assert(added.ok === true, `GraphQL addSource failed: ${addSource.text}`); + + console.log("[agent-config-smoke] invoke configured OAuth-backed tool"); + const invoked = parseJsonOutput<{ readonly ok: boolean; readonly data?: unknown }>( + await callTool(ctx, ["agent_smoke_graphql", "query", "hello"], { name: "Ada" }), + ); + assert( + invoked.ok === true && + JSON.stringify(invoked.data) === JSON.stringify({ hello: "Hello Ada" }), + `GraphQL invocation failed: ${JSON.stringify(invoked)}`, + ); + + console.log("[agent-config-smoke] passed"); + } finally { + await runCli(ctx, ["daemon", "stop", "--base-url", ctx.baseUrl], { + allowFailure: true, + }); + await rm(root, { recursive: true, force: true }); + } +}; + +const main = async () => { + await Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const oauth = yield* serveOAuthTestServer(); + const graph = yield* serveGraphqlTestServer({ + schema: makeGreetingGraphqlSchema(), + auth: { + validateAuthorization: oauth.acceptsAuthorizationHeader, + wwwAuthenticate: `Bearer resource_metadata="${oauth.protectedResourceMetadataUrl}/graphql"`, + }, + }); + yield* Effect.promise(() => runSmoke(oauth, graph)); + }), + ), + ); +}; + +await main(); From 81aef11af6a0d53d5245f381dfd8d4d7eaa8d664 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 22:19:32 -0700 Subject: [PATCH 04/15] feat: expose source presets to agents --- packages/core/sdk/src/core-tools.ts | 58 ++++++++++++++++++- packages/core/sdk/src/executor.test.ts | 23 ++++++++ packages/core/sdk/src/executor.ts | 7 +++ packages/core/sdk/src/plugin.ts | 24 ++++++++ .../google-discovery/src/sdk/plugin.ts | 2 + packages/plugins/graphql/src/sdk/plugin.ts | 2 + packages/plugins/mcp/src/sdk/plugin.ts | 12 ++++ packages/plugins/openapi/src/sdk/plugin.ts | 2 + 8 files changed, 129 insertions(+), 1 deletion(-) diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index f75f39156..83b425bdf 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -115,6 +115,31 @@ const SourcesDetectOutput = Schema.Struct({ results: Schema.Array(SourceDetectionResult), }); +const SourcePresetOutput = Schema.Struct({ + pluginId: Schema.String, + id: Schema.String, + name: Schema.String, + summary: Schema.String, + url: Schema.optional(Schema.String), + icon: Schema.optional(Schema.String), + featured: Schema.optional(Schema.Boolean), + transport: Schema.optional(Schema.Literals(["remote", "stdio"])), + command: Schema.optional(Schema.String), + args: Schema.optional(Schema.Array(Schema.String)), + env: Schema.optional(Schema.Record(Schema.String, Schema.String)), +}); + +const SourcesPresetsInput = Schema.Struct({ + query: Schema.optional(Schema.String), + pluginId: Schema.optional(Schema.String), + featuredOnly: Schema.optional(Schema.Boolean), + limit: Schema.optional(Schema.Number), +}); + +const SourcesPresetsOutput = Schema.Struct({ + presets: Schema.Array(SourcePresetOutput), +}); + const SourcesConfigureSchemasOutput = Schema.Struct({ schemas: Schema.Array( Schema.Struct({ @@ -304,6 +329,11 @@ const SourcesDetectInputStd = schemaToStandard< typeof SourcesDetectInput.Encoded >(SourcesDetectInput); const SourcesDetectOutputStd = schemaToStandard(SourcesDetectOutput); +const SourcesPresetsInputStd = schemaToStandard< + typeof SourcesPresetsInput.Type, + typeof SourcesPresetsInput.Encoded +>(SourcesPresetsInput); +const SourcesPresetsOutputStd = schemaToStandard(SourcesPresetsOutput); const SourcesConfigureSchemasOutputStd = schemaToStandard(SourcesConfigureSchemasOutput); const SourcesConfigureInputStd = schemaToStandard< typeof SourcesConfigureInput.Type, @@ -590,12 +620,38 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "sources.detect", description: - "Detect which plugin can add or configure a URL. Use this when the user gives a URL but not a source type; then call the matching plugin add tool such as `openapi.previewSpec` + `openapi.addSource`, `graphql.addSource`, or `mcp.addSource`.", + "Detect which plugin can add or configure a URL. This is the same URL auto-detection used by the Executor web Connect dialog. Use this when the user gives a URL but not a source type; then call the matching plugin add tool such as `openapi.previewSpec` + `openapi.addSource`, `graphql.addSource`, or `mcp.addSource`.", inputSchema: SourcesDetectInputStd, outputSchema: SourcesDetectOutputStd, execute: (input, { ctx }) => Effect.map(ctx.core.sources.detect(input.url), (results) => ({ results })), }), + tool({ + name: "sources.presets", + description: + "List the same popular source presets shown in Executor web's Connect dialog. Use this before asking the user what to connect; filter with `query` for names like GitHub, Stripe, Axiom, Google Calendar, Linear, or OpenAI. Use the returned `pluginId`, `url`, and optional stdio command to choose the matching add flow (`openapi.previewSpec`/`openapi.addSource`, `graphql.addSource`, `mcp.probeEndpoint`/`mcp.addSource`, or `googleDiscovery.probeDiscovery`/`googleDiscovery.addSource`).", + inputSchema: SourcesPresetsInputStd, + outputSchema: SourcesPresetsOutputStd, + execute: (input, { ctx }) => + Effect.sync(() => { + const query = input.query?.trim().toLowerCase() ?? ""; + const pluginId = input.pluginId?.trim(); + const featuredOnly = input.featuredOnly ?? false; + const limit = Math.max(0, Math.trunc(input.limit ?? 50)); + const presets = ctx.core.sources + .presets() + .filter((preset) => (pluginId ? preset.pluginId === pluginId : true)) + .filter((preset) => (featuredOnly ? preset.featured === true : true)) + .filter((preset) => { + if (query.length === 0) return true; + const corpus = + `${preset.name} ${preset.summary} ${preset.pluginId} ${preset.id}`.toLowerCase(); + return corpus.includes(query); + }) + .slice(0, limit); + return { presets }; + }), + }), tool({ name: "sources.configureSchemas", description: diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index 3024dccf2..22ee75461 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -196,6 +196,15 @@ const caseSensitiveDynamicPlugin = definePlugin(() => ({ const configurableSourcePlugin = definePlugin(() => ({ id: "configurable" as const, + sourcePresets: [ + { + id: "configurable-demo", + name: "Configurable Demo", + summary: "Demo source preset for agent and web discovery.", + url: "https://example.com/configurable.json", + featured: true, + }, + ], storage: ({ pluginStorage }) => ({ get: (scope: string, sourceId = "configured-source") => pluginStorage.getAtScope<{ readonly header: string; readonly sourceScope: string }>({ @@ -511,6 +520,20 @@ describe("createExecutor", () => { expect.objectContaining({ pluginId: "configurable", type: "configurable" }), ]), }); + const presets = yield* executor.tools.invoke("executor.coreTools.sources.presets", { + query: "demo", + }); + expect(presets).toMatchObject({ + presets: [ + expect.objectContaining({ + pluginId: "configurable", + id: "configurable-demo", + name: "Configurable Demo", + url: "https://example.com/configurable.json", + featured: true, + }), + ], + }); yield* executor.tools.invoke("executor.coreTools.sources.configure", { source: { id: "configured-source", scope: "org" }, diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 2aa4e2f39..0d909e8f6 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -3134,6 +3134,13 @@ export const createExecutor = + Array.from(runtimes.values()).flatMap(({ plugin }) => + (plugin.sourcePresets ?? []).map((preset) => ({ + ...preset, + pluginId: plugin.id, + })), + ), }, policies: { list: () => policiesList(), diff --git a/packages/core/sdk/src/plugin.ts b/packages/core/sdk/src/plugin.ts index d5d5415eb..2972d92af 100644 --- a/packages/core/sdk/src/plugin.ts +++ b/packages/core/sdk/src/plugin.ts @@ -148,6 +148,7 @@ export interface PluginCtx { * so agent-facing configuration surfaces can describe the * plugin-specific payload shape before dispatching a write. */ readonly configureSchemas: () => readonly SourceConfigureSchema[]; + readonly presets: () => readonly SourcePresetCatalogEntry[]; }; readonly policies: { readonly list: () => Effect.Effect; @@ -442,6 +443,23 @@ export interface SourceConfigureSchema { readonly schema?: unknown; } +export interface SourcePreset { + readonly id: string; + readonly name: string; + readonly summary: string; + readonly url?: string; + readonly icon?: string; + readonly featured?: boolean; + readonly transport?: "remote" | "stdio"; + readonly command?: string; + readonly args?: readonly string[]; + readonly env?: Readonly>; +} + +export interface SourcePresetCatalogEntry extends SourcePreset { + readonly pluginId: string; +} + // --------------------------------------------------------------------------- // PluginSpec — what a `definePlugin(factory)` call returns. // --------------------------------------------------------------------------- @@ -503,6 +521,12 @@ export interface PluginSpec< * sync. */ readonly clientConfig?: unknown; + /** Source presets shown by the web UI's "Popular sources" list and + * exposed through core tools for agents. Keep this server-safe: + * no React components, only JSON-serializable metadata and optional + * add-flow hints such as URL or stdio command. */ + readonly sourcePresets?: readonly SourcePreset[]; + /** Build the plugin's extension API. The returned object becomes * `executor[plugin.id]` and is also the `self` passed to * `staticSources`. Field order matters: `extension` MUST appear diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts index 2b3671f56..3e3d9da57 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -19,6 +19,7 @@ import { makeGoogleDiscoveryStore, type GoogleDiscoveryStore, } from "./binding-store"; +import { googleDiscoveryPresets } from "./presets"; import { extractGoogleDiscoveryManifest } from "./document"; import { annotationsForOperation, invokeGoogleDiscoveryTool } from "./invoke"; import { GoogleDiscoveryParseError, GoogleDiscoverySourceError } from "./errors"; @@ -512,6 +513,7 @@ export type GoogleDiscoveryPluginExtension = ReturnType ({ id: "googleDiscovery" as const, packageName: "@executor-js/plugin-google-discovery", + sourcePresets: googleDiscoveryPresets, schema: googleDiscoverySchema, storage: (deps) => makeGoogleDiscoveryStore(deps), diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 10a0d4e45..b59e0ed7a 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -40,6 +40,7 @@ import { import { extract } from "./extract"; import { GraphqlIntrospectionError, GraphqlInvocationError } from "./errors"; import { invokeWithLayer } from "./invoke"; +import { graphqlPresets } from "./presets"; import { graphqlSchema, makeDefaultGraphqlStore, @@ -918,6 +919,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { return { id: "graphql" as const, packageName: "@executor-js/plugin-graphql", + sourcePresets: graphqlPresets, schema: graphqlSchema, storage: (deps): GraphqlStore => makeDefaultGraphqlStore(deps), diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 253190f46..1464ef0b8 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -48,6 +48,7 @@ import { discoverTools } from "./discover"; import { McpConnectionError, McpInvocationError, McpToolDiscoveryError } from "./errors"; import { invokeMcpTool } from "./invoke"; import { deriveMcpNamespace, type McpToolManifest, type McpToolManifestEntry } from "./manifest"; +import { mcpPresets } from "./presets"; import { probeMcpEndpointShape, type McpShapeProbeResult } from "./probe-shape"; import { MCP_OAUTH_CLIENT_ID_SLOT, @@ -1095,6 +1096,17 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { return { id: "mcp" as const, packageName: "@executor-js/plugin-mcp", + sourcePresets: allowStdio + ? mcpPresets.map((preset) => ({ + ...preset, + transport: "transport" in preset ? preset.transport : "remote", + })) + : mcpPresets + .filter((preset) => !("transport" in preset && preset.transport === "stdio")) + .map((preset) => ({ + ...preset, + transport: "remote" as const, + })), // Surfaced to the client bundle via the Vite plugin (see // `@executor-js/vite-plugin`). The MCP `./client` factory reads // `allowStdio` and gates the stdio tab + presets in AddMcpSource — diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 9f885f5bc..ffe9d0a78 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -32,6 +32,7 @@ import { extract } from "./extract"; import { compileToolDefinitions, type ToolDefinition } from "./definitions"; import { annotationsForOperation, invokeWithLayer } from "./invoke"; import { previewSpec, SpecPreview } from "./preview"; +import { openApiPresets } from "./presets"; import { makeDefaultOpenapiStore, openapiSchema, @@ -1140,6 +1141,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { return { id: "openapi" as const, packageName: "@executor-js/plugin-openapi", + sourcePresets: openApiPresets, schema: openapiSchema, storage: (deps): OpenapiStore => makeDefaultOpenapiStore(deps), From f4e97dba7c790d1ee52d535b0c51218870fb730c Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 23:30:14 -0700 Subject: [PATCH 05/15] fix: resolve agent source install scopes --- apps/cloud/src/mcp-miniflare.e2e.node.test.ts | 2 +- apps/desktop/scripts/smoke-sidecar.ts | 4 +- packages/core/sdk/src/index.ts | 2 +- packages/core/sdk/src/scope.ts | 13 +++++ .../google-discovery/src/sdk/plugin.ts | 36 ++++++++++--- .../plugins/graphql/src/sdk/plugin.test.ts | 17 ++++-- packages/plugins/graphql/src/sdk/plugin.ts | 38 ++++++++++--- packages/plugins/mcp/src/sdk/plugin.test.ts | 2 +- packages/plugins/mcp/src/sdk/plugin.ts | 54 +++++++++++++++---- .../plugins/openapi/src/sdk/plugin.test.ts | 18 +++++-- packages/plugins/openapi/src/sdk/plugin.ts | 31 ++++++++--- 11 files changed, 173 insertions(+), 44 deletions(-) diff --git a/apps/cloud/src/mcp-miniflare.e2e.node.test.ts b/apps/cloud/src/mcp-miniflare.e2e.node.test.ts index 429a4a79c..7686b8086 100644 --- a/apps/cloud/src/mcp-miniflare.e2e.node.test.ts +++ b/apps/cloud/src/mcp-miniflare.e2e.node.test.ts @@ -782,7 +782,7 @@ layer(TestEnv, { timeout: 60_000 })("cloud MCP over real HTTP (miniflare)", (it) // `HttpApiGroup` name ("approve") becomes part of the sandbox path, // so the invocation reads `tools.approveapi.approve.approveThing`. const code = [ - `await tools.executor.openapi.addSource({ scope: ${JSON.stringify(orgId)}, name: "Approve API", baseUrl: ${JSON.stringify(upstreamBaseUrl)}, spec: { kind: "blob", value: ${JSON.stringify(specJson)} }, namespace: "approveapi" });`, + `await tools.executor.openapi.addSource({ name: "Approve API", baseUrl: ${JSON.stringify(upstreamBaseUrl)}, spec: { kind: "blob", value: ${JSON.stringify(specJson)} }, namespace: "approveapi" });`, `return await tools.approveapi.approve.approveThing({});`, ].join("\n"); const result = yield* Effect.promise(() => diff --git a/apps/desktop/scripts/smoke-sidecar.ts b/apps/desktop/scripts/smoke-sidecar.ts index 759a813c3..74a32f419 100644 --- a/apps/desktop/scripts/smoke-sidecar.ts +++ b/apps/desktop/scripts/smoke-sidecar.ts @@ -234,8 +234,8 @@ const main = async () => { // inside QuickJS. const code = ` await tools.executor.openapi.addSource({ - scope: ${JSON.stringify(scopeDir)}, - spec: ${JSON.stringify(`${openapi.origin}/openapi.json`)}, + spec: { kind: "url", url: ${JSON.stringify(`${openapi.origin}/openapi.json`)} }, + name: "Petstore Smoke API", baseUrl: ${JSON.stringify(openapi.origin)}, namespace: "petstore", }); diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 2e8fd6593..2d995f8f6 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -41,7 +41,7 @@ export { StorageError, UniqueViolationError, isStorageFailure } from "./fuma-run export { ScopeId, ToolId, SecretId, PolicyId, ConnectionId, CredentialBindingId } from "./ids"; // Scope -export { Scope } from "./scope"; +export { Scope, defaultSourceInstallScopeId } from "./scope"; // Errors (tagged) export { diff --git a/packages/core/sdk/src/scope.ts b/packages/core/sdk/src/scope.ts index fdbc58397..9fd34cacd 100644 --- a/packages/core/sdk/src/scope.ts +++ b/packages/core/sdk/src/scope.ts @@ -8,3 +8,16 @@ export const Scope = Schema.Struct({ createdAt: Schema.Date, }); export type Scope = typeof Scope.Type; + +/** + * Source-add flows that do not expose a user-facing placement choice install + * sources at the outermost visible scope. Local executors have one scope, while + * cloud executors use an innermost personal scope plus an outer organization + * scope where shared sources live. + */ +export const defaultSourceInstallScopeId = ( + scopes: readonly { readonly id: ScopeId | string }[], +): string | null => { + const scope = scopes[scopes.length - 1]; + return scope ? String(scope.id) : null; +}; diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts index 3e3d9da57..a82868c1b 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -5,6 +5,7 @@ import { SourceDetectionResult, ToolResult, Usage, + defaultSourceInstallScopeId, definePlugin, tool, resolveSecretBackedMap, @@ -154,11 +155,22 @@ const GoogleDiscoveryAddSourceInputSchema = Schema.Struct({ namespace: Schema.optional(Schema.String), auth: GoogleDiscoveryAuth, }); +const GoogleDiscoveryStaticAddSourceInputSchema = Schema.Struct({ + name: Schema.String, + discoveryUrl: Schema.String, + credentials: Schema.optional(GoogleDiscoveryFetchCredentials), + namespace: Schema.optional(Schema.String), + auth: GoogleDiscoveryAuth, +}); export type GoogleDiscoveryProbeInput = typeof GoogleDiscoveryProbeInputSchema.Type; export type GoogleDiscoveryAddSourceInput = typeof GoogleDiscoveryAddSourceInputSchema.Type; const GoogleDiscoveryAddSourceOutputSchema = Schema.Struct({ namespace: Schema.String, + source: Schema.Struct({ + id: Schema.String, + scope: Schema.String, + }), toolCount: Schema.Number, }); @@ -189,7 +201,7 @@ const GoogleDiscoveryProbeOutputStandardSchema = schemaToStaticToolSchema( GoogleDiscoveryProbeOutputSchema, ); const GoogleDiscoveryAddSourceInputStandardSchema = schemaToStaticToolSchema( - GoogleDiscoveryAddSourceInputSchema, + GoogleDiscoveryStaticAddSourceInputSchema, ); const GoogleDiscoveryAddSourceOutputStandardSchema = schemaToStaticToolSchema( GoogleDiscoveryAddSourceOutputSchema, @@ -536,7 +548,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ tool({ name: "addSource", description: - 'Add a Google Discovery source and register its operations as tools. Recommended flow: call `probeDiscovery`, create any OAuth client id/client secret values through `secrets.create`, call `oauth.start` in the browser for OAuth sources, then pass `{kind:"oauth2", connectionId, clientIdSecretId, clientSecretSecretId, scopes}` or `{kind:"none"}` here.', + 'Add a Google Discovery source and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. Recommended flow: call `probeDiscovery`, create any OAuth client id/client secret values through `secrets.create` at the user\'s chosen credential scope, call `oauth.start` in the browser for OAuth sources, then pass `{kind:"oauth2", connectionId, clientIdSecretId, clientSecretSecretId, scopes}` or `{kind:"none"}` here.', annotations: { requiresApproval: true, approvalDescription: "Add a Google Discovery source", @@ -544,10 +556,22 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ inputSchema: GoogleDiscoveryAddSourceInputStandardSchema, outputSchema: GoogleDiscoveryAddSourceOutputStandardSchema, execute: (input, { ctx }) => { - const args = input as GoogleDiscoveryAddSourceInput; - return Effect.map( - self.addSource({ ...args, scope: resolveStaticScopeInput(ctx, args.scope) }), - ToolResult.ok, + const args = input as typeof GoogleDiscoveryStaticAddSourceInputSchema.Type; + const sourceScope = defaultSourceInstallScopeId(ctx.scopes); + if (sourceScope === null) { + return Effect.succeed( + ToolResult.fail({ + code: "source_scope_unavailable", + message: + "Cannot add a Google Discovery source because this executor has no source install scope.", + }), + ); + } + return Effect.map(self.addSource({ ...args, scope: sourceScope }), (result) => + ToolResult.ok({ + ...result, + source: { id: result.namespace, scope: sourceScope }, + }), ); }, }), diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 240d1adf4..317056875 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -771,7 +771,6 @@ describe("graphqlPlugin", () => { const result = yield* executor.tools.invoke( "executor.graphql.addSource", { - scope: "org", endpoint: "http://localhost:4000/graphql", name: "Via Static", introspectionJson, @@ -779,7 +778,14 @@ describe("graphqlPlugin", () => { }, { onElicitation: "accept-all" }, ); - expect(result).toEqual({ ok: true, data: { toolCount: 2, namespace: "via_static" } }); + expect(result).toEqual({ + ok: true, + data: { + namespace: "via_static", + source: { id: "via_static", scope: String(orgScope) }, + toolCount: 2, + }, + }); expect(yield* executor.graphql.getSource("via_static", String(userScope))).toBeNull(); expect((yield* executor.graphql.getSource("via_static", String(orgScope)))?.scope).toBe( orgScope, @@ -807,7 +813,6 @@ describe("graphqlPlugin", () => { const result = yield* executor.tools.invoke( "executor.graphql.addSource", { - scope: TEST_SCOPE, endpoint: "http://127.0.0.1:1/graphql", name: "Broken GraphQL", namespace: "broken_graphql", @@ -834,8 +839,11 @@ describe("graphqlPlugin", () => { const schema = yield* executor.tools.schema("executor.graphql.addSource"); expect(schema).not.toBeNull(); - expect(schema!.inputTypeScript).toContain("scope: string"); expect(schema!.inputTypeScript).toContain("endpoint: string"); + expect(schema!.inputTypeScript).toContain("credentials?: { scope: string"); + expect( + (schema!.inputSchema as { properties?: Record }).properties, + ).not.toHaveProperty("scope"); expect( (schema!.inputSchema as { properties?: Record }).properties, ).not.toHaveProperty("targetScope"); @@ -861,7 +869,6 @@ describe("graphqlPlugin", () => { "executor.graphql.addSource", { endpoint: server.endpoint, - scope: TEST_SCOPE, introspectionJson, namespace: "runtime_graphql", }, diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index b59e0ed7a..0cce568dc 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -7,6 +7,7 @@ import { type CredentialBindingValue, definePlugin, tool, + defaultSourceInstallScopeId, ScopeId, SourceDetectionResult, StorageError, @@ -121,7 +122,6 @@ const GraphqlInitialCredentialsInputSchema = Schema.Struct({ type GraphqlInitialCredentialsInput = typeof GraphqlInitialCredentialsInputSchema.Type; const StaticAddSourceInputSchema = Schema.Struct({ - scope: Schema.String, endpoint: Schema.String, name: Schema.String, introspectionJson: Schema.optional(Schema.String), @@ -150,7 +150,16 @@ const StaticAddSourceInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(StaticAddSourceInputSchema), ); const StaticAddSourceOutputStandardSchema = Schema.toStandardSchemaV1( - Schema.toStandardJSONSchemaV1(Schema.Struct({ toolCount: Schema.Number })), + Schema.toStandardJSONSchemaV1( + Schema.Struct({ + namespace: Schema.String, + source: Schema.Struct({ + id: Schema.String, + scope: Schema.String, + }), + toolCount: Schema.Number, + }), + ), ); const StaticGetSourceInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(StaticGetSourceInputSchema), @@ -967,23 +976,38 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { tool({ name: "addSource", description: - "Add a GraphQL endpoint and register its operations as tools. For API keys or bearer tokens, first call `executor.coreTools.secrets.create` and pass secret refs through `credentials`. For OAuth, start the browser flow with `executor.coreTools.oauth.start`, verify completion with `connections.list`, then bind the connection via this input or `sources.configure`.", + "Add a GraphQL endpoint and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. For API keys or bearer tokens, first call `executor.coreTools.secrets.create` at the user's chosen credential scope and pass secret refs through `credentials`. For OAuth, start the browser flow with `executor.coreTools.oauth.start`, verify completion with `connections.list`, then bind the connection through `credentials` or `sources.configure`.", annotations: { requiresApproval: true, approvalDescription: "Add a GraphQL source", }, inputSchema: StaticAddSourceInputStandardSchema, outputSchema: StaticAddSourceOutputStandardSchema, - execute: (input, { ctx }) => - self.addSource({ ...input, scope: resolveStaticScopeInput(ctx, input.scope) }).pipe( - Effect.map(ToolResult.ok), + execute: (input, { ctx }) => { + const sourceScope = defaultSourceInstallScopeId(ctx.scopes); + if (sourceScope === null) { + return Effect.succeed( + graphqlToolFailure( + "source_scope_unavailable", + "Cannot add a GraphQL source because this executor has no source install scope.", + ), + ); + } + return self.addSource({ ...input, scope: sourceScope }).pipe( + Effect.map((result) => + ToolResult.ok({ + ...result, + source: { id: result.namespace, scope: sourceScope }, + }), + ), Effect.catchTags({ GraphqlIntrospectionError: ({ message }) => Effect.succeed(graphqlToolFailure("graphql_introspection_failed", message)), GraphqlExtractionError: ({ message }) => Effect.succeed(graphqlToolFailure("graphql_extraction_failed", message)), }), - ), + ); + }, }), ], }, diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index ef7d814bf..3e3f672a8 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -289,7 +289,6 @@ describe("mcpPlugin", () => { "executor.mcp.addSource", { transport: "remote", - scope: "test-scope", name: "broken static", endpoint: "http://127.0.0.1:1/mcp", remoteTransport: "auto", @@ -302,6 +301,7 @@ describe("mcpPlugin", () => { ok: true, data: { namespace: "broken_static_source", + source: { id: "broken_static_source", scope: "test-scope" }, toolCount: 0, discovery: { status: "failed", diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 1464ef0b8..5deb88757 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -21,6 +21,7 @@ import { ScopeId, SourceDetectionResult, ToolResult, + defaultSourceInstallScopeId, definePlugin, tool, resolveSecretBackedMap as resolveSharedSecretBackedMap, @@ -163,7 +164,6 @@ const McpInitialCredentialsInputSchema = Schema.Struct({ type McpInitialCredentialsInput = typeof McpInitialCredentialsInputSchema.Type; const McpRemoteAddSourceInputSchema = Schema.Struct({ - scope: Schema.String, transport: Schema.Literal("remote"), name: Schema.String, endpoint: Schema.String, @@ -176,7 +176,6 @@ const McpRemoteAddSourceInputSchema = Schema.Struct({ }); const McpStdioAddSourceInputSchema = Schema.Struct({ - scope: Schema.String, transport: Schema.Literal("stdio"), name: Schema.String, command: Schema.String, @@ -193,6 +192,10 @@ const McpAddSourceInputSchema = Schema.Union([ const McpAddSourceOutputSchema = Schema.Struct({ namespace: Schema.String, + source: Schema.Struct({ + id: Schema.String, + scope: Schema.String, + }), toolCount: Schema.Number, discovery: Schema.optional( Schema.Struct({ @@ -1722,7 +1725,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { tool({ name: "addSource", description: - "Add an MCP source and register its tools. For remote OAuth-protected servers, first use `probeEndpoint` and the core OAuth browser handoff (`oauth.probe`, `oauth.start`), then bind the completed connection with `sources.configure` if needed. For header/API-key auth, first call `secrets.create` so the value is entered in the browser, then pass the secret reference in `credentials`. Remote sources are still saved if discovery fails; inspect the returned `discovery` field and use `sources.refresh` after credentials or network access are fixed.", + "Add an MCP source and register its tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. For remote OAuth-protected servers, first use `probeEndpoint` and the core OAuth browser handoff (`oauth.probe`, `oauth.start`), then bind the completed connection with `sources.configure` if needed. For header/API-key auth, first call `secrets.create` at the user's chosen credential scope so the value is entered in the browser, then pass the secret reference in `credentials`. Remote sources are still saved if discovery fails; inspect the returned `discovery` field and use `sources.refresh` after credentials or network access are fixed.", annotations: { requiresApproval: true, approvalDescription: "Add an MCP source", @@ -1730,16 +1733,29 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { inputSchema: McpAddSourceInputStandardSchema, outputSchema: McpAddSourceOutputStandardSchema, execute: (rawInput, { ctx }) => { - const input = rawInput as McpSourceConfig; + const input = rawInput as typeof McpAddSourceInputSchema.Type; + const sourceScope = defaultSourceInstallScopeId(ctx.scopes); + if (sourceScope === null) { + return Effect.succeed( + mcpToolFailure( + "source_scope_unavailable", + "Cannot add an MCP source because this executor has no source install scope.", + ), + ); + } const normalizedInput = { ...input, - scope: resolveStaticScopeInput(ctx, input.scope), + scope: sourceScope, } as McpSourceConfig; - const added = self - .addSource(normalizedInput) - .pipe( - Effect.map((result) => ToolResult.ok({ ...result, discovery: { status: "ok" } })), - ); + const added = self.addSource(normalizedInput).pipe( + Effect.map((result) => + ToolResult.ok({ + ...result, + source: { id: result.namespace, scope: sourceScope }, + discovery: { status: "ok" }, + }), + ), + ); if (normalizedInput.transport !== "remote") return added; const savedWithDiscoveryFailure = (failure: { @@ -1754,6 +1770,15 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { name: normalizedInput.name, endpoint: normalizedInput.endpoint, }), + source: { + id: + normalizedInput.namespace ?? + deriveMcpNamespace({ + name: normalizedInput.name, + endpoint: normalizedInput.endpoint, + }), + scope: sourceScope, + }, toolCount: 0, discovery: { status: "failed" as const, @@ -1775,6 +1800,15 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { name: normalizedInput.name, endpoint: normalizedInput.endpoint, }), + source: { + id: + normalizedInput.namespace ?? + deriveMcpNamespace({ + name: normalizedInput.name, + endpoint: normalizedInput.endpoint, + }), + scope: sourceScope, + }, toolCount: 0, discovery: { status: "failed" as const, diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 789f89120..6332235ec 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -317,7 +317,7 @@ describe("OpenAPI Plugin", () => { const schema = yield* executor.tools.schema("executor.openapi.addSource"); expect(schema).not.toBeNull(); - expect(schema!.inputTypeScript).toContain("scope: string"); + expect(schema!.inputTypeScript).not.toContain("scope: string"); expect(schema!.inputTypeScript).toContain('kind: "url"'); expect( (schema!.inputSchema as { properties?: Record }).properties, @@ -348,12 +348,18 @@ describe("OpenAPI Plugin", () => { const result = unwrapInvocation( yield* executor.tools.invoke( "executor.openapi.addSource", - testApiSourceConfig({ scope: "org", namespace: "runtime" }), + (({ scope: _scope, ...input }) => input)( + testApiSourceConfig({ scope: "org", namespace: "runtime" }), + ), autoApprove, ), - ).data as { sourceId: string; toolCount: number }; + ).data as { sourceId: string; source: { id: string; scope: string }; toolCount: number }; - expect(result).toEqual({ sourceId: "runtime", toolCount: 4 }); + expect(result).toEqual({ + sourceId: "runtime", + source: { id: "runtime", scope: String(orgScope) }, + toolCount: 4, + }); expect(yield* executor.openapi.getSource("runtime", String(userScope))).toBeNull(); expect((yield* executor.openapi.getSource("runtime", String(orgScope)))?.scope).toBe( orgScope, @@ -400,7 +406,9 @@ describe("OpenAPI Plugin", () => { const declined = yield* executor.tools .invoke( "executor.openapi.addSource", - testApiSourceConfig({ namespace: "runtime_declined" }), + (({ scope: _scope, ...input }) => input)( + testApiSourceConfig({ namespace: "runtime_declined" }), + ), { onElicitation: () => Effect.succeed({ action: "decline" as const }), }, diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index ffe9d0a78..2aa00ef97 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -9,6 +9,7 @@ import { SourceDetectionResult, StorageError, ToolResult, + defaultSourceInstallScopeId, definePlugin, tool, resolveSecretBackedMap, @@ -305,7 +306,6 @@ export type OpenApiConfigureInput = typeof OpenApiConfigureInputSchema.Type; const OpenApiOAuthInputSchema = OAuth2SourceConfig; const AddSourceInputSchema = Schema.Struct({ - scope: Schema.String, spec: OpenApiSpecInputSchema, name: Schema.String, baseUrl: Schema.String, @@ -323,6 +323,10 @@ const AddSourceInputSchema = Schema.Struct({ const AddSourceOutputSchema = Schema.Struct({ sourceId: Schema.String, + source: Schema.Struct({ + id: Schema.String, + scope: Schema.String, + }), toolCount: Schema.Number, }); const GetSourceInputSchema = Schema.Struct({ @@ -1301,16 +1305,30 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { tool({ name: "addSource", description: - "Add an OpenAPI source and register its operations as tools. Recommended flow: call `previewSpec`, choose or confirm namespace/name/baseUrl from the preview (baseUrl is only needed when the spec cannot infer one or the user wants an override), use `secrets.create` for sensitive header/query/client-secret values, use `oauth.start` for browser OAuth sign-in, then call this tool and follow with `sources.configure` for per-scope credential bindings.", + "Add an OpenAPI source and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. Recommended flow: call `previewSpec`, choose or confirm namespace/name/baseUrl from the preview (baseUrl is only needed when the spec cannot infer one or the user wants an override), declare credential slots here for sensitive headers/query params, then call `secrets.create` and `sources.configure` with the user's chosen credential scope for per-scope bindings. Use `oauth.start` for browser OAuth sign-in.", annotations: { requiresApproval: true, approvalDescription: "Add an OpenAPI source", }, inputSchema: AddSourceInputStandardSchema, outputSchema: AddSourceOutputStandardSchema, - execute: (input, { ctx }) => - self.addSpec({ ...input, scope: resolveStaticScopeInput(ctx, input.scope) }).pipe( - Effect.map(ToolResult.ok), + execute: (input, { ctx }) => { + const sourceScope = defaultSourceInstallScopeId(ctx.scopes); + if (sourceScope === null) { + return Effect.succeed( + openApiToolFailure( + "source_scope_unavailable", + "Cannot add an OpenAPI source because this executor has no source install scope.", + ), + ); + } + return self.addSpec({ ...input, scope: sourceScope }).pipe( + Effect.map((result) => + ToolResult.ok({ + ...result, + source: { id: result.sourceId, scope: sourceScope }, + }), + ), Effect.catchTags({ OpenApiParseError: ({ message }) => Effect.succeed(openApiToolFailure("openapi_parse_failed", message)), @@ -1319,7 +1337,8 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { OpenApiOAuthError: ({ message }) => Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)), }), - ), + ); + }, }), ], }, From 7bc6e7dc59bb838cb71d83b6ad83e7c3a6ab819d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 18 May 2026 23:59:00 -0700 Subject: [PATCH 06/15] fix: clarify secret creation for agents --- notes/todo | 6 +++++ packages/core/sdk/src/core-tools.ts | 23 +++++++++++++++--- packages/core/sdk/src/executor.test.ts | 32 +++++++++++++++++++------- 3 files changed, 50 insertions(+), 11 deletions(-) create mode 100644 notes/todo diff --git a/notes/todo b/notes/todo new file mode 100644 index 000000000..5f38d97c9 --- /dev/null +++ b/notes/todo @@ -0,0 +1,6 @@ +Improve agent-facing source configuration scope flow + +- Source add tools now hide raw source placement and resolve the install scope internally, but credential placement still exposes raw scope ids. +- Add a product-level credential target flow for agents, probably "personal" vs "organization", that maps to the right credential scope internally. +- Keep the main-branch separation intact: source tools declare shared source config and credential slots; credential tools create secrets/connections and bind them at the selected credential scope. +- Agent descriptions should explain when a credential target choice is needed and when local single-scope executors can default automatically. diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 83b425bdf..97f211ac4 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -544,12 +544,22 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "secrets.create", description: - "Create a secret placeholder and return a browser URL for the user to enter the sensitive value. Never ask the user to paste passwords, tokens, client secrets, or API keys into chat. In a single-scope local executor, omit `scope`; otherwise call `scopes.list` and pass the target scope id or name. After the user saves, call `secrets.status` for this id, then pass the secret id to `sources.configure` or `oauth.start`.", + "Create a secret placeholder and return a browser URL for the user to enter the sensitive value. Never ask the user to paste passwords, tokens, client secrets, or API keys into chat. In a single-scope local executor, omit `scope`; otherwise call `scopes.list` and pass the target credential scope id or name. The optional `provider` is the Executor secret storage backend, not the API vendor; omit it unless the user explicitly chose a value returned by `secrets.providers`.", inputSchema: SecretsCreateInputStd, outputSchema: SecretsCreateOutputStd, execute: (input, { ctx }) => Effect.gen(function* () { const webBaseUrl = yield* requireWebBaseUrl(options.webBaseUrl); + if (input.provider) { + const providers = yield* ctx.secrets.providers(); + if (!providers.includes(input.provider)) { + return oauthToolFailure( + "secret_provider_not_found", + `Unknown secret storage provider "${input.provider}". Omit provider unless the user chose one from secrets.providers.`, + { providers }, + ); + } + } const targetScope = yield* resolveScopeInput(ctx.scopes, input.scope); const secretId = crypto.randomUUID(); @@ -559,7 +569,14 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { url.searchParams.set("secretId", secretId); if (input.provider) url.searchParams.set("provider", input.provider); return { id: secretId, url: url.toString() }; - }), + }).pipe( + Effect.catchTags({ + CoreToolsConfigurationError: ({ message }) => + Effect.succeed(oauthToolFailure("secret_handoff_not_configured", message)), + CoreToolsScopeNotFoundError: ({ message, scope }) => + Effect.succeed(oauthToolFailure("scope_not_found", message, { scope })), + }), + ), }), tool({ name: "secrets.status", @@ -582,7 +599,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "secrets.providers", description: - "List registered secret storage providers. Use this to choose the optional provider for `secrets.create`; sensitive values still must be entered through the returned browser URL.", + "List registered secret storage providers. Only use these exact values for the optional `provider` field in `secrets.create`; do not use API vendor names such as Vercel, GitHub, Stripe, or Google. Sensitive values still must be entered through the returned browser URL.", outputSchema: ProvidersOutputStd, execute: (_args, { ctx }) => Effect.map(ctx.secrets.providers(), (providers) => ({ providers })), diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index 22ee75461..7963ee812 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -581,6 +581,20 @@ describe("createExecutor", () => { expect(idUrl.searchParams.get("scope")).toBe("test-scope"); expect(idUrl.searchParams.get("name")).toBe("api-token-by-id"); + const invalidProvider = yield* executor.tools.invoke("executor.coreTools.secrets.create", { + name: "api-token-invalid-provider", + provider: "vercel", + }); + expect(invalidProvider).toMatchObject({ + ok: false, + error: { + code: "secret_provider_not_found", + message: + 'Unknown secret storage provider "vercel". Omit provider unless the user chose one from secrets.providers.', + details: { providers: ["memory"] }, + }, + }); + yield* executor.close(); yield* Effect.promise(() => config.testDb.close()); }), @@ -607,15 +621,17 @@ describe("createExecutor", () => { coreTools: { webBaseUrl: "http://executor.test" }, }); - const error = yield* executor.tools - .invoke("executor.coreTools.secrets.create", { - name: "api-token", - }) - .pipe(Effect.flip); + const result = yield* executor.tools.invoke("executor.coreTools.secrets.create", { + name: "api-token", + }); - expect(error).toMatchObject({ - message: - "Multiple scopes are visible. Call scopes.list and pass the target scope id or name.", + expect(result).toMatchObject({ + ok: false, + error: { + code: "scope_not_found", + message: + "Multiple scopes are visible. Call scopes.list and pass the target scope id or name.", + }, }); yield* executor.close(); From dc143628473a4fe913adc5b65188c98c4f950161 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 19 May 2026 00:02:59 -0700 Subject: [PATCH 07/15] fix: render open object schema previews --- packages/core/sdk/src/schema-types.test.ts | 29 ++++++++++++++++++++++ packages/core/sdk/src/schema-types.ts | 10 +++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/packages/core/sdk/src/schema-types.test.ts b/packages/core/sdk/src/schema-types.test.ts index 38ad16fe8..38fc1d848 100644 --- a/packages/core/sdk/src/schema-types.test.ts +++ b/packages/core/sdk/src/schema-types.test.ts @@ -405,4 +405,33 @@ describe("schema-types", () => { outputTypeScript: "unknown", }); }); + + it("renders open object schemas as unknown-key records", async () => { + await expect( + buildToolTypeScriptPreview({ + inputSchema: { + type: "object", + properties: { + resourceMetadata: { + anyOf: [{ type: "object" }, { type: "null" }], + }, + }, + required: ["resourceMetadata"], + additionalProperties: false, + }, + outputSchema: { + type: "object", + properties: { + metadata: { type: "object" }, + }, + required: ["metadata"], + additionalProperties: false, + }, + defs: new Map(), + }), + ).resolves.toEqual({ + inputTypeScript: "{ resourceMetadata: { [k: string]: unknown; } | null; }", + outputTypeScript: "{ metadata: { [k: string]: unknown; }; }", + }); + }); }); diff --git a/packages/core/sdk/src/schema-types.ts b/packages/core/sdk/src/schema-types.ts index 2995b5642..c7d44e237 100644 --- a/packages/core/sdk/src/schema-types.ts +++ b/packages/core/sdk/src/schema-types.ts @@ -165,7 +165,15 @@ const normalizeSchema = (node: unknown): unknown => { normalized[key] = normalizeSchema(value); } - return normalizeNullable(normalized); + const nullable = normalizeNullable(normalized); + if ( + nullable.type === "object" && + nullable.properties === undefined && + nullable.additionalProperties === undefined + ) { + return { ...nullable, additionalProperties: {} }; + } + return nullable; }; const mergeDefinitions = ( From 6eff1c610258c83318ca6e4a6c7d2f18c27febc9 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 19 May 2026 00:11:16 -0700 Subject: [PATCH 08/15] fix: expose typed source configure tools --- packages/core/sdk/src/core-tools.ts | 29 ++-------- packages/core/sdk/src/executor.test.ts | 8 +-- .../google-discovery/src/sdk/plugin.test.ts | 1 + .../google-discovery/src/sdk/plugin.ts | 38 +++++++++++- .../plugins/graphql/src/sdk/plugin.test.ts | 1 + packages/plugins/graphql/src/sdk/plugin.ts | 44 +++++++++++++- packages/plugins/mcp/src/sdk/plugin.test.ts | 3 + packages/plugins/mcp/src/sdk/plugin.ts | 58 ++++++++++++++++++- .../plugins/openapi/src/sdk/plugin.test.ts | 2 + packages/plugins/openapi/src/sdk/plugin.ts | 42 +++++++++++++- 10 files changed, 186 insertions(+), 40 deletions(-) diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 97f211ac4..d0e51e5bf 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -140,16 +140,6 @@ const SourcesPresetsOutput = Schema.Struct({ presets: Schema.Array(SourcePresetOutput), }); -const SourcesConfigureSchemasOutput = Schema.Struct({ - schemas: Schema.Array( - Schema.Struct({ - pluginId: Schema.String, - type: Schema.String, - schema: Schema.optional(Schema.Unknown), - }), - ), -}); - const SourcePointer = Schema.Struct({ id: Schema.String, scope: Schema.String, @@ -334,7 +324,6 @@ const SourcesPresetsInputStd = schemaToStandard< typeof SourcesPresetsInput.Encoded >(SourcesPresetsInput); const SourcesPresetsOutputStd = schemaToStandard(SourcesPresetsOutput); -const SourcesConfigureSchemasOutputStd = schemaToStandard(SourcesConfigureSchemasOutput); const SourcesConfigureInputStd = schemaToStandard< typeof SourcesConfigureInput.Type, typeof SourcesConfigureInput.Encoded @@ -629,7 +618,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "sources.list", description: - "List configured and built-in sources. Use this to find source ids/scopes before calling `sources.configure`, `sources.bindings.*`, refresh, remove, or tool discovery.", + "List configured and built-in sources. Use this to find source ids/scopes before calling plugin-specific configureSource tools, `sources.bindings.*`, refresh, remove, or tool discovery.", outputSchema: SourcesListOutputStd, execute: (_args, { ctx }) => Effect.map(ctx.core.sources.list(), (sources) => ({ sources })), @@ -669,18 +658,10 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { return { presets }; }), }), - tool({ - name: "sources.configureSchemas", - description: - "Return the plugin-specific payload schemas accepted by `sources.configure`. Use this before configuring a source unless you already know the plugin flow. The `type` field should be passed back to `sources.configure`.", - outputSchema: SourcesConfigureSchemasOutputStd, - execute: (_args, { ctx }) => - Effect.succeed({ schemas: ctx.core.sources.configureSchemas() }), - }), tool({ name: "sources.configure", description: - 'Configure an existing source through its owning plugin. Use `sources.list` to get source id/scope, `sources.configureSchemas` to inspect the config schema, and `secrets.create`/`oauth.start` first for sensitive inputs. Pass secret refs as `{kind:"secret", secretId}` and OAuth connections as `{kind:"connection", connectionId}` when the plugin schema supports them.', + 'Low-level escape hatch for configuring an existing source through its owning plugin. Prefer plugin-specific tools such as `openapi.configureSource`, `graphql.configureSource`, `mcp.configureSource`, or `googleDiscovery.configureSource`; this accepts plugin config as `unknown` for repair and compatibility cases. Use `secrets.create`/`oauth.start` first for sensitive inputs. Pass secret refs as `{kind:"secret", secretId}` and OAuth connections as `{kind:"connection", connectionId}` when the plugin schema supports them.', annotations: { requiresApproval: true, approvalDescription: "Configure an Executor source", @@ -738,7 +719,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "sources.bindings.list", description: - "List credential bindings for a source. Use this to verify that secrets or OAuth connections were bound after `sources.configure`.", + "List credential bindings for a source. Use this to verify that secrets or OAuth connections were bound after a plugin-specific configureSource tool.", inputSchema: SourceBindingsListInputStd, outputSchema: SourceBindingsListOutputStd, execute: (input, { ctx }) => @@ -769,7 +750,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "sources.bindings.set", description: - "Set one credential binding for a source slot. Prefer `sources.configure` for normal flows because plugin schemas name the right slots. Use this low-level tool only when a plugin or status output has given an exact slot key.", + "Set one credential binding for a source slot. Prefer plugin-specific configureSource tools for normal flows because they name the right credential fields. Use this low-level tool only when a plugin or status output has given an exact slot key.", annotations: { requiresApproval: true, approvalDescription: "Set a source credential binding", @@ -814,7 +795,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "connections.list", description: - "List OAuth/sign-in connections. This returns metadata and token secret ids, never token values. Use it to verify that `oauth.start` completed, then bind the connection id with `sources.configure`.", + "List OAuth/sign-in connections. This returns metadata and token secret ids, never token values. Use it to verify that `oauth.start` completed, then bind the connection id with the relevant plugin-specific configureSource tool.", outputSchema: ConnectionsListOutputStd, execute: (_args, { ctx }) => Effect.map(ctx.connections.list(), (connections) => ({ connections })), diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index 7963ee812..d6cf3227f 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -511,15 +511,9 @@ describe("createExecutor", () => { yield* executor.configurable.registerSource("org"); - const schemas = yield* executor.tools.invoke( + expect((yield* executor.tools.list()).map((tool) => tool.id)).not.toContain( "executor.coreTools.sources.configureSchemas", - {}, ); - expect(schemas).toMatchObject({ - schemas: expect.arrayContaining([ - expect.objectContaining({ pluginId: "configurable", type: "configurable" }), - ]), - }); const presets = yield* executor.tools.invoke("executor.coreTools.sources.presets", { query: "demo", }); diff --git a/packages/plugins/google-discovery/src/sdk/plugin.test.ts b/packages/plugins/google-discovery/src/sdk/plugin.test.ts index bbdd4d105..87a377d9c 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.test.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.test.ts @@ -430,6 +430,7 @@ describe("Google Discovery plugin", () => { "executor.googleDiscovery.probeDiscovery", "executor.googleDiscovery.addSource", "executor.googleDiscovery.getSource", + "executor.googleDiscovery.configureSource", ]), ); diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts index a82868c1b..281f8d3a8 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -187,6 +187,16 @@ const GoogleDiscoveryConfigureInputSchema = Schema.Struct({ name: Schema.optional(Schema.String), auth: Schema.optional(GoogleDiscoveryAuth), }); +const GoogleDiscoveryConfigureSourceInputSchema = Schema.Struct({ + source: Schema.Struct({ + id: Schema.String, + scope: Schema.String, + }), + ...GoogleDiscoveryConfigureInputSchema.fields, +}); +const GoogleDiscoveryConfigureSourceOutputSchema = Schema.Struct({ + configured: Schema.Boolean, +}); const schemaToStaticToolSchema = (schema: Schema.Decoder): StaticToolSchema => Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema< @@ -212,6 +222,12 @@ const GoogleDiscoveryGetSourceInputStandardSchema = schemaToStaticToolSchema( const GoogleDiscoveryGetSourceOutputStandardSchema = schemaToStaticToolSchema( GoogleDiscoveryGetSourceOutputSchema, ); +const GoogleDiscoveryConfigureSourceInputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryConfigureSourceInputSchema, +); +const GoogleDiscoveryConfigureSourceOutputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryConfigureSourceOutputSchema, +); const resolveStaticScopeInput = ( ctx: { readonly scopes: readonly { readonly id: ScopeId; readonly name: string }[] }, @@ -578,7 +594,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ tool({ name: "getSource", description: - "Inspect an existing Google Discovery source, including discovery URL, service metadata, auth mode, OAuth scopes, connection id, and credential slots. Use this before repairing an existing source with `sources.configure`, `secrets.create`, or `oauth.start`.", + "Inspect an existing Google Discovery source, including discovery URL, service metadata, auth mode, OAuth scopes, connection id, and credential slots. Use this before repairing an existing source with `googleDiscovery.configureSource`, `secrets.create`, or `oauth.start`.", inputSchema: GoogleDiscoveryGetSourceInputStandardSchema, outputSchema: GoogleDiscoveryGetSourceOutputStandardSchema, execute: (input, { ctx }) => { @@ -589,6 +605,26 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ ); }, }), + tool({ + name: "configureSource", + description: + "Configure an existing Google Discovery source with concrete fields. Use `source` returned by `googleDiscovery.addSource` or `sources.list`. For OAuth, call `oauth.start` in the browser first, then pass the returned connection id and client secret ids through `auth`.", + annotations: { + requiresApproval: true, + approvalDescription: "Configure a Google Discovery source", + }, + inputSchema: GoogleDiscoveryConfigureSourceInputStandardSchema, + outputSchema: GoogleDiscoveryConfigureSourceOutputStandardSchema, + execute: (input, { ctx }) => { + const { source, ...config } = + input as typeof GoogleDiscoveryConfigureSourceInputSchema.Type; + const sourceScope = resolveStaticScopeInput(ctx, source.scope); + return Effect.as( + self.updateSource(source.id, sourceScope, config), + ToolResult.ok({ configured: true }), + ); + }, + }), ], }, ], diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 317056875..9eb8766d1 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -632,6 +632,7 @@ describe("graphqlPlugin", () => { // static executor tool also present under the executor namespace expect(ids).toContain("executor.graphql.getSource"); expect(ids).toContain("executor.graphql.addSource"); + expect(ids).toContain("executor.graphql.configureSource"); const queryTool = tools.find((t) => t.id === "test_api.query.hello"); expect(queryTool?.description).toBe("Say hello"); diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 0cce568dc..1bd3e1985 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -138,6 +138,17 @@ const SourceConfigureInputSchema = Schema.Struct({ queryParams: Schema.optional(Schema.Record(Schema.String, GraphqlCredentialInputSchema)), auth: Schema.optional(GraphqlSourceAuthInputSchema), }); +const StaticConfigureSourceInputSchema = Schema.Struct({ + source: Schema.Struct({ + id: Schema.String, + scope: Schema.String, + }), + scope: Schema.String, + ...SourceConfigureInputSchema.fields, +}); +const StaticConfigureSourceOutputSchema = Schema.Struct({ + configured: Schema.Boolean, +}); const StaticGetSourceInputSchema = Schema.Struct({ namespace: Schema.String, scope: Schema.String, @@ -167,6 +178,12 @@ const StaticGetSourceInputStandardSchema = Schema.toStandardSchemaV1( const StaticGetSourceOutputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(StaticGetSourceOutputSchema), ); +const StaticConfigureSourceInputStandardSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(StaticConfigureSourceInputSchema), +); +const StaticConfigureSourceOutputStandardSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(StaticConfigureSourceOutputSchema), +); const graphqlToolFailure = (code: string, message: string, details?: unknown) => ToolResult.fail({ @@ -964,7 +981,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { tool({ name: "getSource", description: - "Inspect an existing GraphQL source, including endpoint, auth mode, configured headers/query params, and credential slots. Use this before repairing an existing source with `sources.configure`, `secrets.create`, or `oauth.start`.", + "Inspect an existing GraphQL source, including endpoint, auth mode, configured headers/query params, and credential slots. Use this before repairing an existing source with `graphql.configureSource`, `secrets.create`, or `oauth.start`.", inputSchema: StaticGetSourceInputStandardSchema, outputSchema: StaticGetSourceOutputStandardSchema, execute: (input, { ctx }) => @@ -976,7 +993,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { tool({ name: "addSource", description: - "Add a GraphQL endpoint and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. For API keys or bearer tokens, first call `executor.coreTools.secrets.create` at the user's chosen credential scope and pass secret refs through `credentials`. For OAuth, start the browser flow with `executor.coreTools.oauth.start`, verify completion with `connections.list`, then bind the connection through `credentials` or `sources.configure`.", + "Add a GraphQL endpoint and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. For API keys or bearer tokens, first call `executor.coreTools.secrets.create` at the user's chosen credential scope and pass secret refs through `credentials`. For OAuth, start the browser flow with `executor.coreTools.oauth.start`, verify completion with `connections.list`, then bind the connection through `credentials` or `graphql.configureSource`.", annotations: { requiresApproval: true, approvalDescription: "Add a GraphQL source", @@ -1009,6 +1026,29 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { ); }, }), + tool({ + name: "configureSource", + description: + 'Configure an existing GraphQL source with concrete fields. Use `source` returned by `graphql.addSource` or `sources.list`. The top-level `scope` is the credential target scope for bindings; in cloud, choose the user or organization credential scope deliberately. Pass secret refs as `{kind:"secret", secretId}` and OAuth connections as `{kind:"connection", connectionId}`.', + annotations: { + requiresApproval: true, + approvalDescription: "Configure a GraphQL source", + }, + inputSchema: StaticConfigureSourceInputStandardSchema, + outputSchema: StaticConfigureSourceOutputStandardSchema, + execute: (input, { ctx }) => { + const { source, ...config } = input as typeof StaticConfigureSourceInputSchema.Type; + const sourceScope = resolveStaticScopeInput(ctx, source.scope); + const targetScope = resolveStaticScopeInput(ctx, config.scope); + return Effect.as( + self.configure( + { id: source.id, scope: sourceScope }, + { ...config, scope: targetScope }, + ), + ToolResult.ok({ configured: true }), + ); + }, + }), ], }, ], diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 3e3f672a8..fe41f06dd 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -216,6 +216,9 @@ describe("mcpPlugin", () => { expect(executor.mcp.refreshSource).toBeTypeOf("function"); expect(executor.mcp.probeEndpoint).toBeTypeOf("function"); expect(executor.mcp.getSource).toBeTypeOf("function"); + expect((yield* executor.tools.list()).map((tool) => tool.id)).toContain( + "executor.mcp.configureSource", + ); expect(executor.oauth.start).toBeTypeOf("function"); expect(executor.oauth.complete).toBeTypeOf("function"); }), diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 5deb88757..29bfea9d0 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -231,6 +231,19 @@ const McpGetSourceOutputSchema = Schema.Struct({ source: Schema.NullOr(Schema.Unknown), }); +const McpStaticConfigureSourceInputSchema = Schema.Struct({ + source: Schema.Struct({ + id: Schema.String, + scope: Schema.String, + }), + scope: Schema.String, + ...McpConfigureSourcePayloadSchema.fields, +}); + +const McpStaticConfigureSourceOutputSchema = Schema.Struct({ + configured: Schema.Boolean, +}); + const schemaToStaticToolSchema = (schema: Schema.Decoder): StaticToolSchema => Schema.toStandardSchemaV1(Schema.toStandardJSONSchemaV1(schema) as never) as StaticToolSchema< A, @@ -250,6 +263,12 @@ const McpProbeEndpointInputStandardSchema = schemaToStaticToolSchema(McpProbeEnd const McpProbeEndpointOutputStandardSchema = schemaToStaticToolSchema(McpProbeEndpointOutputSchema); const McpGetSourceInputStandardSchema = schemaToStaticToolSchema(McpGetSourceInputSchema); const McpGetSourceOutputStandardSchema = schemaToStaticToolSchema(McpGetSourceOutputSchema); +const McpStaticConfigureSourceInputStandardSchema = schemaToStaticToolSchema( + McpStaticConfigureSourceInputSchema, +); +const McpStaticConfigureSourceOutputStandardSchema = schemaToStaticToolSchema( + McpStaticConfigureSourceOutputSchema, +); export type McpProbeEndpointInput = typeof McpProbeEndpointInputSchema.Type; @@ -1697,7 +1716,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { tool({ name: "probeEndpoint", description: - "Probe a remote MCP endpoint before adding it. If the result requires OAuth, call `executor.coreTools.oauth.probe` and `executor.coreTools.oauth.start` first, then pass the resulting connection through `addSource` credentials or `sources.configure`.", + "Probe a remote MCP endpoint before adding it. If the result requires OAuth, call `executor.coreTools.oauth.probe` and `executor.coreTools.oauth.start` first, then pass the resulting connection through `addSource` credentials or `mcp.configureSource`.", inputSchema: McpProbeEndpointInputStandardSchema, outputSchema: McpProbeEndpointOutputStandardSchema, execute: (input) => @@ -1711,7 +1730,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { tool({ name: "getSource", description: - "Inspect an existing MCP source, including transport, endpoint/command, auth mode, configured headers/query params, and credential slots. Use this before repairing an existing source with `sources.configure`, `secrets.create`, or `oauth.start`.", + "Inspect an existing MCP source, including transport, endpoint/command, auth mode, configured headers/query params, and credential slots. Use this before repairing an existing source with `mcp.configureSource`, `secrets.create`, or `oauth.start`.", inputSchema: McpGetSourceInputStandardSchema, outputSchema: McpGetSourceOutputStandardSchema, execute: (input, { ctx }) => { @@ -1725,7 +1744,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { tool({ name: "addSource", description: - "Add an MCP source and register its tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. For remote OAuth-protected servers, first use `probeEndpoint` and the core OAuth browser handoff (`oauth.probe`, `oauth.start`), then bind the completed connection with `sources.configure` if needed. For header/API-key auth, first call `secrets.create` at the user's chosen credential scope so the value is entered in the browser, then pass the secret reference in `credentials`. Remote sources are still saved if discovery fails; inspect the returned `discovery` field and use `sources.refresh` after credentials or network access are fixed.", + "Add an MCP source and register its tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. For remote OAuth-protected servers, first use `probeEndpoint` and the core OAuth browser handoff (`oauth.probe`, `oauth.start`), then bind the completed connection with `mcp.configureSource` if needed. For header/API-key auth, first call `secrets.create` at the user's chosen credential scope so the value is entered in the browser, then pass the secret reference in `credentials`. Remote sources are still saved if discovery fails; inspect the returned `discovery` field and use `sources.refresh` after credentials or network access are fixed.", annotations: { requiresApproval: true, approvalDescription: "Add an MCP source", @@ -1820,6 +1839,39 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { ); }, }), + tool({ + name: "configureSource", + description: + 'Configure an existing remote MCP source with concrete fields. Use `source` returned by `mcp.addSource` or `sources.list`. The top-level `scope` is the credential target scope for bindings; in cloud, choose the user or organization credential scope deliberately. Pass secret refs as `{kind:"secret", secretId}` and OAuth connections as `{kind:"connection", connectionId}`.', + annotations: { + requiresApproval: true, + approvalDescription: "Configure an MCP source", + }, + inputSchema: McpStaticConfigureSourceInputStandardSchema, + outputSchema: McpStaticConfigureSourceOutputStandardSchema, + execute: (rawInput, { ctx }) => + Effect.gen(function* () { + const { source, ...config } = + rawInput as typeof McpStaticConfigureSourceInputSchema.Type; + const sourceScope = resolveStaticScopeInput(ctx, source.scope); + const targetScope = resolveStaticScopeInput(ctx, config.scope); + yield* ctx.core.sources.configure({ + source: { id: source.id, scope: sourceScope }, + scope: targetScope, + type: "mcp", + config: { + ...(config.name !== undefined ? { name: config.name } : {}), + ...(config.endpoint !== undefined ? { endpoint: config.endpoint } : {}), + ...(config.headers !== undefined ? { headers: config.headers } : {}), + ...(config.queryParams !== undefined + ? { queryParams: config.queryParams } + : {}), + ...(config.auth !== undefined ? { auth: config.auth } : {}), + }, + }); + return ToolResult.ok({ configured: true }); + }), + }), ], }, ], diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 6332235ec..f29efe9b4 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -239,6 +239,7 @@ describe("OpenAPI Plugin", () => { expect(ids).toContain("executor.openapi.previewSpec"); expect(ids).toContain("executor.openapi.getSource"); expect(ids).toContain("executor.openapi.addSource"); + expect(ids).toContain("executor.openapi.configureSource"); }), ); @@ -929,6 +930,7 @@ describe("OpenAPI Plugin", () => { const ids = remaining.map((t) => t.id).sort(); expect(ids).toEqual([ "executor.openapi.addSource", + "executor.openapi.configureSource", "executor.openapi.getSource", "executor.openapi.previewSpec", ]); diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 2aa00ef97..a788c4246 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -4,6 +4,7 @@ import { HttpClient } from "effect/unstable/http"; import { ConnectionId, + CredentialBindingRef, ScopeId, SecretId, SourceDetectionResult, @@ -14,7 +15,6 @@ import { tool, resolveSecretBackedMap, type CredentialBindingValue, - type CredentialBindingRef, type PluginCtx, type StorageFailure, type ToolAnnotations, @@ -303,6 +303,13 @@ const OpenApiConfigureInputSchema = Schema.Struct({ oauth2Source: Schema.optional(OAuth2SourceConfig), }); export type OpenApiConfigureInput = typeof OpenApiConfigureInputSchema.Type; +const OpenApiConfigureSourceInputSchema = Schema.Struct({ + source: Schema.Struct({ + id: Schema.String, + scope: Schema.String, + }), + ...OpenApiConfigureInputSchema.fields, +}); const OpenApiOAuthInputSchema = OAuth2SourceConfig; const AddSourceInputSchema = Schema.Struct({ @@ -349,6 +356,12 @@ const AddSourceInputStandardSchema = Schema.toStandardSchemaV1( const AddSourceOutputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(AddSourceOutputSchema), ); +const OpenApiConfigureSourceInputStandardSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(OpenApiConfigureSourceInputSchema), +); +const OpenApiConfigureSourceOutputStandardSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(Schema.Struct({ result: Schema.Array(CredentialBindingRef) })), +); const GetSourceInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(GetSourceInputSchema), ); @@ -1293,7 +1306,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { tool({ name: "getSource", description: - "Inspect an existing OpenAPI source, including effective base URL, configured headers/query params, OAuth settings, and stored credential slots. Use this before repairing an existing source with `sources.configure`, `secrets.create`, or `oauth.start`.", + "Inspect an existing OpenAPI source, including effective base URL, configured headers/query params, OAuth settings, and stored credential slots. Use this before repairing an existing source with `openapi.configureSource`, `secrets.create`, or `oauth.start`.", inputSchema: GetSourceInputStandardSchema, outputSchema: GetSourceOutputStandardSchema, execute: (input, { ctx }) => @@ -1305,7 +1318,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { tool({ name: "addSource", description: - "Add an OpenAPI source and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. Recommended flow: call `previewSpec`, choose or confirm namespace/name/baseUrl from the preview (baseUrl is only needed when the spec cannot infer one or the user wants an override), declare credential slots here for sensitive headers/query params, then call `secrets.create` and `sources.configure` with the user's chosen credential scope for per-scope bindings. Use `oauth.start` for browser OAuth sign-in.", + "Add an OpenAPI source and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. Recommended flow: call `previewSpec`, choose or confirm namespace/name/baseUrl from the preview (baseUrl is only needed when the spec cannot infer one or the user wants an override), declare credential slots here for sensitive headers/query params, then call `secrets.create` and `openapi.configureSource` with the user's chosen credential scope for per-scope bindings. Use `oauth.start` for browser OAuth sign-in.", annotations: { requiresApproval: true, approvalDescription: "Add an OpenAPI source", @@ -1340,6 +1353,29 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { ); }, }), + tool({ + name: "configureSource", + description: + 'Configure an existing OpenAPI source with concrete fields. Use `source` returned by `openapi.addSource` or `sources.list`. The top-level `scope` is the credential target scope for bindings; in cloud, choose the user or organization credential scope deliberately. Pass secret refs as `{kind:"secret", secretId}` and OAuth connections as `{kind:"connection", connectionId}`.', + annotations: { + requiresApproval: true, + approvalDescription: "Configure an OpenAPI source", + }, + inputSchema: OpenApiConfigureSourceInputStandardSchema, + outputSchema: OpenApiConfigureSourceOutputStandardSchema, + execute: (input, { ctx }) => { + const { source, ...config } = input as typeof OpenApiConfigureSourceInputSchema.Type; + const sourceScope = resolveStaticScopeInput(ctx, source.scope); + const targetScope = resolveStaticScopeInput(ctx, config.scope); + return Effect.map( + self.configure( + { id: source.id, scope: sourceScope }, + { ...config, scope: targetScope }, + ), + (result) => ToolResult.ok({ result }), + ); + }, + }), ], }, ], From 87c951d03b7fe89c6f4b28a8b3bf63fc8a75efbd Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 19 May 2026 00:22:17 -0700 Subject: [PATCH 09/15] fix: stabilize agent openapi preview flow --- .../core/execution/src/tool-invoker.test.ts | 52 ++++++ packages/core/execution/src/tool-invoker.ts | 16 ++ .../runtime-dynamic-worker/src/executor.ts | 33 +++- .../src/invocation.test.ts | 26 +++ .../src/module-template.ts | 23 ++- .../plugins/openapi/src/sdk/plugin.test.ts | 2 + packages/plugins/openapi/src/sdk/plugin.ts | 150 +++++++++++++++++- 7 files changed, 287 insertions(+), 15 deletions(-) diff --git a/packages/core/execution/src/tool-invoker.test.ts b/packages/core/execution/src/tool-invoker.test.ts index 3ce1d2a74..f688b5d38 100644 --- a/packages/core/execution/src/tool-invoker.test.ts +++ b/packages/core/execution/src/tool-invoker.test.ts @@ -8,6 +8,7 @@ import { ToolResult, createExecutor, definePlugin, + tool, } from "@executor-js/sdk"; import { makeTestConfig } from "@executor-js/sdk/testing"; import { makeQuickJsExecutor } from "@executor-js/runtime-quickjs"; @@ -177,6 +178,27 @@ const errorPlugin = definePlugin(() => ({ ], })); +const validatedInputPlugin = definePlugin(() => ({ + id: "validated-input-test" as const, + storage: () => ({}), + staticSources: () => [ + { + id: "validated", + kind: "in-memory", + name: "Validated", + tools: [ + tool({ + name: "getRepositoryDetails", + description: "Get repository details including the default branch", + inputSchema: RepoInputSchema, + outputSchema: RepoDetailsOutputSchema, + execute: () => Effect.succeed({ defaultBranch: "main" }), + }), + ], + }, + ], +})); + const structuredFailurePlugin = definePlugin(() => ({ id: "structured-failure-test" as const, storage: () => ({}), @@ -661,6 +683,36 @@ describe("tool discovery", () => { }), ); + it.effect("returns invalid static tool arguments as ToolResult.fail", () => + Effect.gen(function* () { + const executor = yield* createExecutor( + makeTestConfig({ plugins: [validatedInputPlugin()] as const }), + ); + const invoker = makeExecutorToolInvoker(executor, { + invokeOptions: { onElicitation: acceptAll }, + }); + + const result = yield* invoker.invoke({ + path: "validated.getRepositoryDetails", + args: { url: "https://example.com/repo" }, + }); + + expect(result).toMatchObject({ + ok: false, + error: { + code: "invalid_tool_arguments", + message: "Tool arguments did not match the input schema.", + details: { + issues: expect.arrayContaining([ + expect.objectContaining({ path: ["owner"], message: "Missing key" }), + expect.objectContaining({ path: ["repo"], message: "Missing key" }), + ]), + }, + }, + }); + }), + ); + it.effect("preserves nested upstream error bodies through ToolResult.fail", () => Effect.gen(function* () { const executor = yield* createExecutor( diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 79ef4ea1c..9d45c449b 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -34,6 +34,12 @@ const newCorrelationId = (): string => { .padStart(8, "0"); }; +const validationIssues = (value: unknown): readonly unknown[] | null => { + if (typeof value !== "object" || value === null) return null; + const issues = (value as { readonly issues?: unknown }).issues; + return Array.isArray(issues) ? issues : null; +}; + const expectedToolFailure = ( value: unknown, ): { readonly code: string; readonly message: string; readonly details?: unknown } | null => { @@ -53,6 +59,16 @@ const expectedToolFailure = ( details: value, }; } + if (Predicate.isTagged(value, "ToolInvocationError")) { + const issues = validationIssues((value as { readonly cause?: unknown }).cause); + if (issues) { + return { + code: "invalid_tool_arguments", + message: "Tool arguments did not match the input schema.", + details: { issues }, + }; + } + } return null; }; diff --git a/packages/kernel/runtime-dynamic-worker/src/executor.ts b/packages/kernel/runtime-dynamic-worker/src/executor.ts index 0747a9887..45206699a 100644 --- a/packages/kernel/runtime-dynamic-worker/src/executor.ts +++ b/packages/kernel/runtime-dynamic-worker/src/executor.ts @@ -267,6 +267,7 @@ const rehydrateBinary = (value: unknown, seen = new WeakSet()): unknown } seen.add(value); if (isBinaryEnvelope(value)) { + seen.delete(value); if (value.kind === "file" && typeof value.name === "string") { return new File([value.buffer], value.name, { type: value.type, @@ -275,12 +276,20 @@ const rehydrateBinary = (value: unknown, seen = new WeakSet()): unknown } return new Blob([value.buffer], { type: value.type }); } - if (Array.isArray(value)) return value.map((item) => rehydrateBinary(item, seen)); - if (!isPlainObject(value)) return value; + if (Array.isArray(value)) { + const out = value.map((item) => rehydrateBinary(item, seen)); + seen.delete(value); + return out; + } + if (!isPlainObject(value)) { + seen.delete(value); + return value; + } const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { out[k] = rehydrateBinary(v, seen); } + seen.delete(value); return out; }; @@ -294,7 +303,7 @@ const encodeBinary = async (value: unknown, seen = new WeakSet()): Promi } seen.add(value); if (typeof File !== "undefined" && value instanceof File) { - return { + const out = { __executorBinary: 1 as const, kind: "file" as const, type: value.type, @@ -302,21 +311,33 @@ const encodeBinary = async (value: unknown, seen = new WeakSet()): Promi lastModified: value.lastModified, buffer: await value.arrayBuffer(), }; + seen.delete(value); + return out; } if (value instanceof Blob) { - return { + const out = { __executorBinary: 1 as const, kind: "blob" as const, type: value.type, buffer: await value.arrayBuffer(), }; + seen.delete(value); + return out; + } + if (Array.isArray(value)) { + const out = await Promise.all(value.map((item) => encodeBinary(item, seen))); + seen.delete(value); + return out; + } + if (!isPlainObject(value)) { + seen.delete(value); + return value; } - if (Array.isArray(value)) return Promise.all(value.map((item) => encodeBinary(item, seen))); - if (!isPlainObject(value)) return value; const out: Record = {}; for (const [k, v] of Object.entries(value as Record)) { out[k] = await encodeBinary(v, seen); } + seen.delete(value); return out; }; diff --git a/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts b/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts index d2ee3aef4..a3ab2f191 100644 --- a/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts +++ b/packages/kernel/runtime-dynamic-worker/src/invocation.test.ts @@ -114,6 +114,19 @@ describe("ToolDispatcher", () => { }); }); + it("allows shared object references in RPC args", async () => { + const invoker = makeInvoker(({ args }) => args); + const dispatcher = new ToolDispatcher(invoker, Effect.runPromise); + const shared = { value: 1 }; + + const result = await dispatcher.call("test.tool", { first: shared, second: shared }); + + expect(result).toEqual({ + ok: true, + result: { first: { value: 1 }, second: { value: 1 } }, + }); + }); + it("passes the tool path correctly", async () => { let capturedPath = ""; const invoker = makeInvoker(({ path }) => { @@ -404,6 +417,19 @@ describe("makeDynamicWorkerExecutor", () => { expect(result.error).toBe("Internal tool error"); }); + it("returns shared object references from tool results", async () => { + const executor = makeDynamicWorkerExecutor({ loader }); + const shared = { _tag: "None" }; + const invoker = makeInvoker(() => ({ first: shared, second: shared })); + + const result = await Effect.runPromise( + executor.execute("async () => await tools.shared.read({})", invoker), + ); + + expect(result.error).toBeUndefined(); + expect(result.result).toEqual({ first: { _tag: "None" }, second: { _tag: "None" } }); + }); + it("respects timeout", async () => { const executor = makeDynamicWorkerExecutor({ loader, timeoutMs: 500 }); const invoker = makeInvoker(() => null); diff --git a/packages/kernel/runtime-dynamic-worker/src/module-template.ts b/packages/kernel/runtime-dynamic-worker/src/module-template.ts index 754eca1b4..fae96f7b6 100644 --- a/packages/kernel/runtime-dynamic-worker/src/module-template.ts +++ b/packages/kernel/runtime-dynamic-worker/src/module-template.ts @@ -89,7 +89,7 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => " if (seen.has(value)) throw new Error('Tool RPC payload contains a circular reference');", " seen.add(value);", " if (typeof File !== 'undefined' && value instanceof File) {", - " return {", + " const out = {", " __executorBinary: 1,", " kind: 'file',", " type: value.type,", @@ -97,14 +97,23 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => " lastModified: value.lastModified,", " buffer: await value.arrayBuffer(),", " };", + " seen.delete(value);", + " return out;", " }", " if (value instanceof Blob) {", - " return { __executorBinary: 1, kind: 'blob', type: value.type, buffer: await value.arrayBuffer() };", + " const out = { __executorBinary: 1, kind: 'blob', type: value.type, buffer: await value.arrayBuffer() };", + " seen.delete(value);", + " return out;", + " }", + " if (Array.isArray(value)) {", + " const out = await Promise.all(value.map((item) => __encodeBinary(item, seen)));", + " seen.delete(value);", + " return out;", " }", - " if (Array.isArray(value)) return Promise.all(value.map((item) => __encodeBinary(item, seen)));", - " if (!__isPlainObject(value)) return value;", + " if (!__isPlainObject(value)) { seen.delete(value); return value; }", " const out = {};", " for (const [k, v] of Object.entries(value)) out[k] = await __encodeBinary(v, seen);", + " seen.delete(value);", " return out;", " };", " const __decodeBinary = (value, seen = new WeakSet()) => {", @@ -113,6 +122,7 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => " if (seen.has(value)) throw new Error('Tool RPC payload contains a circular reference');", " seen.add(value);", " if (__isBinaryEnvelope(value)) {", + " seen.delete(value);", " if (value.kind === 'file' && typeof value.name === 'string') {", " return new File([value.buffer], value.name, {", " type: value.type,", @@ -121,10 +131,11 @@ export const buildExecutorModule = (body: string, timeoutMs: number): string => " }", " return new Blob([value.buffer], { type: value.type });", " }", - " if (Array.isArray(value)) return value.map((item) => __decodeBinary(item, seen));", - " if (!__isPlainObject(value)) return value;", + " if (Array.isArray(value)) { const out = value.map((item) => __decodeBinary(item, seen)); seen.delete(value); return out; }", + " if (!__isPlainObject(value)) { seen.delete(value); return value; }", " const out = {};", " for (const [k, v] of Object.entries(value)) out[k] = __decodeBinary(v, seen);", + " seen.delete(value);", " return out;", " };", " const __publicToolErrorMessage = (error) => {", diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index f29efe9b4..06cfd48f1 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -304,6 +304,8 @@ describe("OpenAPI Plugin", () => { expect(schema!.outputTypeScript).toContain("servers:"); expect(schema!.outputTypeScript).toContain("securitySchemes:"); expect(schema!.outputTypeScript).toContain("oauth2Presets:"); + expect(schema!.outputTypeScript).toContain("title: string | null"); + expect(schema!.outputTypeScript).not.toContain("_tag"); }), ); diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index a788c4246..fbf789432 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -32,7 +32,7 @@ import { parse, resolveSpecText } from "./parse"; import { extract } from "./extract"; import { compileToolDefinitions, type ToolDefinition } from "./definitions"; import { annotationsForOperation, invokeWithLayer } from "./invoke"; -import { previewSpec, SpecPreview } from "./preview"; +import { previewSpec, type SpecPreview } from "./preview"; import { openApiPresets } from "./presets"; import { makeDefaultOpenapiStore, @@ -249,6 +249,79 @@ const PreviewSpecInputSchema = Schema.Struct({ ), }); +const StaticPreviewOperationSchema = Schema.Struct({ + operationId: Schema.String, + method: Schema.Literals(["get", "put", "post", "delete", "patch", "head", "options", "trace"]), + path: Schema.String, + summary: Schema.NullOr(Schema.String), + tags: Schema.Array(Schema.String), + deprecated: Schema.Boolean, +}); +const StaticPreviewServerVariableSchema = Schema.Struct({ + default: Schema.String, + enum: Schema.NullOr(Schema.Array(Schema.String)), + description: Schema.NullOr(Schema.String), +}); +const StaticPreviewServerSchema = Schema.Struct({ + url: Schema.String, + description: Schema.NullOr(Schema.String), + variables: Schema.NullOr(Schema.Record(Schema.String, StaticPreviewServerVariableSchema)), +}); +const StaticPreviewOAuthAuthorizationCodeFlowSchema = Schema.Struct({ + authorizationUrl: Schema.String, + tokenUrl: Schema.String, + refreshUrl: Schema.NullOr(Schema.String), + scopes: Schema.Record(Schema.String, Schema.String), +}); +const StaticPreviewOAuthClientCredentialsFlowSchema = Schema.Struct({ + tokenUrl: Schema.String, + refreshUrl: Schema.NullOr(Schema.String), + scopes: Schema.Record(Schema.String, Schema.String), +}); +const StaticPreviewOAuthFlowsSchema = Schema.Struct({ + authorizationCode: Schema.NullOr(StaticPreviewOAuthAuthorizationCodeFlowSchema), + clientCredentials: Schema.NullOr(StaticPreviewOAuthClientCredentialsFlowSchema), +}); +const StaticPreviewSecuritySchemeSchema = Schema.Struct({ + name: Schema.String, + type: Schema.Literals(["http", "apiKey", "oauth2", "openIdConnect"]), + scheme: Schema.NullOr(Schema.String), + bearerFormat: Schema.NullOr(Schema.String), + in: Schema.NullOr(Schema.Literals(["header", "query", "cookie"])), + headerName: Schema.NullOr(Schema.String), + description: Schema.NullOr(Schema.String), + flows: Schema.NullOr(StaticPreviewOAuthFlowsSchema), + openIdConnectUrl: Schema.NullOr(Schema.String), +}); +const StaticPreviewOAuth2PresetSchema = Schema.Struct({ + label: Schema.String, + securitySchemeName: Schema.String, + flow: Schema.Literals(["authorizationCode", "clientCredentials"]), + authorizationUrl: Schema.NullOr(Schema.String), + tokenUrl: Schema.String, + refreshUrl: Schema.NullOr(Schema.String), + scopes: Schema.Record(Schema.String, Schema.String), +}); +const StaticPreviewSpecOutputSchema = Schema.Struct({ + title: Schema.NullOr(Schema.String), + version: Schema.NullOr(Schema.String), + servers: Schema.Array(StaticPreviewServerSchema), + operationCount: Schema.Number, + operations: Schema.Array(StaticPreviewOperationSchema), + tags: Schema.Array(Schema.String), + securitySchemes: Schema.Array(StaticPreviewSecuritySchemeSchema), + authStrategies: Schema.Array(Schema.Struct({ schemes: Schema.Array(Schema.String) })), + headerPresets: Schema.Array( + Schema.Struct({ + label: Schema.String, + headers: Schema.Record(Schema.String, Schema.NullOr(Schema.String)), + secretHeaders: Schema.Array(Schema.String), + }), + ), + oauth2Presets: Schema.Array(StaticPreviewOAuth2PresetSchema), +}); +type StaticPreviewSpecOutput = typeof StaticPreviewSpecOutputSchema.Type; + const OpenApiSpecInputSchema = Schema.Union([ Schema.Struct({ kind: Schema.Literal("url"), url: Schema.String }), Schema.Struct({ kind: Schema.Literal("blob"), value: Schema.String }), @@ -348,7 +421,7 @@ const PreviewSpecInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(PreviewSpecInputSchema), ); const PreviewSpecOutputStandardSchema = Schema.toStandardSchemaV1( - Schema.toStandardJSONSchemaV1(SpecPreview), + Schema.toStandardJSONSchemaV1(StaticPreviewSpecOutputSchema), ); const AddSourceInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(AddSourceInputSchema), @@ -376,6 +449,77 @@ const openApiToolFailure = (code: string, message: string, details?: unknown) => ...(details === undefined ? {} : { details }), }); +const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ({ + title: Option.getOrNull(preview.title), + version: Option.getOrNull(preview.version), + servers: preview.servers.map((server) => ({ + url: server.url, + description: Option.getOrNull(server.description), + variables: Option.getOrNull(server.variables) + ? Object.fromEntries( + Object.entries(Option.getOrNull(server.variables) ?? {}).map(([name, variable]) => [ + name, + { + default: variable.default, + enum: Option.getOrNull(variable.enum), + description: Option.getOrNull(variable.description), + }, + ]), + ) + : null, + })), + operationCount: preview.operationCount, + operations: preview.operations.map((operation) => ({ + operationId: operation.operationId, + method: operation.method, + path: operation.path, + summary: Option.getOrNull(operation.summary), + tags: operation.tags, + deprecated: operation.deprecated, + })), + tags: preview.tags, + securitySchemes: preview.securitySchemes.map((scheme) => ({ + name: scheme.name, + type: scheme.type, + scheme: Option.getOrNull(scheme.scheme), + bearerFormat: Option.getOrNull(scheme.bearerFormat), + in: Option.getOrNull(scheme.in), + headerName: Option.getOrNull(scheme.headerName), + description: Option.getOrNull(scheme.description), + flows: Option.isSome(scheme.flows) + ? { + authorizationCode: Option.isSome(scheme.flows.value.authorizationCode) + ? { + authorizationUrl: scheme.flows.value.authorizationCode.value.authorizationUrl, + tokenUrl: scheme.flows.value.authorizationCode.value.tokenUrl, + refreshUrl: Option.getOrNull(scheme.flows.value.authorizationCode.value.refreshUrl), + scopes: scheme.flows.value.authorizationCode.value.scopes, + } + : null, + clientCredentials: Option.isSome(scheme.flows.value.clientCredentials) + ? { + tokenUrl: scheme.flows.value.clientCredentials.value.tokenUrl, + refreshUrl: Option.getOrNull(scheme.flows.value.clientCredentials.value.refreshUrl), + scopes: scheme.flows.value.clientCredentials.value.scopes, + } + : null, + } + : null, + openIdConnectUrl: Option.getOrNull(scheme.openIdConnectUrl), + })), + authStrategies: preview.authStrategies, + headerPresets: preview.headerPresets, + oauth2Presets: preview.oauth2Presets.map((preset) => ({ + label: preset.label, + securitySchemeName: preset.securitySchemeName, + flow: preset.flow, + authorizationUrl: Option.getOrNull(preset.authorizationUrl), + tokenUrl: preset.tokenUrl, + refreshUrl: Option.getOrNull(preset.refreshUrl), + scopes: preset.scopes, + })), +}); + const resolveStaticScopeInput = ( ctx: { readonly scopes: readonly { readonly id: ScopeId; readonly name: string }[] }, value: string, @@ -1292,7 +1436,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { outputSchema: PreviewSpecOutputStandardSchema, execute: (input) => self.previewSpec(input).pipe( - Effect.map(ToolResult.ok), + Effect.map((preview) => ToolResult.ok(staticPreviewOutput(preview))), Effect.catchTags({ OpenApiParseError: ({ message }) => Effect.succeed(openApiToolFailure("openapi_parse_failed", message)), From 879bf93a21dc754d018da603d2d7ecd6a95ae668 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 19 May 2026 00:25:35 -0700 Subject: [PATCH 10/15] fix: let secret handoff choose scope --- .../plugins/openapi/src/sdk/plugin.test.ts | 4 +- packages/plugins/openapi/src/sdk/plugin.ts | 19 +--- packages/react/src/pages/secrets.tsx | 88 ++++++++++++++++--- 3 files changed, 79 insertions(+), 32 deletions(-) diff --git a/packages/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 06cfd48f1..36af87cb7 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -284,9 +284,10 @@ describe("OpenAPI Plugin", () => { { spec: testApiSpec() }, autoApprove, ), - ).data as { operationCount: number }; + ).data as { operationCount: number; operations?: unknown }; expect(preview.operationCount).toBeGreaterThanOrEqual(2); + expect(preview.operations).toBeUndefined(); }), ); @@ -305,6 +306,7 @@ describe("OpenAPI Plugin", () => { expect(schema!.outputTypeScript).toContain("securitySchemes:"); expect(schema!.outputTypeScript).toContain("oauth2Presets:"); expect(schema!.outputTypeScript).toContain("title: string | null"); + expect(schema!.outputTypeScript).not.toContain("operations:"); expect(schema!.outputTypeScript).not.toContain("_tag"); }), ); diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index fbf789432..6bcdb1dec 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -249,14 +249,6 @@ const PreviewSpecInputSchema = Schema.Struct({ ), }); -const StaticPreviewOperationSchema = Schema.Struct({ - operationId: Schema.String, - method: Schema.Literals(["get", "put", "post", "delete", "patch", "head", "options", "trace"]), - path: Schema.String, - summary: Schema.NullOr(Schema.String), - tags: Schema.Array(Schema.String), - deprecated: Schema.Boolean, -}); const StaticPreviewServerVariableSchema = Schema.Struct({ default: Schema.String, enum: Schema.NullOr(Schema.Array(Schema.String)), @@ -307,7 +299,6 @@ const StaticPreviewSpecOutputSchema = Schema.Struct({ version: Schema.NullOr(Schema.String), servers: Schema.Array(StaticPreviewServerSchema), operationCount: Schema.Number, - operations: Schema.Array(StaticPreviewOperationSchema), tags: Schema.Array(Schema.String), securitySchemes: Schema.Array(StaticPreviewSecuritySchemeSchema), authStrategies: Schema.Array(Schema.Struct({ schemes: Schema.Array(Schema.String) })), @@ -469,14 +460,6 @@ const staticPreviewOutput = (preview: SpecPreview): StaticPreviewSpecOutput => ( : null, })), operationCount: preview.operationCount, - operations: preview.operations.map((operation) => ({ - operationId: operation.operationId, - method: operation.method, - path: operation.path, - summary: Option.getOrNull(operation.summary), - tags: operation.tags, - deprecated: operation.deprecated, - })), tags: preview.tags, securitySchemes: preview.securitySchemes.map((scheme) => ({ name: scheme.name, @@ -1431,7 +1414,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { tool({ name: "previewSpec", description: - "Preview an OpenAPI document before adding it as a source. Call this first when the user provides a spec URL/blob so you can inspect servers, auth schemes, operation count, and credential slots before `addSource`. Do not collect API keys or OAuth client secrets in chat; use `executor.coreTools.secrets.create` for those values.", + "Preview an OpenAPI document before adding it as a source. Call this first when the user provides a spec URL/blob so you can inspect servers, auth schemes, operation count, tags, and credential slots before `addSource`. This agent-facing preview intentionally omits the full operations list; use `operationCount` and `tags` for full-size specs. Do not collect API keys or OAuth client secrets in chat; use `executor.coreTools.secrets.create` for those values.", inputSchema: PreviewSpecInputStandardSchema, outputSchema: PreviewSpecOutputStandardSchema, execute: (input) => diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index cb1c47930..afffadcb7 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -22,6 +22,13 @@ import { DialogClose, } from "../components/dialog"; import { Button } from "../components/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../components/select"; import { DropdownMenu, DropdownMenuContent, @@ -39,12 +46,18 @@ import { CardStackHeader, } from "../components/card-stack"; import { Badge } from "../components/badge"; +import { cn } from "../lib/utils"; type SecretStorageOption = { readonly label: string; readonly value: string; }; +type SecretScopeOption = { + readonly label: string; + readonly value: ScopeId; +}; + const defaultStorageOptions: readonly SecretStorageOption[] = [ { value: "auto", label: "Auto" }, { value: "keychain", label: "Keychain" }, @@ -77,8 +90,9 @@ function AddSecretDialog(props: { onOpenChange: (v: boolean) => void; description: string; storageOptions: readonly SecretStorageOption[]; - existingSecretIds: readonly string[]; + existingSecrets: readonly { readonly id: string; readonly scopeId: ScopeId }[]; scopeId: ScopeId; + scopeOptions: readonly SecretScopeOption[]; prefill?: SecretPrefill; }) { return ( @@ -88,8 +102,9 @@ function AddSecretDialog(props: { key="open" description={props.description} storageOptions={props.storageOptions} - existingSecretIds={props.existingSecretIds} + existingSecrets={props.existingSecrets} scopeId={props.scopeId} + scopeOptions={props.scopeOptions} prefill={props.prefill} onClose={() => props.onOpenChange(false)} /> @@ -101,20 +116,31 @@ function AddSecretDialog(props: { function AddSecretDialogContent(props: { description: string; storageOptions: readonly SecretStorageOption[]; - existingSecretIds: readonly string[]; + existingSecrets: readonly { readonly id: string; readonly scopeId: ScopeId }[]; scopeId: ScopeId; + scopeOptions: readonly SecretScopeOption[]; prefill?: SecretPrefill; onClose: () => void; }) { const initialProvider = props.prefill?.provider ?? props.storageOptions[0]?.value ?? "auto"; + const [targetScope, setTargetScope] = useState(props.scopeId); + const existingSecretIds = useMemo( + () => + props.existingSecrets + .filter((secret) => secret.scopeId === targetScope) + .map((secret) => secret.id), + [props.existingSecrets, targetScope], + ); + const controlFieldClassName = + "[&_[data-slot=field-label]]:h-5 [&_[data-slot=field-label]]:items-start [&_[data-slot=field-label]]:leading-none [&_[data-slot=input]]:h-9"; return ( @@ -126,11 +152,37 @@ function AddSecretDialogContent(props: {
-
- - +
+
+ +
+
+ +
+
+
+
+ +
+
+ + +
-
@@ -273,12 +325,21 @@ export function SecretsPage(props: { const existingSecretIds = useMemo( () => AsyncResult.match(secrets, { - onInitial: () => [] as string[], - onFailure: () => [] as string[], - onSuccess: ({ value }) => value.map((secret) => secret.id), + onInitial: () => [] as { readonly id: string; readonly scopeId: ScopeId }[], + onFailure: () => [] as { readonly id: string; readonly scopeId: ScopeId }[], + onSuccess: ({ value }) => + value.map((secret) => ({ id: secret.id, scopeId: secret.scopeId })), }), [secrets], ); + const scopeOptions = useMemo( + () => + scopeStack.map((entry, index) => ({ + value: entry.id, + label: index === 0 ? "Personal" : entry.name || "Organization", + })), + [scopeStack], + ); const doRemove = useAtomSet(removeSecretOptimistic(scopeId), { mode: "promiseExit", }); @@ -414,8 +475,9 @@ export function SecretsPage(props: { onOpenChange={setAddOpen} description={addSecretDescription} storageOptions={storageOptions} - existingSecretIds={existingSecretIds} + existingSecrets={existingSecretIds} scopeId={formScopeId} + scopeOptions={scopeOptions} prefill={props.prefill} />
From 01e8fd1dfffbd6fb5cd28475acb2fa2a2d5737f5 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 19 May 2026 00:36:00 -0700 Subject: [PATCH 11/15] fix: explain secret handoff completion --- packages/core/sdk/src/core-tools.ts | 8 +++++++- packages/core/sdk/src/executor.test.ts | 6 +++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index d0e51e5bf..9d2e7ab5c 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -58,6 +58,7 @@ const SecretsCreateInput = Schema.Struct({ const SecretsCreateOutput = Schema.Struct({ id: Schema.String, url: Schema.String, + instructions: Schema.String, }); const SecretPointerInput = Schema.Struct({ @@ -557,7 +558,12 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { url.searchParams.set("name", input.name); url.searchParams.set("secretId", secretId); if (input.provider) url.searchParams.set("provider", input.provider); - return { id: secretId, url: url.toString() }; + return { + id: secretId, + url: url.toString(), + instructions: + "The user needs to open this URL and set the secret value in the browser. Until the user saves the value there, this secret is only a placeholder and will not be available for binding. After the user saves it, call secrets.status for this id before using it in source configuration.", + }; }).pipe( Effect.catchTags({ CoreToolsConfigurationError: ({ message }) => diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index d6cf3227f..e7a6ecb53 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -557,7 +557,11 @@ describe("createExecutor", () => { provider: "memory", }); - expect(result).toMatchObject({ id: expect.any(String), url: expect.any(String) }); + expect(result).toMatchObject({ + id: expect.any(String), + url: expect.any(String), + instructions: expect.stringContaining("placeholder"), + }); const url = new URL((result as { readonly url: string }).url); expect(url.origin).toBe("http://executor.test"); expect(url.pathname).toBe("/secrets"); From ab6b2a4c7c661740a2daf744f7a990705877f69f Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 19 May 2026 00:39:51 -0700 Subject: [PATCH 12/15] fix: explain oauth handoff completion --- packages/core/sdk/src/core-tools.ts | 10 +++++++++- packages/core/sdk/src/executor.test.ts | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 9d2e7ab5c..9b597109d 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -283,6 +283,7 @@ const OAuthStartOutput = Schema.Struct({ sessionId: Schema.String, authorizationUrl: Schema.NullOr(Schema.String), completedConnection: Schema.NullOr(Schema.Struct({ connectionId: Schema.String })), + instructions: Schema.String, }); const OAuthCancelInput = Schema.Struct({ @@ -932,7 +933,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { Effect.gen(function* () { const webBaseUrl = yield* requireWebBaseUrl(options.webBaseUrl); const tokenScope = yield* resolveScopeInput(ctx.scopes, input.scope); - return yield* ctx.oauth.start({ + const result = yield* ctx.oauth.start({ endpoint: input.endpoint, headers: input.headers, queryParams: input.queryParams, @@ -943,6 +944,13 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { pluginId: input.pluginId, identityLabel: input.identityLabel, }); + return { + ...result, + instructions: + result.authorizationUrl === null + ? "This OAuth flow completed without a browser handoff. Call connections.list to verify the connection id, then pass that connection id to the relevant source configuration tool." + : "The user needs to open this authorization URL in a browser and complete the OAuth/sign-in flow. Until the browser callback completes, no connection is available for binding. After the user finishes sign-in, call connections.list to find the connection id, then pass that connection id to the relevant source configuration tool.", + }; }).pipe( Effect.catchTags({ CoreToolsConfigurationError: ({ message }) => diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index e7a6ecb53..e663640e9 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -768,6 +768,7 @@ describe("createExecutor", () => { expect(started).toMatchObject({ authorizationUrl: null, completedConnection: { connectionId: "agent-oauth" }, + instructions: expect.stringContaining("completed without a browser handoff"), }); const listed = yield* executor.tools.invoke("executor.coreTools.connections.list", {}); @@ -864,6 +865,7 @@ describe("createExecutor", () => { expect(started).toMatchObject({ authorizationUrl: expect.stringContaining(oauthServer.authorizationEndpoint), completedConnection: null, + instructions: expect.stringContaining("open this authorization URL"), }); const authorizationUrl = (started as { authorizationUrl: string }).authorizationUrl; From 6cdf3b8e02b5bacbe473d96782d97b57a1d8ca4e Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 19 May 2026 00:44:07 -0700 Subject: [PATCH 13/15] fix: clarify oauth credential scope --- packages/core/sdk/src/core-tools.ts | 16 ++++++++-------- packages/core/sdk/src/executor.test.ts | 8 ++++++-- .../plugins/google-discovery/src/sdk/plugin.ts | 4 ++-- packages/plugins/graphql/src/sdk/plugin.ts | 2 +- packages/plugins/mcp/src/sdk/plugin.ts | 4 ++-- packages/plugins/openapi/src/sdk/plugin.ts | 2 +- 6 files changed, 20 insertions(+), 16 deletions(-) diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts index 9b597109d..612f9d3d0 100644 --- a/packages/core/sdk/src/core-tools.ts +++ b/packages/core/sdk/src/core-tools.ts @@ -268,7 +268,7 @@ const OAuthProbeOutput = Schema.Struct({ }); const OAuthStartInput = Schema.Struct({ - scope: Schema.String, + credentialScope: Schema.optional(Schema.String), endpoint: Schema.String, connectionId: Schema.String, pluginId: Schema.String, @@ -287,7 +287,7 @@ const OAuthStartOutput = Schema.Struct({ }); const OAuthCancelInput = Schema.Struct({ - scope: Schema.String, + credentialScope: Schema.optional(Schema.String), sessionId: Schema.String, }); @@ -926,13 +926,13 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "oauth.start", description: - "Start an OAuth flow and return the authorization URL the user must open in a browser. Never put OAuth passwords, authorization codes, or client secrets in chat. For confidential clients, first call `secrets.create` for client id/secret and pass those secret ids in the strategy. After the browser callback completes, call `connections.list`, then configure the source with the returned connection id.", + "Start an OAuth flow and return the authorization URL the user must open in a browser. `credentialScope` chooses where Executor stores the OAuth connection/token secrets; omit it only in a single-scope local executor, otherwise call `scopes.list` and ask whether the connection should be personal/user-scoped or organization-scoped. OAuth permission scopes belong in `strategy.scopes`. Never put OAuth passwords, authorization codes, or client secrets in chat. For confidential clients, first call `secrets.create` for client id/secret and pass those secret ids in the strategy. After the browser callback completes, call `connections.list`, then configure the source with the returned connection id.", inputSchema: OAuthStartInputStd, outputSchema: OAuthStartOutputStd, execute: (input, { ctx }) => Effect.gen(function* () { const webBaseUrl = yield* requireWebBaseUrl(options.webBaseUrl); - const tokenScope = yield* resolveScopeInput(ctx.scopes, input.scope); + const tokenScope = yield* resolveScopeInput(ctx.scopes, input.credentialScope); const result = yield* ctx.oauth.start({ endpoint: input.endpoint, headers: input.headers, @@ -948,8 +948,8 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { ...result, instructions: result.authorizationUrl === null - ? "This OAuth flow completed without a browser handoff. Call connections.list to verify the connection id, then pass that connection id to the relevant source configuration tool." - : "The user needs to open this authorization URL in a browser and complete the OAuth/sign-in flow. Until the browser callback completes, no connection is available for binding. After the user finishes sign-in, call connections.list to find the connection id, then pass that connection id to the relevant source configuration tool.", + ? "This OAuth flow completed without a browser handoff. The OAuth connection/token secrets were saved to the selected credential scope. Call connections.list to verify the connection id, then pass that connection id to the relevant source configuration tool." + : "The user needs to open this authorization URL in a browser and complete the OAuth/sign-in flow. Until the browser callback completes, no connection is available for binding. After the user finishes sign-in, call connections.list to find the connection id, then pass that connection id to the relevant source configuration tool. The OAuth connection/token secrets are saved to the selected credential scope.", }; }).pipe( Effect.catchTags({ @@ -970,12 +970,12 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = { tool({ name: "oauth.cancel", description: - "Cancel a pending OAuth browser handoff if the user declines or the wrong flow was started.", + "`credentialScope` must match where `oauth.start` saved the pending browser handoff. Cancel it if the user declines or the wrong flow was started.", inputSchema: OAuthCancelInputStd, outputSchema: OAuthCancelOutputStd, execute: (input, { ctx }) => Effect.gen(function* () { - const scope = yield* resolveScopeInput(ctx.scopes, input.scope); + const scope = yield* resolveScopeInput(ctx.scopes, input.credentialScope); return yield* Effect.as(ctx.oauth.cancel(input.sessionId, scope), { cancelled: true, }); diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index e663640e9..fbcd99962 100644 --- a/packages/core/sdk/src/executor.test.ts +++ b/packages/core/sdk/src/executor.test.ts @@ -751,8 +751,12 @@ describe("createExecutor", () => { provider: "memory", }); + const schema = yield* executor.tools.schema("executor.coreTools.oauth.start"); + expect(schema?.inputTypeScript).toContain("credentialScope?: string"); + expect(schema?.inputTypeScript).not.toContain("scope: string; endpoint"); + const started = yield* executor.tools.invoke("executor.coreTools.oauth.start", { - scope: "test-scope", + credentialScope: "test-scope", endpoint: oauthServer.resourceUrl, connectionId: "agent-oauth", pluginId: "test-plugin", @@ -849,7 +853,7 @@ describe("createExecutor", () => { }); const started = yield* executor.tools.invoke("executor.coreTools.oauth.start", { - scope: "test", + credentialScope: "test", endpoint: oauthServer.resourceUrl, connectionId: "agent-browser-oauth", pluginId: "test-plugin", diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts index 281f8d3a8..bc8bf119a 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -564,7 +564,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ tool({ name: "addSource", description: - 'Add a Google Discovery source and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. Recommended flow: call `probeDiscovery`, create any OAuth client id/client secret values through `secrets.create` at the user\'s chosen credential scope, call `oauth.start` in the browser for OAuth sources, then pass `{kind:"oauth2", connectionId, clientIdSecretId, clientSecretSecretId, scopes}` or `{kind:"none"}` here.', + 'Add a Google Discovery source and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. Recommended flow: call `probeDiscovery`, create any OAuth client id/client secret values through `secrets.create` at the user\'s chosen credential scope, call `oauth.start` with `credentialScope` set to the user\'s chosen personal or organization credential scope for OAuth sources, then pass `{kind:"oauth2", connectionId, clientIdSecretId, clientSecretSecretId, scopes}` or `{kind:"none"}` here.', annotations: { requiresApproval: true, approvalDescription: "Add a Google Discovery source", @@ -608,7 +608,7 @@ export const googleDiscoveryPlugin = definePlugin(() => ({ tool({ name: "configureSource", description: - "Configure an existing Google Discovery source with concrete fields. Use `source` returned by `googleDiscovery.addSource` or `sources.list`. For OAuth, call `oauth.start` in the browser first, then pass the returned connection id and client secret ids through `auth`.", + "Configure an existing Google Discovery source with concrete fields. Use `source` returned by `googleDiscovery.addSource` or `sources.list`. For OAuth, call `oauth.start` with the target `credentialScope` first, then pass the returned connection id and client secret ids through `auth`.", annotations: { requiresApproval: true, approvalDescription: "Configure a Google Discovery source", diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 1bd3e1985..938dd21ed 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -993,7 +993,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { tool({ name: "addSource", description: - "Add a GraphQL endpoint and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. For API keys or bearer tokens, first call `executor.coreTools.secrets.create` at the user's chosen credential scope and pass secret refs through `credentials`. For OAuth, start the browser flow with `executor.coreTools.oauth.start`, verify completion with `connections.list`, then bind the connection through `credentials` or `graphql.configureSource`.", + "Add a GraphQL endpoint and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. For API keys or bearer tokens, first call `executor.coreTools.secrets.create` at the user's chosen credential scope and pass secret refs through `credentials`. For OAuth, start the browser flow with `executor.coreTools.oauth.start` using `credentialScope` set to the user's chosen personal or organization credential scope, verify completion with `connections.list`, then bind the connection through `credentials` or `graphql.configureSource`.", annotations: { requiresApproval: true, approvalDescription: "Add a GraphQL source", diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 29bfea9d0..ab6df0be0 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1716,7 +1716,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { tool({ name: "probeEndpoint", description: - "Probe a remote MCP endpoint before adding it. If the result requires OAuth, call `executor.coreTools.oauth.probe` and `executor.coreTools.oauth.start` first, then pass the resulting connection through `addSource` credentials or `mcp.configureSource`.", + "Probe a remote MCP endpoint before adding it. If the result requires OAuth, call `executor.coreTools.oauth.probe` and `executor.coreTools.oauth.start` with `credentialScope` set to the user's chosen personal or organization credential scope first, then pass the resulting connection through `addSource` credentials or `mcp.configureSource`.", inputSchema: McpProbeEndpointInputStandardSchema, outputSchema: McpProbeEndpointOutputStandardSchema, execute: (input) => @@ -1744,7 +1744,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { tool({ name: "addSource", description: - "Add an MCP source and register its tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. For remote OAuth-protected servers, first use `probeEndpoint` and the core OAuth browser handoff (`oauth.probe`, `oauth.start`), then bind the completed connection with `mcp.configureSource` if needed. For header/API-key auth, first call `secrets.create` at the user's chosen credential scope so the value is entered in the browser, then pass the secret reference in `credentials`. Remote sources are still saved if discovery fails; inspect the returned `discovery` field and use `sources.refresh` after credentials or network access are fixed.", + "Add an MCP source and register its tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. For remote OAuth-protected servers, first use `probeEndpoint` and the core OAuth browser handoff (`oauth.probe`, `oauth.start` with the user's chosen `credentialScope`), then bind the completed connection with `mcp.configureSource` if needed. For header/API-key auth, first call `secrets.create` at the user's chosen credential scope so the value is entered in the browser, then pass the secret reference in `credentials`. Remote sources are still saved if discovery fails; inspect the returned `discovery` field and use `sources.refresh` after credentials or network access are fixed.", annotations: { requiresApproval: true, approvalDescription: "Add an MCP source", diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 6bcdb1dec..2dd322e70 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -1445,7 +1445,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { tool({ name: "addSource", description: - "Add an OpenAPI source and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. Recommended flow: call `previewSpec`, choose or confirm namespace/name/baseUrl from the preview (baseUrl is only needed when the spec cannot infer one or the user wants an override), declare credential slots here for sensitive headers/query params, then call `secrets.create` and `openapi.configureSource` with the user's chosen credential scope for per-scope bindings. Use `oauth.start` for browser OAuth sign-in.", + "Add an OpenAPI source and register its operations as tools. Executor chooses the source install scope (local scope locally, organization scope in cloud) and returns it as `source`. Recommended flow: call `previewSpec`, choose or confirm namespace/name/baseUrl from the preview (baseUrl is only needed when the spec cannot infer one or the user wants an override), declare credential slots here for sensitive headers/query params, then call `secrets.create` and `openapi.configureSource` with the user's chosen credential scope for per-scope bindings. Use `oauth.start` with `credentialScope` set to the user's chosen personal or organization credential scope for browser OAuth sign-in.", annotations: { requiresApproval: true, approvalDescription: "Add an OpenAPI source", From be4669815a3212d1b45e142a392384b51c12f744 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Tue, 19 May 2026 00:52:49 -0700 Subject: [PATCH 14/15] fix: satisfy core tools ci --- .../src/sdk/__snapshots__/real-specs.test.ts.snap | 10 +++++----- packages/react/src/pages/secrets.tsx | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/plugins/openapi/src/sdk/__snapshots__/real-specs.test.ts.snap b/packages/plugins/openapi/src/sdk/__snapshots__/real-specs.test.ts.snap index 3cbb2e964..388289617 100644 --- a/packages/plugins/openapi/src/sdk/__snapshots__/real-specs.test.ts.snap +++ b/packages/plugins/openapi/src/sdk/__snapshots__/real-specs.test.ts.snap @@ -258,7 +258,7 @@ exports[`Real specs: Cloudflare API > preserves registered tool schema and TypeS "AccessAppPolicyLink": "{ id?: AccessSchemasUuid; precedence?: AccessPrecedence; }", "AccessAppPolicyRequest": "({ precedence?: AccessPrecedence; } & AccessPolicyReq)", "AccessAppPolicyResponse": "(AccessPolicyResp & { precedence?: AccessPrecedence; })", - "AccessAppReqEmbeddedPolicies": "{ policies?: (AccessAppPolicyLink | ({ [k: string]: unknown; } & AccessSchemasUuid) | ({ } & { id?: AccessSchemasUuid; } & AccessAppPolicyRequest))[]; }", + "AccessAppReqEmbeddedPolicies": "{ policies?: (AccessAppPolicyLink | ({ [k: string]: unknown; } & AccessSchemasUuid) | ({ [k: string]: unknown; } & { id?: AccessSchemasUuid; } & AccessAppPolicyRequest))[]; }", "AccessAppReqEmbeddedScimConfig": "{ scim_config?: AccessScimConfig; }", "AccessAppRequest": "SelfHostedApplication | SaaSApplication | BrowserSSHApplication | BrowserVNCApplication | AppLauncherApplication | DeviceEnrollmentPermissionsApplication | BrowserIsolationPermissionsApplication | GatewayIdentityProxyEndpointApplication | BookmarkApplication | InfrastructureApplication | BrowserRDPApplication | MCPServerApplication | MCPServerPortalApplication", "AccessAppRespEmbeddedPolicies": "{ policies?: AccessAppPolicyResponse[]; }", @@ -354,7 +354,7 @@ exports[`Real specs: Cloudflare API > preserves registered tool schema and TypeS "AccessUuid": "string", "AccessVncProps": "(SelfHostedApplication1 & { type?: (AccessType & { [k: string]: unknown; }); })", "AccessWarpProps": "(AccessFeatureAppProps & { domain?: { [k: string]: unknown; }; name?: string; type?: (AccessType & { [k: string]: unknown; }); })", - "AnyValidServiceToken": "{ any_valid_service_token: { }; }", + "AnyValidServiceToken": "{ any_valid_service_token: { [k: string]: unknown; }; }", "AppLauncherApplication": "(AccessAppLauncherProps & AccessAppReqEmbeddedPolicies)", "AppLauncherApplication1": "(AccessBasicAppResponseProps & AccessAppLauncherProps & AccessAppRespEmbeddedPolicies)", "AuthenticationContext": "{ auth_context: { ac_id: string; id: string; identity_provider_id: string; }; }", @@ -381,7 +381,7 @@ exports[`Real specs: Cloudflare API > preserves registered tool schema and TypeS "Email": "{ email: { email: string; }; }", "EmailDomain": "{ email_domain: { domain: string; }; }", "EmailList": "{ email_list: { id: string; }; }", - "Everyone": "{ everyone: { }; }", + "Everyone": "{ everyone: { [k: string]: unknown; }; }", "ExternalEvaluation": "{ external_evaluation: { evaluate_url: string; keys_url: string; }; }", "GatewayIdentityProxyEndpointApplication": "(AccessProxyEndpointProps & AccessAppReqEmbeddedPolicies)", "GatewayIdentityProxyEndpointApplication1": "(AccessBasicAppResponseProps & AccessProxyEndpointProps & AccessAppRespEmbeddedPolicies)", @@ -420,7 +420,7 @@ exports[`Real specs: Cloudflare API > preserves registered tool schema and TypeS "ServiceToken": "{ service_token: { token_id: string; }; }", "TargetCriteria": "{ port: AccessPort; target_attributes: AccessTargetAttributes; }", "UserRiskScore": "{ user_risk_score: { user_risk_score: ["low" | "medium" | "high" | "unscored", ...(("low" | "medium" | "high" | "unscored"))[]]; }; }", - "ValidCertificate": "{ certificate: { }; }", + "ValidCertificate": "{ certificate: { [k: string]: unknown; }; }", "ViaMCPServerPortalDestination": "{ mcp_server_id?: string; type?: "via_mcp_server_portal"; }", }, } @@ -4325,7 +4325,7 @@ exports[`Real specs: Vercel API > preserves registered tool schema and TypeScrip ], "type": "object", }, - "outputTypeScript": "{ aliasAssignedAt?: number | false | true | null; alwaysRefuseToBuild?: false | true; build: { env: string[]; }; buildArtifactUrls?: string[]; builds?: { use: string; src?: string; config?: { [k: string]: unknown; }; }[]; env: string[]; inspectorUrl: string | null; isInConcurrentBuildsQueue: false | true; isInSystemBuildsQueue: false | true; projectSettings: { nodeVersion?: "24.x" | "22.x" | "20.x" | "18.x" | "16.x" | "14.x" | "12.x" | "10.x" | "8.10.x"; buildCommand?: string | null; devCommand?: string | null; framework?: "blitzjs" | "nextjs" | "gatsby" | "remix" | "react-router" | "astro" | "hexo" | "eleventy" | "docusaurus-2" | "docusaurus" | "preact" | "solidstart-1" | "solidstart" | "dojo" | "ember" | "vue" | "scully" | "ionic-angular" | "angular" | "polymer" | "svelte" | "sveltekit" | "sveltekit-1" | "ionic-react" | "create-react-app" | "gridsome" | "umijs" | "sapper" | "saber" | "stencil" | "nuxtjs" | "redwoodjs" | "hugo" | "jekyll" | "brunch" | "middleman" | "zola" | "hydrogen" | "vite" | "tanstack-start" | "vitepress" | "vuepress" | "parcel" | "fastapi" | "flask" | "fasthtml" | "django" | "ash" | "sanity-v3" | "sanity" | "storybook" | "nitro" | "hono" | "express" | "h3" | "koa" | "nestjs" | "elysia" | "fastify" | "xmcp" | "python" | "ruby" | "rust" | "axum" | "actix-web" | "node" | "go" | "services" | "mastra" | null; commandForIgnoringBuildStep?: string | null; installCommand?: string | null; outputDirectory?: string | null; speedInsights?: { id: string; enabledAt?: number; disabledAt?: number; canceledAt?: number; hasData?: false | true; paidAt?: number; }; webAnalytics?: { id: string; disabledAt?: number; canceledAt?: number; enabledAt?: number; hasData?: true; }; }; integrations?: { status: "skipped" | "pending" | "ready" | "error" | "timeout"; startedAt: number; claimedAt?: number; completedAt?: number; skippedAt?: number; skippedBy?: string; }; images?: { sizes?: number[]; qualities?: number[]; domains?: string[]; remotePatterns?: { protocol?: "http" | "https"; hostname: string; port?: string; pathname?: string; search?: string; }[]; localPatterns?: { pathname?: string; search?: string; }[]; minimumCacheTTL?: number; formats?: ("image/avif" | "image/webp")[]; dangerouslyAllowSVG?: false | true; contentSecurityPolicy?: string; contentDispositionType?: "inline" | "attachment"; }; alias?: string[]; aliasAssigned: false | true; bootedAt: number; buildingAt: number; buildContainerFinishedAt?: number; buildSkipped: false | true; creator: { uid: string; username?: string; avatar?: string; }; initReadyAt?: number; isFirstBranchDeployment?: false | true; lambdas?: { id: string; createdAt?: number; readyState?: "BUILDING" | "ERROR" | "INITIALIZING" | "READY"; entrypoint?: string | null; readyStateAt?: number; output: { path: string; functionName: string; }[]; }[]; public: false | true; ready?: number; status: "QUEUED" | "BUILDING" | "ERROR" | "BLOCKED" | "INITIALIZING" | "READY" | "CANCELED"; team?: { id: string; name: string; slug: string; avatar?: string; }; userAliases?: string[]; previewCommentsEnabled?: false | true; ttyBuildLogs?: false | true; customEnvironment?: { id: string; slug: string; type: "production" | "preview" | "development"; description?: string; branchMatcher?: { type: "endsWith" | "startsWith" | "equals"; pattern: string; }; domains?: { name: string; apexName: string; projectId: string; redirect?: string | null; redirectStatusCode?: 307 | 301 | 302 | 308 | null; gitBranch?: string | null; customEnvironmentId?: string | null; updatedAt?: number; createdAt?: number; verified: false | true; verification?: { type: string; domain: string; value: string; reason: string; }[]; }[]; currentDeploymentAliases?: string[]; createdAt: number; updatedAt: number; } | { id: string; }; oomReport?: "out-of-memory"; readyStateReason?: string; aliasWarning?: { code: string; message: string; link?: string; action?: string; } | null; id: string; createdAt: number; readyState: "QUEUED" | "BUILDING" | "ERROR" | "BLOCKED" | "INITIALIZING" | "READY" | "CANCELED"; name: string; type: "LAMBDAS"; aliasError?: { code: string; message: string; } | null; aliasFinal?: string | null; autoAssignCustomDomains?: false | true; automaticAliases?: string[]; buildErrorAt?: number; checksState?: "registered" | "running" | "completed"; checksConclusion?: "succeeded" | "failed" | "skipped" | "canceled"; deletedAt?: number | null; defaultRoute?: string; canceledAt?: number; errorCode?: string; errorLink?: string; errorMessage?: string | null; errorStep?: string; passiveRegions?: string[]; gitSource?: { type: "github"; repoId: string | number; ref?: string | null; sha?: string; prId?: number | null; } | { type: "github"; org: string; repo: string; ref?: string | null; sha?: string; prId?: number | null; } | { type: "github-custom-host"; host: string; repoId: string | number; ref?: string | null; sha?: string; prId?: number | null; } | { type: "github-custom-host"; host: string; org: string; repo: string; ref?: string | null; sha?: string; prId?: number | null; } | { type: "github-limited"; repoId: string | number; ref?: string | null; sha?: string; prId?: number | null; } | { type: "github-limited"; org: string; repo: string; ref?: string | null; sha?: string; prId?: number | null; } | { type: "gitlab"; projectId: string | number; ref?: string | null; sha?: string; prId?: number | null; } | { type: "bitbucket"; workspaceUuid?: string; repoUuid: string; ref?: string | null; sha?: string; prId?: number | null; } | { type: "bitbucket"; owner: string; slug: string; ref?: string | null; sha?: string; prId?: number | null; } | { type: "vercel"; org?: string; repo?: string; sha: string; repoPushedAt?: number; ref?: string | null; prId?: number | null; } | { type: "custom"; ref: string; sha: string; gitUrl: string; } | { type: "github"; ref: string; sha: string; repoId: number; org?: string; repo?: string; } | { type: "github-custom-host"; host: string; ref: string; sha: string; repoId: number; org?: string; repo?: string; } | { type: "github-limited"; ref: string; sha: string; repoId: number; org?: string; repo?: string; } | { type: "gitlab"; ref: string; sha: string; projectId: number; } | { type: "bitbucket"; ref: string; sha: string; owner?: string; slug?: string; workspaceUuid: string; repoUuid: string; } | { type: "vercel"; ref: string; sha: string; org: string; repo: string; repoPushedAt?: number; }; manualProvisioning?: { state: "PENDING" | "COMPLETE" | "TIMEOUT"; completedAt?: number; }; meta: { [k: string]: string; }; originCacheRegion?: string; nodeVersion?: "24.x" | "22.x" | "20.x" | "18.x" | "16.x" | "14.x" | "12.x" | "10.x" | "8.10.x"; project?: { id: string; name: string; framework?: string | null; }; prebuilt?: false | true; readySubstate?: "STAGED" | "ROLLING" | "PROMOTED"; regions: string[]; softDeletedByRetention?: false | true; source?: "api-trigger-git-deploy" | "cli" | "clone/repo" | "git" | "import" | "import/repo" | "redeploy" | "v0-web"; target?: "staging" | "production" | null; undeletedAt?: number; url: string; userConfiguredDeploymentId?: string; version: 2; oidcTokenClaims?: { iss: string; sub: string; scope: string; aud: string; owner: string; owner_id: string; project: string; project_id: string; environment: string; custom_environment_id?: string; plan?: string; }; projectId: string; plan: "pro" | "enterprise" | "hobby"; platform?: { source: { name: string; }; origin: { type: "id" | "url"; value: string; }; creator: { name: string; avatar?: string; }; meta?: { [k: string]: string; }; }; connectBuildsEnabled?: false | true; connectConfigurationId?: string; createdIn: string; crons?: { schedule: string; path: string; }[]; functions?: { [k: string]: { architecture?: "x86_64" | "arm64"; memory?: number; maxDuration?: number | "max"; regions?: string[]; functionFailoverRegions?: string[]; runtime?: string; includeFiles?: string; excludeFiles?: string; experimentalTriggers?: ({ type: "queue/v1beta"; consumer: string; topic: string; maxDeliveries?: number; retryAfterSeconds?: number; initialDelaySeconds?: number; maxConcurrency?: number; } | { type: "queue/v2beta"; topic: string; maxDeliveries?: number; retryAfterSeconds?: number; initialDelaySeconds?: number; maxConcurrency?: number; })[]; supportsCancellation?: false | true; }; } | null; monorepoManager?: string | null; ownerId: string; passiveConnectConfigurationId?: string; routes: ({ src: string; dest?: string; headers?: { [k: string]: string; }; methods?: string[]; continue?: (false | true); override?: (false | true); caseSensitive?: (false | true); check?: (false | true); important?: (false | true); status?: number; has?: ({ type: "host"; value: (string | { eq?: (string | number); neq?: string; inc?: string[]; ninc?: string[]; pre?: string; suf?: string; re?: string; gt?: number; gte?: number; lt?: number; lte?: number; }); } | { type: ("header" | "cookie" | "query"); key: string; value?: (string | { eq?: (string | number); neq?: string; inc?: string[]; ninc?: string[]; pre?: string; suf?: string; re?: string; gt?: number; gte?: number; lt?: number; lte?: number; }); })[]; missing?: ({ type: "host"; value: (string | { eq?: (string | number); neq?: string; inc?: string[]; ninc?: string[]; pre?: string; suf?: string; re?: string; gt?: number; gte?: number; lt?: number; lte?: number; }); } | { type: ("header" | "cookie" | "query"); key: string; value?: (string | { eq?: (string | number); neq?: string; inc?: string[]; ninc?: string[]; pre?: string; suf?: string; re?: string; gt?: number; gte?: number; lt?: number; lte?: number; }); })[]; mitigate?: { action: ("challenge" | "deny"); }; transforms?: { type: ("request.headers" | "request.query" | "response.headers"); op: ("append" | "set" | "delete"); target: { key: (string | { eq?: (string | number); neq?: string; inc?: string[]; ninc?: string[]; pre?: string; suf?: string; gt?: number; gte?: number; lt?: number; lte?: number; }); }; args?: (string | string[]); env?: string[]; }[]; env?: string[]; locale?: { redirect?: { [k: string]: string; }; cookie?: string; }; source?: string; destination?: string; statusCode?: number; middlewarePath?: string; middlewareRawSrc?: string[]; middleware?: number; respectOriginCacheControl?: (false | true); } | { handle: ("error" | "filesystem" | "hit" | "miss" | "rewrite" | "resource"); src?: string; dest?: string; status?: number; } | { src: string; continue: (false | true); middleware: 0; })[] | null; gitRepo?: { namespace: string; projectId: number; type: "gitlab"; url: string; path: string; defaultBranch: string; name: string; private: false | true; ownerType: "team" | "user"; } | { org: string; repo: string; repoId: number; type: "github"; repoOwnerId: number; path: string; defaultBranch: string; name: string; private: false | true; ownerType: "team" | "user"; } | { owner: string; repoUuid: string; slug: string; type: "bitbucket"; workspaceUuid: string; path: string; defaultBranch: string; name: string; private: false | true; ownerType: "team" | "user"; } | { org: string; repo: string; type: "vercel"; path: string; defaultBranch: string; name: string; private: false | true; ownerType: "team" | "user"; } | null; flags?: { definitions: { [k: string]: { options?: { value: FlagJSONValue; label?: string; }[]; url?: string; description?: string; }; }; } | { }[]; microfrontends?: { isDefaultApp?: false; defaultAppProjectName: string; defaultRoute?: string; groupIds: [string, ...(string)[]]; } | { isDefaultApp: true; mfeConfigUploadState?: "success" | "waiting_on_build" | "no_config"; defaultAppProjectName: string; defaultRoute?: string; groupIds: [string, ...(string)[]]; }; config?: { version?: number; functionType: "standard" | "fluid"; functionMemoryType: "standard" | "standard_legacy" | "performance"; functionTimeout: number | null; secureComputePrimaryRegion: string | null; secureComputeFallbackRegion: string | null; isUsingActiveCPU?: false | true; resourceConfig?: { buildQueue?: { configuration?: "SKIP_NAMESPACE_QUEUE" | "WAIT_FOR_NAMESPACE_QUEUE"; }; elasticConcurrency?: "TEAM_SETTING" | "PROJECT_SETTING" | "SKIP_QUEUE"; buildMachine?: { purchaseType?: "enhanced" | "turbo" | "standard" | null; }; }; }; checks?: { "deployment-alias": { state: "succeeded" | "failed" | "pending"; startedAt: number; completedAt?: number; }; }; seatBlock?: { blockCode: "TEAM_ACCESS_REQUIRED" | "COMMIT_AUTHOR_REQUIRED"; userId?: string; isVerified?: false | true; gitUserId?: string | number; gitProvider?: "gitlab" | "bitbucket" | "github"; }; attribution?: { commitMeta?: { email?: string; name?: string; isVerified?: false | true; }; gitUser?: { id: string | number; login: string; type?: string; provider?: string; }; vercelUser?: { id: string; username: string; teamRoles?: string[]; }; }; }", + "outputTypeScript": "{ aliasAssignedAt?: number | false | true | null; alwaysRefuseToBuild?: false | true; build: { env: string[]; }; buildArtifactUrls?: string[]; builds?: { use: string; src?: string; config?: { [k: string]: unknown; }; }[]; env: string[]; inspectorUrl: string | null; isInConcurrentBuildsQueue: false | true; isInSystemBuildsQueue: false | true; projectSettings: { nodeVersion?: "24.x" | "22.x" | "20.x" | "18.x" | "16.x" | "14.x" | "12.x" | "10.x" | "8.10.x"; buildCommand?: string | null; devCommand?: string | null; framework?: "blitzjs" | "nextjs" | "gatsby" | "remix" | "react-router" | "astro" | "hexo" | "eleventy" | "docusaurus-2" | "docusaurus" | "preact" | "solidstart-1" | "solidstart" | "dojo" | "ember" | "vue" | "scully" | "ionic-angular" | "angular" | "polymer" | "svelte" | "sveltekit" | "sveltekit-1" | "ionic-react" | "create-react-app" | "gridsome" | "umijs" | "sapper" | "saber" | "stencil" | "nuxtjs" | "redwoodjs" | "hugo" | "jekyll" | "brunch" | "middleman" | "zola" | "hydrogen" | "vite" | "tanstack-start" | "vitepress" | "vuepress" | "parcel" | "fastapi" | "flask" | "fasthtml" | "django" | "ash" | "sanity-v3" | "sanity" | "storybook" | "nitro" | "hono" | "express" | "h3" | "koa" | "nestjs" | "elysia" | "fastify" | "xmcp" | "python" | "ruby" | "rust" | "axum" | "actix-web" | "node" | "go" | "services" | "mastra" | null; commandForIgnoringBuildStep?: string | null; installCommand?: string | null; outputDirectory?: string | null; speedInsights?: { id: string; enabledAt?: number; disabledAt?: number; canceledAt?: number; hasData?: false | true; paidAt?: number; }; webAnalytics?: { id: string; disabledAt?: number; canceledAt?: number; enabledAt?: number; hasData?: true; }; }; integrations?: { status: "skipped" | "pending" | "ready" | "error" | "timeout"; startedAt: number; claimedAt?: number; completedAt?: number; skippedAt?: number; skippedBy?: string; }; images?: { sizes?: number[]; qualities?: number[]; domains?: string[]; remotePatterns?: { protocol?: "http" | "https"; hostname: string; port?: string; pathname?: string; search?: string; }[]; localPatterns?: { pathname?: string; search?: string; }[]; minimumCacheTTL?: number; formats?: ("image/avif" | "image/webp")[]; dangerouslyAllowSVG?: false | true; contentSecurityPolicy?: string; contentDispositionType?: "inline" | "attachment"; }; alias?: string[]; aliasAssigned: false | true; bootedAt: number; buildingAt: number; buildContainerFinishedAt?: number; buildSkipped: false | true; creator: { uid: string; username?: string; avatar?: string; }; initReadyAt?: number; isFirstBranchDeployment?: false | true; lambdas?: { id: string; createdAt?: number; readyState?: "BUILDING" | "ERROR" | "INITIALIZING" | "READY"; entrypoint?: string | null; readyStateAt?: number; output: { path: string; functionName: string; }[]; }[]; public: false | true; ready?: number; status: "QUEUED" | "BUILDING" | "ERROR" | "BLOCKED" | "INITIALIZING" | "READY" | "CANCELED"; team?: { id: string; name: string; slug: string; avatar?: string; }; userAliases?: string[]; previewCommentsEnabled?: false | true; ttyBuildLogs?: false | true; customEnvironment?: { id: string; slug: string; type: "production" | "preview" | "development"; description?: string; branchMatcher?: { type: "endsWith" | "startsWith" | "equals"; pattern: string; }; domains?: { name: string; apexName: string; projectId: string; redirect?: string | null; redirectStatusCode?: 307 | 301 | 302 | 308 | null; gitBranch?: string | null; customEnvironmentId?: string | null; updatedAt?: number; createdAt?: number; verified: false | true; verification?: { type: string; domain: string; value: string; reason: string; }[]; }[]; currentDeploymentAliases?: string[]; createdAt: number; updatedAt: number; } | { id: string; }; oomReport?: "out-of-memory"; readyStateReason?: string; aliasWarning?: { code: string; message: string; link?: string; action?: string; } | null; id: string; createdAt: number; readyState: "QUEUED" | "BUILDING" | "ERROR" | "BLOCKED" | "INITIALIZING" | "READY" | "CANCELED"; name: string; type: "LAMBDAS"; aliasError?: { code: string; message: string; } | null; aliasFinal?: string | null; autoAssignCustomDomains?: false | true; automaticAliases?: string[]; buildErrorAt?: number; checksState?: "registered" | "running" | "completed"; checksConclusion?: "succeeded" | "failed" | "skipped" | "canceled"; deletedAt?: number | null; defaultRoute?: string; canceledAt?: number; errorCode?: string; errorLink?: string; errorMessage?: string | null; errorStep?: string; passiveRegions?: string[]; gitSource?: { type: "github"; repoId: string | number; ref?: string | null; sha?: string; prId?: number | null; } | { type: "github"; org: string; repo: string; ref?: string | null; sha?: string; prId?: number | null; } | { type: "github-custom-host"; host: string; repoId: string | number; ref?: string | null; sha?: string; prId?: number | null; } | { type: "github-custom-host"; host: string; org: string; repo: string; ref?: string | null; sha?: string; prId?: number | null; } | { type: "github-limited"; repoId: string | number; ref?: string | null; sha?: string; prId?: number | null; } | { type: "github-limited"; org: string; repo: string; ref?: string | null; sha?: string; prId?: number | null; } | { type: "gitlab"; projectId: string | number; ref?: string | null; sha?: string; prId?: number | null; } | { type: "bitbucket"; workspaceUuid?: string; repoUuid: string; ref?: string | null; sha?: string; prId?: number | null; } | { type: "bitbucket"; owner: string; slug: string; ref?: string | null; sha?: string; prId?: number | null; } | { type: "vercel"; org?: string; repo?: string; sha: string; repoPushedAt?: number; ref?: string | null; prId?: number | null; } | { type: "custom"; ref: string; sha: string; gitUrl: string; } | { type: "github"; ref: string; sha: string; repoId: number; org?: string; repo?: string; } | { type: "github-custom-host"; host: string; ref: string; sha: string; repoId: number; org?: string; repo?: string; } | { type: "github-limited"; ref: string; sha: string; repoId: number; org?: string; repo?: string; } | { type: "gitlab"; ref: string; sha: string; projectId: number; } | { type: "bitbucket"; ref: string; sha: string; owner?: string; slug?: string; workspaceUuid: string; repoUuid: string; } | { type: "vercel"; ref: string; sha: string; org: string; repo: string; repoPushedAt?: number; }; manualProvisioning?: { state: "PENDING" | "COMPLETE" | "TIMEOUT"; completedAt?: number; }; meta: { [k: string]: string; }; originCacheRegion?: string; nodeVersion?: "24.x" | "22.x" | "20.x" | "18.x" | "16.x" | "14.x" | "12.x" | "10.x" | "8.10.x"; project?: { id: string; name: string; framework?: string | null; }; prebuilt?: false | true; readySubstate?: "STAGED" | "ROLLING" | "PROMOTED"; regions: string[]; softDeletedByRetention?: false | true; source?: "api-trigger-git-deploy" | "cli" | "clone/repo" | "git" | "import" | "import/repo" | "redeploy" | "v0-web"; target?: "staging" | "production" | null; undeletedAt?: number; url: string; userConfiguredDeploymentId?: string; version: 2; oidcTokenClaims?: { iss: string; sub: string; scope: string; aud: string; owner: string; owner_id: string; project: string; project_id: string; environment: string; custom_environment_id?: string; plan?: string; }; projectId: string; plan: "pro" | "enterprise" | "hobby"; platform?: { source: { name: string; }; origin: { type: "id" | "url"; value: string; }; creator: { name: string; avatar?: string; }; meta?: { [k: string]: string; }; }; connectBuildsEnabled?: false | true; connectConfigurationId?: string; createdIn: string; crons?: { schedule: string; path: string; }[]; functions?: { [k: string]: { architecture?: "x86_64" | "arm64"; memory?: number; maxDuration?: number | "max"; regions?: string[]; functionFailoverRegions?: string[]; runtime?: string; includeFiles?: string; excludeFiles?: string; experimentalTriggers?: ({ type: "queue/v1beta"; consumer: string; topic: string; maxDeliveries?: number; retryAfterSeconds?: number; initialDelaySeconds?: number; maxConcurrency?: number; } | { type: "queue/v2beta"; topic: string; maxDeliveries?: number; retryAfterSeconds?: number; initialDelaySeconds?: number; maxConcurrency?: number; })[]; supportsCancellation?: false | true; }; } | null; monorepoManager?: string | null; ownerId: string; passiveConnectConfigurationId?: string; routes: ({ src: string; dest?: string; headers?: { [k: string]: string; }; methods?: string[]; continue?: (false | true); override?: (false | true); caseSensitive?: (false | true); check?: (false | true); important?: (false | true); status?: number; has?: ({ type: "host"; value: (string | { eq?: (string | number); neq?: string; inc?: string[]; ninc?: string[]; pre?: string; suf?: string; re?: string; gt?: number; gte?: number; lt?: number; lte?: number; }); } | { type: ("header" | "cookie" | "query"); key: string; value?: (string | { eq?: (string | number); neq?: string; inc?: string[]; ninc?: string[]; pre?: string; suf?: string; re?: string; gt?: number; gte?: number; lt?: number; lte?: number; }); })[]; missing?: ({ type: "host"; value: (string | { eq?: (string | number); neq?: string; inc?: string[]; ninc?: string[]; pre?: string; suf?: string; re?: string; gt?: number; gte?: number; lt?: number; lte?: number; }); } | { type: ("header" | "cookie" | "query"); key: string; value?: (string | { eq?: (string | number); neq?: string; inc?: string[]; ninc?: string[]; pre?: string; suf?: string; re?: string; gt?: number; gte?: number; lt?: number; lte?: number; }); })[]; mitigate?: { action: ("challenge" | "deny"); }; transforms?: { type: ("request.headers" | "request.query" | "response.headers"); op: ("append" | "set" | "delete"); target: { key: (string | { eq?: (string | number); neq?: string; inc?: string[]; ninc?: string[]; pre?: string; suf?: string; gt?: number; gte?: number; lt?: number; lte?: number; }); }; args?: (string | string[]); env?: string[]; }[]; env?: string[]; locale?: { redirect?: { [k: string]: string; }; cookie?: string; }; source?: string; destination?: string; statusCode?: number; middlewarePath?: string; middlewareRawSrc?: string[]; middleware?: number; respectOriginCacheControl?: (false | true); } | { handle: ("error" | "filesystem" | "hit" | "miss" | "rewrite" | "resource"); src?: string; dest?: string; status?: number; } | { src: string; continue: (false | true); middleware: 0; })[] | null; gitRepo?: { namespace: string; projectId: number; type: "gitlab"; url: string; path: string; defaultBranch: string; name: string; private: false | true; ownerType: "team" | "user"; } | { org: string; repo: string; repoId: number; type: "github"; repoOwnerId: number; path: string; defaultBranch: string; name: string; private: false | true; ownerType: "team" | "user"; } | { owner: string; repoUuid: string; slug: string; type: "bitbucket"; workspaceUuid: string; path: string; defaultBranch: string; name: string; private: false | true; ownerType: "team" | "user"; } | { org: string; repo: string; type: "vercel"; path: string; defaultBranch: string; name: string; private: false | true; ownerType: "team" | "user"; } | null; flags?: { definitions: { [k: string]: { options?: { value: FlagJSONValue; label?: string; }[]; url?: string; description?: string; }; }; } | { [k: string]: unknown; }[]; microfrontends?: { isDefaultApp?: false; defaultAppProjectName: string; defaultRoute?: string; groupIds: [string, ...(string)[]]; } | { isDefaultApp: true; mfeConfigUploadState?: "success" | "waiting_on_build" | "no_config"; defaultAppProjectName: string; defaultRoute?: string; groupIds: [string, ...(string)[]]; }; config?: { version?: number; functionType: "standard" | "fluid"; functionMemoryType: "standard" | "standard_legacy" | "performance"; functionTimeout: number | null; secureComputePrimaryRegion: string | null; secureComputeFallbackRegion: string | null; isUsingActiveCPU?: false | true; resourceConfig?: { buildQueue?: { configuration?: "SKIP_NAMESPACE_QUEUE" | "WAIT_FOR_NAMESPACE_QUEUE"; }; elasticConcurrency?: "TEAM_SETTING" | "PROJECT_SETTING" | "SKIP_QUEUE"; buildMachine?: { purchaseType?: "enhanced" | "turbo" | "standard" | null; }; }; }; checks?: { "deployment-alias": { state: "succeeded" | "failed" | "pending"; startedAt: number; completedAt?: number; }; }; seatBlock?: { blockCode: "TEAM_ACCESS_REQUIRED" | "COMMIT_AUTHOR_REQUIRED"; userId?: string; isVerified?: false | true; gitUserId?: string | number; gitProvider?: "gitlab" | "bitbucket" | "github"; }; attribution?: { commitMeta?: { email?: string; name?: string; isVerified?: false | true; }; gitUser?: { id: string | number; login: string; type?: string; provider?: string; }; vercelUser?: { id: string; username: string; teamRoles?: string[]; }; }; }", "schemaDefinitionCount": 1, "schemaDefinitionNames": [ "FlagJSONValue", diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index afffadcb7..c5c0522e3 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -29,6 +29,7 @@ import { SelectTrigger, SelectValue, } from "../components/select"; +import { Label } from "../components/label"; import { DropdownMenu, DropdownMenuContent, @@ -165,7 +166,7 @@ function AddSecretDialogContent(props: {
- +