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/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/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/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/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 363404ebc..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({ @@ -653,6 +668,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/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/notes/source-setup-sdk-flow.md b/notes/source-setup-sdk-flow.md new file mode 100644 index 000000000..ec01c25fd --- /dev/null +++ b/notes/source-setup-sdk-flow.md @@ -0,0 +1,12 @@ +# Source setup orchestration + +The web source-add flow currently owns a lot of business logic that agents have +to rediscover through low-level tools: preset lookup, URL-to-endpoint mapping, +endpoint probing, OAuth strategy choice, connection id generation, browser +handoff, credential binding, and final source registration. + +Longer term, consider moving this into an SDK-level source setup service so the +frontend and agent tools share the same state machine. The frontend would render +steps from the service, while agent tools would return the same state with +model-facing `instructions` fields. Keep low-level plugin tools as escape +hatches, but make common preset flows first-class. 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/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 0b69f0b50..cdf46a221 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, scope } = Route.useSearch(); + const hasPrefill = name != null || secretId != null; + 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/execution/src/engine.ts b/packages/core/execution/src/engine.ts index 769cda661..aacb048fe 100644 --- a/packages/core/execution/src/engine.ts +++ b/packages/core/execution/src/engine.ts @@ -107,19 +107,31 @@ export const formatPausedExecution = ( const lines: string[] = [`Execution paused: ${req.message}`]; const isUrlElicitation = Predicate.isTagged(req, "UrlElicitation"); const isFormElicitation = Predicate.isTagged(req, "FormElicitation"); + const requestedSchema = isFormElicitation ? req.requestedSchema : undefined; + const hasRequestedSchema = + requestedSchema !== undefined && Object.keys(requestedSchema).length > 0; + const instructions = isUrlElicitation + ? `The user needs to open this URL in a browser and complete the flow. After the user finishes, call the resume tool with executionId "${paused.id}" and action "accept".` + : hasRequestedSchema + ? `Ask the user for values matching requestedSchema. Then call the resume tool with executionId "${paused.id}", action "accept", and content matching requestedSchema. If the user declines, call resume with action "decline" or "cancel".` + : `This is a model-side confirmation gate; there is no browser form to open. Ask the user whether to approve the paused tool call. If the user approves, call the resume tool with executionId "${paused.id}" and action "accept". If the user declines, call resume with action "decline" or "cancel".`; if (isUrlElicitation) { lines.push(`\nOpen this URL in a browser:\n${req.url}`); - lines.push("\nAfter the browser flow, resume with the executionId below:"); + lines.push('\nAfter the browser flow, call the resume tool with action "accept".'); + } else if (hasRequestedSchema) { + lines.push( + "\nAsk the user for a response matching the requested schema, then call the resume tool.", + ); + lines.push(`\nRequested schema:\n${JSON.stringify(requestedSchema, null, 2)}`); } else { - lines.push("\nResume with the executionId below and a response matching the requested schema:"); - const schema = req.requestedSchema; - if (schema && Object.keys(schema).length > 0) { - lines.push(`\nRequested schema:\n${JSON.stringify(schema, null, 2)}`); - } + lines.push( + '\nThis is a model-side confirmation gate; no browser form is waiting. Ask the user whether to approve, then call the resume tool with action "accept", "decline", or "cancel".', + ); } lines.push(`\nexecutionId: ${paused.id}`); + lines.push(`\ninstructions: ${instructions}`); return { text: lines.join("\n"), @@ -129,6 +141,7 @@ export const formatPausedExecution = ( interaction: { kind: isUrlElicitation ? "url" : "form", message: req.message, + instructions, toolId: String(paused.elicitationContext.toolId), args: paused.elicitationContext.args, ...(isUrlElicitation ? { url: req.url } : {}), 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/core/sdk/src/client.ts b/packages/core/sdk/src/client.ts index d52cec093..ed07cbd01 100644 --- a/packages/core/sdk/src/client.ts +++ b/packages/core/sdk/src/client.ts @@ -96,6 +96,9 @@ export interface SourcePreset { /** URL passed as `initialUrl` to the add form. Omit for presets that * don't use a URL (e.g. stdio MCP presets). */ readonly url?: string; + /** Endpoint passed to agent-facing probe/add tools when their schema + * uses `endpoint` instead of `url`. */ + readonly endpoint?: string; /** Optional icon URL (favicon, logo). */ readonly icon?: string; /** Shown in the top-level grid on the sources page when true. */ diff --git a/packages/core/sdk/src/core-tools.ts b/packages/core/sdk/src/core-tools.ts new file mode 100644 index 000000000..cdf70c6fd --- /dev/null +++ b/packages/core/sdk/src/core-tools.ts @@ -0,0 +1,994 @@ +// --------------------------------------------------------------------------- +// core-tools plugin +// +// 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 { 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"; + +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(SecretRefOutput), +}); + +const SecretsCreateInput = Schema.Struct({ + name: Schema.String, + scope: Schema.optional(ScopeName), + provider: Schema.optional(Schema.String), +}); + +const SecretsCreateOutput = Schema.Struct({ + id: Schema.String, + url: Schema.String, + instructions: Schema.String, +}); + +const SecretPointerInput = Schema.Struct({ + id: Schema.String, +}); + +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 SourcePresetOutput = Schema.Struct({ + pluginId: Schema.String, + id: Schema.String, + name: Schema.String, + summary: Schema.String, + url: Schema.optional(Schema.String), + endpoint: 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 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({ + credentialScope: Schema.optional(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 })), + instructions: Schema.String, +}); + +const OAuthCancelInput = Schema.Struct({ + credentialScope: Schema.optional(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 SourcesPresetsInputStd = schemaToStandard< + typeof SourcesPresetsInput.Type, + typeof SourcesPresetsInput.Encoded +>(SourcesPresetsInput); +const SourcesPresetsOutputStd = schemaToStandard(SourcesPresetsOutput); +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 { + readonly webBaseUrl?: string; +} + +class CoreToolsConfigurationError extends Data.TaggedError("CoreToolsConfigurationError")<{ + readonly message: string; +}> {} + +class CoreToolsScopeNotFoundError extends Data.TaggedError("CoreToolsScopeNotFoundError")<{ + readonly scope: string; + readonly message: string; +}> {} + +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, + packageName: "@executor-js/sdk/core-tools", + storage: () => ({}), + extension: () => ({}), + + staticSources: () => [ + { + id: "coreTools", + kind: "executor", + name: "Executor", + tools: [ + tool({ + name: "scopes.list", + description: + "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) => ({ id: String(s.id), name: s.name })), + }), + }), + tool({ + name: "secrets.list", + description: + "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.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 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(); + 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(), + 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 }) => + Effect.succeed(oauthToolFailure("secret_handoff_not_configured", message)), + CoreToolsScopeNotFoundError: ({ message, scope }) => + Effect.succeed(oauthToolFailure("scope_not_found", message, { scope })), + }), + ), + }), + 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. 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 })), + }), + 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 plugin-specific configureSource tools, `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. 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. For MCP and GraphQL presets, pass `endpoint` to the probe/add tools. For OpenAPI and Google Discovery presets, pass `url` to the preview/probe and add tools. For stdio MCP presets, use the returned command/args/env.", + 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.configure", + description: + '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", + }, + 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 a plugin-specific configureSource tool.", + 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 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", + }, + 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 the relevant plugin-specific configureSource tool.", + 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. `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.credentialScope); + const result = 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, + }); + return { + ...result, + instructions: + result.authorizationUrl === null + ? "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({ + 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: + "`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.credentialScope); + 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 })), + ), + ), + }), + ], + }, + ], +})); + +export default coreToolsPlugin; diff --git a/packages/core/sdk/src/executor.test.ts b/packages/core/sdk/src/executor.test.ts index f36055a37..fbcd99962 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; @@ -191,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 }>({ @@ -473,4 +487,407 @@ 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"); + + expect((yield* executor.tools.list()).map((tool) => tool.id)).not.toContain( + "executor.coreTools.sources.configureSchemas", + ); + 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" }, + 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), + 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"); + 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"); + + 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()); + }), + ); + + 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 result = yield* executor.tools.invoke("executor.coreTools.secrets.create", { + name: "api-token", + }); + + 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(); + 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 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", { + credentialScope: "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" }, + instructions: expect.stringContaining("completed without a browser handoff"), + }); + + 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", { + credentialScope: "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, + instructions: expect.stringContaining("open this authorization URL"), + }); + + 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 5674d7f23..0d909e8f6 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, @@ -28,6 +29,7 @@ import { } from "./fuma-runtime"; import { makeFumaBlobStore, pluginBlobStore } from "./blob"; +import { coreToolsPlugin } from "./core-tools"; import { ConnectionProviderState, ConnectionRef, @@ -110,6 +112,7 @@ import type { Elicit, PluginCtx, PluginExtensions, + SourceConfigureSchema, StaticSourceDecl, StaticToolDecl, StaticToolSchema, @@ -410,6 +413,20 @@ export interface ExecutorConfig | 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, @@ -1031,7 +1086,25 @@ export const createExecutor = collectTables(plugins), catch: (cause) => storageFailureFromUnknown("Failed to collect executor tables", cause), @@ -1051,14 +1124,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), @@ -1661,6 +1726,7 @@ export const createExecutor = ({ id: String(ref.id), + scopeId: ref.scopeId, name: ref.name, provider: ref.provider, })); @@ -3051,6 +3117,36 @@ 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), + presets: () => + Array.from(runtimes.values()).flatMap(({ plugin }) => + (plugin.sourcePresets ?? []).map((preset) => ({ + ...preset, + pluginId: plugin.id, + })), + ), + }, + policies: { + list: () => policiesList(), + create: (input) => policiesCreate(input), + update: (input) => policiesUpdate(input), + remove: (input) => policiesRemove(input), }, definitions: { register: (input: DefinitionsInput) => @@ -3061,6 +3157,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), }, @@ -3068,6 +3168,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), @@ -3092,11 +3195,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) { @@ -3120,7 +3226,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 presets: () => readonly SourcePresetCatalogEntry[]; + }; + 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 +179,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 +220,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 +437,30 @@ export interface SourceConfigureDecl { ) => Effect.Effect; } +export interface SourceConfigureSchema { + readonly pluginId: string; + readonly type: string; + readonly schema?: unknown; +} + +export interface SourcePreset { + readonly id: string; + readonly name: string; + readonly summary: string; + readonly url?: string; + readonly endpoint?: 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. // --------------------------------------------------------------------------- @@ -432,6 +522,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/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 = ( 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/hosts/mcp/src/server.test.ts b/packages/hosts/mcp/src/server.test.ts index 8afef4ada..34a5b669e 100644 --- a/packages/hosts/mcp/src/server.test.ts +++ b/packages/hosts/mcp/src/server.test.ts @@ -559,6 +559,35 @@ describe("MCP host server — client without elicitation (pause/resume)", () => const structured = result.structuredContent as Record; expect(structured?.executionId).toBe("exec_42"); expect(structured?.status).toBe("waiting_for_interaction"); + const interaction = structured.interaction as Record; + expect(interaction.instructions).toContain( + "Ask the user for values matching requestedSchema", + ); + }); + }); + + it("default model resume mode explains empty form schemas as model-side confirmation", async () => { + const engine = makeStubEngine({ + executeWithPause: () => + Effect.succeed( + makePausedResult( + "exec_confirm", + FormElicitation.make({ message: "Confirm source add", requestedSchema: {} }), + ), + ), + }); + + await withClient(engine, NO_CAPS, async (client) => { + const result = await client.callTool({ + name: "execute", + arguments: { code: "confirm-me" }, + }); + + expect(textOf(result)).toContain("no browser form is waiting"); + const structured = result.structuredContent as Record; + const interaction = structured.interaction as Record; + expect(interaction.instructions).toContain("model-side confirmation gate"); + expect(interaction.instructions).toContain('action "accept"'); }); }); 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/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..87a377d9c 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.test.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.test.ts @@ -425,6 +425,24 @@ 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", + "executor.googleDiscovery.configureSource", + ]), + ); + + 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..bc8bf119a 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -5,9 +5,12 @@ import { SourceDetectionResult, ToolResult, Usage, + defaultSourceInstallScopeId, definePlugin, + tool, resolveSecretBackedMap, type PluginCtx, + type StaticToolSchema, type StorageFailure, type ToolAnnotations, } from "@executor-js/sdk/core"; @@ -17,18 +20,19 @@ 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"; -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 +118,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 +125,118 @@ 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, +}); +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, +}); + +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 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< + A, + I + >; + +const GoogleDiscoveryProbeInputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryProbeInputSchema, +); +const GoogleDiscoveryProbeOutputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryProbeOutputSchema, +); +const GoogleDiscoveryAddSourceInputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryStaticAddSourceInputSchema, +); +const GoogleDiscoveryAddSourceOutputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryAddSourceOutputSchema, +); +const GoogleDiscoveryGetSourceInputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryGetSourceInputSchema, +); +const GoogleDiscoveryGetSourceOutputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryGetSourceOutputSchema, +); +const GoogleDiscoveryConfigureSourceInputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryConfigureSourceInputSchema, +); +const GoogleDiscoveryConfigureSourceOutputStandardSchema = schemaToStaticToolSchema( + GoogleDiscoveryConfigureSourceOutputSchema, +); + +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. */ @@ -439,11 +541,105 @@ export type GoogleDiscoveryPluginExtension = ReturnType ({ id: "googleDiscovery" as const, packageName: "@executor-js/plugin-google-discovery", + sourcePresets: googleDiscoveryPresets, schema: googleDiscoverySchema, storage: (deps) => makeGoogleDiscoveryStore(deps), 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. 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", + }, + inputSchema: GoogleDiscoveryAddSourceInputStandardSchema, + outputSchema: GoogleDiscoveryAddSourceOutputStandardSchema, + execute: (input, { ctx }) => { + 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 }, + }), + ); + }, + }), + 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 `googleDiscovery.configureSource`, `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 }), + ); + }, + }), + 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` 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", + }, + 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 }), + ); + }, + }), + ], + }, + ], + + 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..9eb8766d1 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -630,7 +630,9 @@ 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"); + expect(ids).toContain("executor.graphql.configureSource"); const queryTool = tools.find((t) => t.id === "test_api.query.hello"); expect(queryTool?.description).toBe("Say hello"); @@ -770,7 +772,6 @@ describe("graphqlPlugin", () => { const result = yield* executor.tools.invoke( "executor.graphql.addSource", { - scope: String(orgScope), endpoint: "http://localhost:4000/graphql", name: "Via Static", introspectionJson, @@ -778,17 +779,60 @@ 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, ); + 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", + { + 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()] })); @@ -796,8 +840,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"); @@ -823,7 +870,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 1eb840e09..938dd21ed 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, @@ -40,6 +41,7 @@ import { import { extract } from "./extract"; import { GraphqlIntrospectionError, GraphqlInvocationError } from "./errors"; import { invokeWithLayer } from "./invoke"; +import { graphqlPresets } from "./presets"; import { graphqlSchema, makeDefaultGraphqlStore, @@ -120,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), @@ -137,13 +138,67 @@ 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, +}); +const StaticGetSourceOutputSchema = Schema.Struct({ + source: Schema.NullOr(Schema.Unknown), +}); 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), +); +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({ + 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 @@ -890,6 +945,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), @@ -922,16 +978,76 @@ 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 `graphql.configureSource`, `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. 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", }, inputSchema: StaticAddSourceInputStandardSchema, outputSchema: StaticAddSourceOutputStandardSchema, - execute: (input) => Effect.map(self.addSource(input), 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)), + }), + ); + }, + }), + 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/graphql/src/sdk/presets.ts b/packages/plugins/graphql/src/sdk/presets.ts index f53f474bc..b169573cf 100644 --- a/packages/plugins/graphql/src/sdk/presets.ts +++ b/packages/plugins/graphql/src/sdk/presets.ts @@ -3,6 +3,7 @@ export interface GraphqlPreset { readonly name: string; readonly summary: string; readonly url: string; + readonly endpoint: string; readonly icon?: string; readonly featured?: boolean; } @@ -13,6 +14,7 @@ export const graphqlPresets: readonly GraphqlPreset[] = [ name: "GitHub GraphQL", summary: "Repos, issues, PRs, and users via GitHub's GraphQL API.", url: "https://api.github.com/graphql", + endpoint: "https://api.github.com/graphql", icon: "https://github.com/favicon.ico", featured: true, }, @@ -21,6 +23,7 @@ export const graphqlPresets: readonly GraphqlPreset[] = [ name: "GitLab", summary: "Projects, merge requests, pipelines, and users.", url: "https://gitlab.com/api/graphql", + endpoint: "https://gitlab.com/api/graphql", icon: "https://gitlab.com/favicon.ico", featured: true, }, @@ -29,6 +32,7 @@ export const graphqlPresets: readonly GraphqlPreset[] = [ name: "Linear", summary: "Issues, projects, teams, and cycles.", url: "https://api.linear.app/graphql", + endpoint: "https://api.linear.app/graphql", icon: "https://linear.app/favicon.ico", featured: true, }, @@ -37,6 +41,7 @@ export const graphqlPresets: readonly GraphqlPreset[] = [ name: "Monday.com", summary: "Boards, items, columns, and workspace automation.", url: "https://api.monday.com/v2", + endpoint: "https://api.monday.com/v2", icon: "https://monday.com/favicon.ico", }, { @@ -44,6 +49,7 @@ export const graphqlPresets: readonly GraphqlPreset[] = [ name: "AniList", summary: "Anime and manga database — no auth required.", url: "https://graphql.anilist.co", + endpoint: "https://graphql.anilist.co", icon: "https://anilist.co/img/icons/favicon-32x32.png", }, ]; diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 03720c674..fe41f06dd 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -215,24 +215,28 @@ 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((yield* executor.tools.list()).map((tool) => tool.id)).toContain( + "executor.mcp.configureSource", + ); 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 +272,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", + 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", + source: { id: "broken_static_source", scope: "test-scope" }, + 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..ab6df0be0 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -21,9 +21,12 @@ import { ScopeId, SourceDetectionResult, ToolResult, + defaultSourceInstallScopeId, definePlugin, + tool, resolveSecretBackedMap as resolveSharedSecretBackedMap, type PluginCtx, + type StaticToolSchema, type StorageFailure, StorageError, type ToolAnnotations, @@ -46,19 +49,22 @@ 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, 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 +163,122 @@ 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({ + 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({ + 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, + source: Schema.Struct({ + id: Schema.String, + scope: 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 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, + 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); +const McpStaticConfigureSourceInputStandardSchema = schemaToStaticToolSchema( + McpStaticConfigureSourceInputSchema, +); +const McpStaticConfigureSourceOutputStandardSchema = schemaToStaticToolSchema( + McpStaticConfigureSourceOutputSchema, +); + +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 @@ -1001,6 +1118,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 — @@ -1579,6 +1707,175 @@ 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` 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) => + 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 `mcp.configureSource`, `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. 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", + }, + inputSchema: McpAddSourceInputStandardSchema, + outputSchema: McpAddSourceOutputStandardSchema, + execute: (rawInput, { ctx }) => { + 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: sourceScope, + } as McpSourceConfig; + 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: { + readonly message: string; + readonly stage?: string; + }) => + Effect.succeed( + ToolResult.ok({ + namespace: + normalizedInput.namespace ?? + deriveMcpNamespace({ + 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, + 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, + }), + source: { + id: + normalizedInput.namespace ?? + deriveMcpNamespace({ + name: normalizedInput.name, + endpoint: normalizedInput.endpoint, + }), + scope: sourceScope, + }, + toolCount: 0, + discovery: { + status: "failed" as const, + message, + }, + }), + ), + }), + ); + }, + }), + 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 }); + }), + }), + ], + }, + ], + invokeTool: ({ ctx, toolRow, args, elicit }) => Effect.gen(function* () { const runtime = yield* ensureRuntime(); diff --git a/packages/plugins/mcp/src/sdk/presets.ts b/packages/plugins/mcp/src/sdk/presets.ts index 564633040..a8f75ef91 100644 --- a/packages/plugins/mcp/src/sdk/presets.ts +++ b/packages/plugins/mcp/src/sdk/presets.ts @@ -3,6 +3,7 @@ export interface McpRemotePreset { readonly name: string; readonly summary: string; readonly url: string; + readonly endpoint: string; readonly icon?: string; readonly featured?: boolean; readonly transport?: undefined; @@ -28,6 +29,7 @@ export const mcpPresets: readonly McpPreset[] = [ name: "DeepWiki", summary: "Search and read documentation from any GitHub repo.", url: "https://mcp.deepwiki.com/mcp", + endpoint: "https://mcp.deepwiki.com/mcp", icon: "https://deepwiki.com/favicon.ico", featured: true, }, @@ -36,6 +38,7 @@ export const mcpPresets: readonly McpPreset[] = [ name: "Context7", summary: "Up-to-date docs and code examples for any library.", url: "https://mcp.context7.com/mcp", + endpoint: "https://mcp.context7.com/mcp", icon: "https://context7.com/favicon.ico", featured: true, }, @@ -44,6 +47,7 @@ export const mcpPresets: readonly McpPreset[] = [ name: "Browserbase", summary: "Cloud browser sessions for web scraping and automation.", url: "https://mcp.browserbase.com/mcp", + endpoint: "https://mcp.browserbase.com/mcp", icon: "https://www.browserbase.com/favicon.ico", featured: true, }, @@ -52,6 +56,7 @@ export const mcpPresets: readonly McpPreset[] = [ name: "Firecrawl", summary: "Crawl and scrape websites into structured data.", url: "https://mcp.firecrawl.dev/mcp", + endpoint: "https://mcp.firecrawl.dev/mcp", icon: "https://www.firecrawl.dev/favicon.ico", featured: true, }, @@ -60,6 +65,7 @@ export const mcpPresets: readonly McpPreset[] = [ name: "Neon", summary: "Serverless Postgres — branches, queries, and management.", url: "https://mcp.neon.tech/mcp", + endpoint: "https://mcp.neon.tech/mcp", icon: "https://neon.tech/favicon/favicon.ico", featured: true, }, @@ -68,6 +74,7 @@ export const mcpPresets: readonly McpPreset[] = [ name: "Axiom", summary: "Query, analyze, and monitor your logs and event data.", url: "https://mcp.axiom.co/mcp", + endpoint: "https://mcp.axiom.co/mcp", icon: "https://axiom.co/favicon.ico", featured: true, }, @@ -76,6 +83,7 @@ export const mcpPresets: readonly McpPreset[] = [ name: "Stripe", summary: "Manage payments, subscriptions, and billing via MCP.", url: "https://mcp.stripe.com", + endpoint: "https://mcp.stripe.com", icon: "https://stripe.com/favicon.ico", featured: true, }, @@ -84,6 +92,7 @@ export const mcpPresets: readonly McpPreset[] = [ name: "Linear", summary: "Issues, projects, teams, and cycles via MCP.", url: "https://mcp.linear.app/mcp", + endpoint: "https://mcp.linear.app/mcp", icon: "https://linear.app/favicon.ico", featured: true, }, @@ -92,6 +101,7 @@ export const mcpPresets: readonly McpPreset[] = [ name: "Notion", summary: "Databases, pages, blocks, and search via MCP.", url: "https://mcp.notion.com/mcp", + endpoint: "https://mcp.notion.com/mcp", icon: "https://www.notion.com/front-static/favicon.ico", featured: true, }, @@ -100,6 +110,7 @@ export const mcpPresets: readonly McpPreset[] = [ name: "Sentry", summary: "Error monitoring, issues, and performance data.", url: "https://mcp.sentry.dev/mcp", + endpoint: "https://mcp.sentry.dev/mcp", icon: "https://sentry-brand.storage.googleapis.com/sentry-glyph-black.png", }, { @@ -107,6 +118,7 @@ export const mcpPresets: readonly McpPreset[] = [ name: "Cloudflare", summary: "Workers, KV, D1, R2, and DNS management via MCP.", url: "https://mcp.cloudflare.com/mcp", + endpoint: "https://mcp.cloudflare.com/mcp", icon: "https://cloudflare.com/favicon.ico", }, { 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/__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/plugins/openapi/src/sdk/plugin.test.ts b/packages/plugins/openapi/src/sdk/plugin.test.ts index 5adffef88..36af87cb7 100644 --- a/packages/plugins/openapi/src/sdk/plugin.test.ts +++ b/packages/plugins/openapi/src/sdk/plugin.test.ts @@ -237,7 +237,9 @@ 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"); + expect(ids).toContain("executor.openapi.configureSource"); }), ); @@ -282,9 +284,30 @@ 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(); + }), + ); + + 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:"); + expect(schema!.outputTypeScript).toContain("title: string | null"); + expect(schema!.outputTypeScript).not.toContain("operations:"); + expect(schema!.outputTypeScript).not.toContain("_tag"); }), ); @@ -299,7 +322,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, @@ -330,20 +353,55 @@ describe("OpenAPI Plugin", () => { const result = unwrapInvocation( yield* executor.tools.invoke( "executor.openapi.addSource", - testApiSourceConfig({ scope: String(orgScope), 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, ); + 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( @@ -353,7 +411,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 }), }, @@ -872,7 +932,12 @@ 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.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 c85ac60f1..2dd322e70 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -4,16 +4,17 @@ import { HttpClient } from "effect/unstable/http"; import { ConnectionId, + CredentialBindingRef, ScopeId, SecretId, SourceDetectionResult, StorageError, ToolResult, + defaultSourceInstallScopeId, definePlugin, tool, resolveSecretBackedMap, type CredentialBindingValue, - type CredentialBindingRef, type PluginCtx, type StorageFailure, type ToolAnnotations, @@ -31,7 +32,8 @@ 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, openapiSchema, @@ -247,6 +249,70 @@ const PreviewSpecInputSchema = Schema.Struct({ ), }); +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, + 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 }), @@ -301,10 +367,16 @@ 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({ - scope: Schema.String, spec: OpenApiSpecInputSchema, name: Schema.String, baseUrl: Schema.String, @@ -322,18 +394,122 @@ 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({ + 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(StaticPreviewSpecOutputSchema), +); const AddSourceInputStandardSchema = Schema.toStandardSchemaV1( Schema.toStandardJSONSchemaV1(AddSourceInputSchema), ); 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), +); +const GetSourceOutputStandardSchema = Schema.toStandardSchemaV1( + Schema.toStandardJSONSchemaV1(GetSourceOutputSchema), +); + +const openApiToolFailure = (code: string, message: string, details?: unknown) => + ToolResult.fail({ + code, + message, + ...(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, + 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, +): string => + String( + ctx.scopes.find((scope) => scope.name === value || String(scope.id) === value)?.id ?? value, + ); // --------------------------------------------------------------------------- // Helpers @@ -1109,6 +1285,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), @@ -1236,20 +1413,95 @@ 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, 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, - execute: (input) => Effect.map(self.previewSpec(input), ToolResult.ok), + outputSchema: PreviewSpecOutputStandardSchema, + execute: (input) => + self.previewSpec(input).pipe( + Effect.map((preview) => ToolResult.ok(staticPreviewOutput(preview))), + 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 `openapi.configureSource`, `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. 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", }, inputSchema: AddSourceInputStandardSchema, outputSchema: AddSourceOutputStandardSchema, - execute: (input) => Effect.map(self.addSpec(input), 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)), + OpenApiExtractionError: ({ message }) => + Effect.succeed(openApiToolFailure("openapi_extraction_failed", message)), + OpenApiOAuthError: ({ message }) => + Effect.succeed(openApiToolFailure("openapi_oauth_failed", message)), + }), + ); + }, + }), + 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 }), + ); + }, }), ], }, 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 73ca5cece..c5c0522e3 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"; @@ -22,6 +22,14 @@ import { DialogClose, } from "../components/dialog"; import { Button } from "../components/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../components/select"; +import { Label } from "../components/label"; import { DropdownMenu, DropdownMenuContent, @@ -39,12 +47,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" }, @@ -62,13 +76,25 @@ const isSecretInUseError = Schema.is(SecretInUseError); // state always starts fresh — no manual reset. // --------------------------------------------------------------------------- +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; description: string; storageOptions: readonly SecretStorageOption[]; - existingSecretIds: readonly string[]; + existingSecrets: readonly { readonly id: string; readonly scopeId: ScopeId }[]; scopeId: ScopeId; + scopeOptions: readonly SecretScopeOption[]; + prefill?: SecretPrefill; }) { return ( @@ -77,8 +103,10 @@ 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)} /> )} @@ -89,17 +117,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.storageOptions[0]?.value ?? "auto"; + 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 ( @@ -111,11 +153,37 @@ function AddSecretDialogContent(props: {
-
- - +
+
+ +
+
+ +
+
+
+
+ +
+
+ + +
-
@@ -232,6 +300,11 @@ export function SecretsPage(props: { addSecretDescription?: string; showProviderInfo?: boolean; storageOptions?: readonly SecretStorageOption[]; + /** Pre-fill values for the add-secret modal and auto-open it. Set by + * the route when the URL carries `?openAdd=1&name=…&secretId=…`, + * which is how the agent-facing `secrets.create` tool hands a user + * off to this page. */ + prefill?: SecretPrefill; }) { const storageOptions = props.storageOptions ?? defaultStorageOptions; const showProviderInfo = props.showProviderInfo ?? true; @@ -239,8 +312,9 @@ export function SecretsPage(props: { props.addSecretDescription ?? "Store a credential or API key. Values are kept in your system keychain when available, with a local encrypted file fallback."; const secretProviderPlugins = useSecretProviderPlugins(); - const [addOpen, setAddOpen] = useState(false); + 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 => { @@ -252,12 +326,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", }); @@ -393,8 +476,10 @@ export function SecretsPage(props: { onOpenChange={setAddOpen} description={addSecretDescription} storageOptions={storageOptions} - existingSecretIds={existingSecretIds} - scopeId={scopeId} + existingSecrets={existingSecretIds} + scopeId={formScopeId} + scopeOptions={scopeOptions} + prefill={props.prefill} />
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" }, 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();