From 8fa321258e3cbb6d98e180452188681c499bb40b Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 8 Jun 2026 19:58:21 -0700 Subject: [PATCH 1/2] Fix v1.5 auth-failure recovery guidance and guard valueless connections Rework the auth-tool-failure taxonomy for the v1.5 connection model and close the gap that let a credential-less connection be created. - Drop the dead `credential_binding_missing` code (thrown nowhere) and rename the live codes to a connection-centric vocabulary (`connection_value_missing`, `connection_rejected`, `oauth_refresh_failed`). - Repoint the recovery block at tools/flows that exist: the old block named a removed core tool and a redirected page. It now references `connections.createHandoff`, `oauth.start`, and `connections.list`, and describes the connection model instead of the retired secret/source-binding one. These strings are read by the agent resolving the failure. - Normalize `credential.kind` to `secret | oauth | upstream` across plugins. - Strip orphaned secrets-handoff query plumbing from the self-host secrets routes (they referenced the removed tool) and a stale comment. - Reject creating a non-OAuth connection with no bound credential input (empty values/inputs or a blank pasted secret), and stop producing tools for a connection whose item_ids is empty. Such a connection can never resolve a value and previously failed every invocation at the tool boundary instead of being rejected up front. --- .../src/auth/auth-tool-failures.node.test.ts | 4 +- apps/host-cloudflare/web/routes/secrets.tsx | 25 ++------ apps/host-selfhost/web/routes/secrets.tsx | 23 ++------ apps/local/src/auth-tool-failures.test.ts | 10 ++-- .../core/api/src/server/scoped-executor.ts | 3 +- packages/core/execution/src/tool-invoker.ts | 2 +- packages/core/sdk/src/auth-tool-failure.ts | 24 ++++---- packages/core/sdk/src/connections.test.ts | 59 +++++++++++++++++++ packages/core/sdk/src/executor.ts | 42 +++++++++++++ .../graphql-greenfield/src/sdk/errors.ts | 2 +- .../graphql-greenfield/src/sdk/plugin.test.ts | 2 +- .../graphql-greenfield/src/sdk/plugin.ts | 4 +- packages/plugins/graphql/src/sdk/errors.ts | 2 +- .../plugins/graphql/src/sdk/plugin.test.ts | 2 +- packages/plugins/graphql/src/sdk/plugin.ts | 4 +- .../http-source/src/sdk/plugin.test.ts | 2 +- .../plugins/http-source/src/sdk/plugin.ts | 2 +- packages/plugins/mcp/src/sdk/plugin.ts | 2 +- packages/plugins/openapi/src/sdk/errors.ts | 2 +- packages/plugins/openapi/src/sdk/plugin.ts | 6 +- .../openapi/src/sdk/upstream-failures.test.ts | 4 +- 21 files changed, 151 insertions(+), 75 deletions(-) diff --git a/apps/cloud/src/auth/auth-tool-failures.node.test.ts b/apps/cloud/src/auth/auth-tool-failures.node.test.ts index d6b5260c71..a905f89f34 100644 --- a/apps/cloud/src/auth/auth-tool-failures.node.test.ts +++ b/apps/cloud/src/auth/auth-tool-failures.node.test.ts @@ -10,7 +10,7 @@ // v2: a connection IS the credential. `addSpec` registers the integration with // an apiKey auth template; a connection is then created whose value cannot // resolve (a `from` reference to a WorkOS Vault item that was never stored). -// Invoking one of that connection's tools surfaces `credential_secret_missing` +// Invoking one of that connection's tools surfaces `connection_value_missing` // to the model instead of an opaque internal tool error. // --------------------------------------------------------------------------- @@ -55,7 +55,7 @@ const expectModelVisibleAuthFailure = (execution: ExecuteResult) => { result: { ok: false, error: { - code: "credential_secret_missing", + code: "connection_value_missing", details: { category: "authentication", }, diff --git a/apps/host-cloudflare/web/routes/secrets.tsx b/apps/host-cloudflare/web/routes/secrets.tsx index cdf46a2219..10bd4a10bb 100644 --- a/apps/host-cloudflare/web/routes/secrets.tsx +++ b/apps/host-cloudflare/web/routes/secrets.tsx @@ -1,25 +1,10 @@ -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), - }), -); - +// The Providers/Secrets page lets self-host users inspect their credential +// backends. Credential entry happens through the per-integration Add Account +// flow (`connections.createHandoff` → `/integrations/{slug}?addAccount=1`), +// not here, so this route takes no search params. export const Route = createFileRoute("/secrets")({ - validateSearch: SearchParams, - component: () => { - const { name, secretId, provider, scope } = Route.useSearch(); - const hasPrefill = name != null || secretId != null; - return ; - }, + component: () => , }); diff --git a/apps/host-selfhost/web/routes/secrets.tsx b/apps/host-selfhost/web/routes/secrets.tsx index 190789172f..10bd4a10bb 100644 --- a/apps/host-selfhost/web/routes/secrets.tsx +++ b/apps/host-selfhost/web/routes/secrets.tsx @@ -1,23 +1,10 @@ -import { Schema } from "effect"; import { createFileRoute } from "@tanstack/react-router"; import { SecretsPage } from "@executor-js/react/pages/secrets"; -// Query params from the agent-facing `secrets.create` static tool: it builds a -// URL like `/secrets?name=…&scope=…&secretId=…`; open the add modal pre-filled. -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), - }), -); - +// The Providers/Secrets page lets self-host users inspect their credential +// backends. Credential entry happens through the per-integration Add Account +// flow (`connections.createHandoff` → `/integrations/{slug}?addAccount=1`), +// not here, so this route takes no search params. export const Route = createFileRoute("/secrets")({ - validateSearch: SearchParams, - component: () => { - const { name, secretId, provider, scope } = Route.useSearch(); - const hasPrefill = name != null || secretId != null; - return ; - }, + component: () => , }); diff --git a/apps/local/src/auth-tool-failures.test.ts b/apps/local/src/auth-tool-failures.test.ts index 04f7ae27b9..59ae264f0b 100644 --- a/apps/local/src/auth-tool-failures.test.ts +++ b/apps/local/src/auth-tool-failures.test.ts @@ -14,7 +14,7 @@ // v2: a connection IS the credential. addSpec registers the integration with an // apiKey auth template; a connection is then created whose value cannot resolve // (a `from` reference to a missing provider item). Invoking one of that -// connection's tools surfaces `credential_secret_missing` to the model. +// connection's tools surfaces `connection_value_missing` to the model. // --------------------------------------------------------------------------- import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest"; @@ -142,7 +142,7 @@ const startHarness = async (tmpDir: string): Promise => { )) as typeof globalThis.fetch, // Create an org connection whose value cannot resolve: a `from` reference // to a memory-provider item that was never stored resolves to `null`, so - // tool invocation surfaces `credential_secret_missing`. + // tool invocation surfaces `connection_value_missing`. addConnection: (input) => Effect.runPromise( executor.connections @@ -196,12 +196,12 @@ const expectModelVisibleAuthFailure = (execution: ExecuteResult) => { result: { ok: false, error: { - code: "credential_secret_missing", + code: "connection_value_missing", details: { category: "authentication", recovery: { - createSecretTool: "executor.coreTools.secrets.create", - secretsUrl: "https://executor.sh/secrets", + createConnectionTool: "executor.coreTools.connections.createHandoff", + listConnectionsTool: "executor.coreTools.connections.list", }, }, }, diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 018609ffba..25fd5558db 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -58,7 +58,8 @@ export interface HostConfigShape { readonly allowLocalNetwork: boolean; /** * Base URL of the executor's web UI. Threaded into `coreTools.webBaseUrl` so - * `secrets.create` can point the user at `${webBaseUrl}/secrets?...`. + * `connections.createHandoff` can point the user at + * `${webBaseUrl}/integrations/{slug}?addAccount=1`. * * Optional: when a host can't know its public URL at boot (a Worker has no * static URL var), leave it unset and `makeScopedExecutor` falls back to the diff --git a/packages/core/execution/src/tool-invoker.ts b/packages/core/execution/src/tool-invoker.ts index 46bce0ca08..cf6eef7216 100644 --- a/packages/core/execution/src/tool-invoker.ts +++ b/packages/core/execution/src/tool-invoker.ts @@ -138,7 +138,7 @@ const credentialResolutionToolFailure = (input: { readonly reauthRequired?: boolean; }) => authToolFailure({ - code: input.reauthRequired === true ? "oauth_reauth_required" : "oauth_connection_failed", + code: input.reauthRequired === true ? "oauth_reauth_required" : "oauth_refresh_failed", message: input.reauthRequired === true ? `OAuth connection "${input.label}" requires reauthorization: ${input.message}` diff --git a/packages/core/sdk/src/auth-tool-failure.ts b/packages/core/sdk/src/auth-tool-failure.ts index b43fad8a64..cef04006bb 100644 --- a/packages/core/sdk/src/auth-tool-failure.ts +++ b/packages/core/sdk/src/auth-tool-failure.ts @@ -1,11 +1,10 @@ import { ToolResult, type ToolError } from "./tool-result"; export type AuthToolFailureCode = - | "credential_binding_missing" - | "credential_secret_missing" - | "credential_rejected" + | "connection_value_missing" + | "connection_rejected" | "oauth_connection_missing" - | "oauth_connection_failed" + | "oauth_refresh_failed" | "oauth_reauth_required"; export type AuthToolFailureInput = { @@ -16,7 +15,7 @@ export type AuthToolFailureInput = { readonly scope?: string; }; readonly credential?: { - readonly kind: "secret" | "connection" | "oauth" | "upstream"; + readonly kind: "secret" | "oauth" | "upstream"; readonly label?: string; readonly slotKey?: string; readonly secretId?: string; @@ -32,16 +31,21 @@ export type AuthToolFailureInput = { }; }; +// In v1.5 a connection IS the credential: there is no standalone secret to +// "bind" to a source afterward. Manually-entered credentials are created via +// the connection handoff (the user enters the value in the web UI, which +// creates the bound connection in one step); OAuth credentials are minted by +// the OAuth start flow. These strings are read by the agent resolving the +// failure, so they must name tools that actually exist on the executor. const authRecovery = (input?: AuthToolFailureInput["recovery"]) => ({ - secretsUrl: "https://executor.sh/secrets", - createSecretTool: "executor.coreTools.secrets.create", + createConnectionTool: "executor.coreTools.connections.createHandoff", startOAuthTool: "executor.coreTools.oauth.start", listConnectionsTool: "executor.coreTools.connections.list", ...(input?.configureSourceTool ? { configureSourceTool: input.configureSourceTool } : {}), - secretInstructions: - "For API keys, tokens, and other manually entered credentials, call createSecretTool and give the returned browser URL to the user before configuring the source binding.", + connectionInstructions: + "For API keys and tokens, call createConnectionTool for the integration to get a browser URL; the user enters the credential there, which creates the bound connection. Do not ask the user to paste secrets into chat. Then call listConnectionsTool to confirm the connection exists before retrying this tool.", oauthInstructions: - "For OAuth credentials, call startOAuthTool and give the returned authorizationUrl to the user, then bind the completed connection with the source configuration tool.", + "For OAuth credentials, call startOAuthTool and give the returned authorizationUrl to the user. The completed connection binds automatically, then retry the tool.", }); export const authToolFailure = (input: AuthToolFailureInput): ToolResult => { diff --git a/packages/core/sdk/src/connections.test.ts b/packages/core/sdk/src/connections.test.ts index 7cdcfa80e5..84b3146a59 100644 --- a/packages/core/sdk/src/connections.test.ts +++ b/packages/core/sdk/src/connections.test.ts @@ -161,6 +161,65 @@ describe("connections.create", () => { expect(Predicate.isTagged("IntegrationNotFoundError")(result.failure)).toBe(true); }), ); + + // A connection is "born wired": for a non-OAuth template it must reference at + // least one non-empty credential input. An empty binding produces a + // credential with no credential — it persists, produces a full tool catalog, + // and then fails every invocation with `connection_value_missing`. These + // cases must be rejected at create. (An external `from` that resolves to null + // is a DIFFERENT, supported case — covered above — and is not rejected here.) + it.effect("rejects an empty `values` map and persists nothing", () => + Effect.gen(function* () { + const executor = yield* setup(); + const result = yield* Effect.result( + executor.connections.create({ + owner: "org", + name: ConnectionName.make("empty"), + integration: INTEG, + template: TEMPLATE, + values: {}, + }), + ); + expect(Result.isFailure(result)).toBe(true); + // No connection row and — critically — no tools were produced. + expect(yield* executor.connections.list()).toEqual([]); + expect(yield* executor.tools.list()).toEqual([]); + }), + ); + + it.effect("rejects an empty `inputs` map", () => + Effect.gen(function* () { + const executor = yield* setup(); + const result = yield* Effect.result( + executor.connections.create({ + owner: "org", + name: ConnectionName.make("empty2"), + integration: INTEG, + template: TEMPLATE, + inputs: {}, + }), + ); + expect(Result.isFailure(result)).toBe(true); + expect(yield* executor.connections.list()).toEqual([]); + }), + ); + + it.effect("rejects a blank pasted value", () => + Effect.gen(function* () { + const executor = yield* setup(); + const result = yield* Effect.result( + executor.connections.create({ + owner: "org", + name: ConnectionName.make("blank"), + integration: INTEG, + template: TEMPLATE, + value: " ", + }), + ); + expect(Result.isFailure(result)).toBe(true); + expect(yield* executor.connections.list()).toEqual([]); + }), + ); }); describe("connections.list / get", () => { diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 93ca87d7cc..7c4f09b740 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1653,6 +1653,27 @@ export const createExecutor = "value" in i.origin); const external = inputs.filter((i) => "from" in i.origin); + // A connection is born wired: it must reference at least one non-empty + // credential input. An empty binding (no inputs, or a blank pasted + // secret) is a credential with no credential — it would persist, produce + // a full tool catalog, and then fail every invocation with + // `connection_value_missing`. Reject it here. (OAuth connections are + // minted via `mintOAuthConnection`, not this path; an external `from` + // reference is allowed to resolve to null — the item may be written later + // or removed upstream — and is surfaced at invoke time, not here.) + if (inputs.length === 0) { + return yield* new StorageError({ + message: "A connection must supply at least one credential input.", + cause: undefined, + }); + } + const blankPasted = pasted.find((i) => "value" in i.origin && i.origin.value.trim() === ""); + if (blankPasted) { + return yield* new StorageError({ + message: `Credential input "${blankPasted.variable}" cannot be empty.`, + cause: undefined, + }); + } let providerKey: string; const itemIds: Record = {}; if (external.length > 0 && pasted.length > 0) { diff --git a/packages/plugins/graphql-greenfield/src/sdk/errors.ts b/packages/plugins/graphql-greenfield/src/sdk/errors.ts index 4ea2477e00..f495ab8d47 100644 --- a/packages/plugins/graphql-greenfield/src/sdk/errors.ts +++ b/packages/plugins/graphql-greenfield/src/sdk/errors.ts @@ -32,7 +32,7 @@ export class GraphqlAuthRequiredError extends Data.TaggedError("GraphqlAuthRequi readonly owner: "org" | "user"; readonly integration: string; readonly connection: string; - readonly credentialKind: "secret" | "connection" | "oauth" | "upstream"; + readonly credentialKind: "secret" | "oauth" | "upstream"; readonly credentialLabel?: string; readonly status?: number; readonly details?: unknown; diff --git a/packages/plugins/graphql-greenfield/src/sdk/plugin.test.ts b/packages/plugins/graphql-greenfield/src/sdk/plugin.test.ts index b5e80ed778..915e6590b8 100644 --- a/packages/plugins/graphql-greenfield/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql-greenfield/src/sdk/plugin.test.ts @@ -467,7 +467,7 @@ describe("graphqlPlugin invocation", () => { expect(result).toMatchObject({ ok: false, error: { - code: "credential_rejected", + code: "connection_rejected", status: 401, details: { category: "authentication" }, }, diff --git a/packages/plugins/graphql-greenfield/src/sdk/plugin.ts b/packages/plugins/graphql-greenfield/src/sdk/plugin.ts index 52e2a6e261..20b9bd9ffc 100644 --- a/packages/plugins/graphql-greenfield/src/sdk/plugin.ts +++ b/packages/plugins/graphql-greenfield/src/sdk/plugin.ts @@ -618,7 +618,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { owner: credential.owner, integration: String(credential.integration), connection: String(credential.connection), - credentialKind: "connection", + credentialKind: "oauth", credentialLabel: "GraphQL credential", message: `Missing credential value for GraphQL connection ` + @@ -651,7 +651,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { if (result.status < 200 || result.status >= 300) { if (result.status === 401 || result.status === 403) { return authToolFailure({ - code: "credential_rejected", + code: "connection_rejected", status: result.status, message: `Upstream rejected credentials for GraphQL integration ` + diff --git a/packages/plugins/graphql/src/sdk/errors.ts b/packages/plugins/graphql/src/sdk/errors.ts index 5e858e0184..2f0b61bbae 100644 --- a/packages/plugins/graphql/src/sdk/errors.ts +++ b/packages/plugins/graphql/src/sdk/errors.ts @@ -31,7 +31,7 @@ export class GraphqlAuthRequiredError extends Data.TaggedError("GraphqlAuthRequi readonly owner: string; readonly integration: string; readonly connection: string; - readonly credentialKind: "secret" | "connection" | "oauth" | "upstream"; + readonly credentialKind: "secret" | "oauth" | "upstream"; readonly credentialLabel?: string; readonly template?: string; readonly status?: number; diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 59c1fe91a2..5b9a62bde3 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -763,7 +763,7 @@ describe("graphqlPlugin", () => { expect(result).toMatchObject({ ok: false, error: { - code: "credential_secret_missing", + code: "connection_value_missing", details: { category: "authentication" }, }, }); diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 22687b9e94..e2d9ba8e2a 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -944,7 +944,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { code: template.kind === "oauth2" ? "oauth_connection_missing" - : "credential_secret_missing", + : "connection_value_missing", message: template.kind === "oauth2" ? `Missing OAuth connection value for GraphQL integration "${integration}" (connection "${credential.connection}")` @@ -983,7 +983,7 @@ export const graphqlPlugin = definePlugin((options?: GraphqlPluginOptions) => { if (result.status < 200 || result.status >= 300) { if (result.status === 401 || result.status === 403) { return authToolFailure({ - code: "credential_rejected", + code: "connection_rejected", status: result.status, message: `Upstream rejected credentials for GraphQL integration "${integration}" with HTTP ${result.status}. Re-authenticate or update the connection before retrying this tool.`, source: { id: integration, scope: credential.owner }, diff --git a/packages/plugins/http-source/src/sdk/plugin.test.ts b/packages/plugins/http-source/src/sdk/plugin.test.ts index 361b6d5038..79e4a39406 100644 --- a/packages/plugins/http-source/src/sdk/plugin.test.ts +++ b/packages/plugins/http-source/src/sdk/plugin.test.ts @@ -159,7 +159,7 @@ describe("httpSourcePlugin.invokeTool", () => { }; const out = yield* plugin.invokeTool!(input); - expect(failureCode(out)).toBe("credential_secret_missing"); + expect(failureCode(out)).toBe("connection_value_missing"); // No request should have been issued. expect(capture.request).toBeUndefined(); }), diff --git a/packages/plugins/http-source/src/sdk/plugin.ts b/packages/plugins/http-source/src/sdk/plugin.ts index 32992d2ff9..a4bf9b9e80 100644 --- a/packages/plugins/http-source/src/sdk/plugin.ts +++ b/packages/plugins/http-source/src/sdk/plugin.ts @@ -323,7 +323,7 @@ export const httpSourcePlugin = definePlugin(() => ({ }); if (missing.length > 0) { return authToolFailure({ - code: "credential_secret_missing", + code: "connection_value_missing", message: `No credential value resolved for connection "${input.credential.connection}" on integration "${input.credential.integration}".`, credential: { kind: "secret", diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index c20e281f1c..12e349ac0b 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -761,7 +761,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { Effect.catchTag("McpConnectionError", ({ message }) => Effect.succeed( authToolFailure({ - code: "credential_rejected", + code: "connection_rejected", message, source: { id: String(credential.integration) }, credential: { kind: "upstream", label: String(credential.connection) }, diff --git a/packages/plugins/openapi/src/sdk/errors.ts b/packages/plugins/openapi/src/sdk/errors.ts index ba3864be60..a83158e9b5 100644 --- a/packages/plugins/openapi/src/sdk/errors.ts +++ b/packages/plugins/openapi/src/sdk/errors.ts @@ -50,7 +50,7 @@ export class OpenApiAuthRequiredError extends Data.TaggedError("OpenApiAuthRequi readonly owner: "org" | "user"; readonly integration: string; readonly connection: string; - readonly credentialKind: "secret" | "connection" | "oauth" | "upstream"; + readonly credentialKind: "secret" | "oauth" | "upstream"; readonly credentialLabel?: string; readonly status?: number; readonly details?: unknown; diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 33579c04ef..4613c60f65 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -1106,9 +1106,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { if (missing.length > 0) { return openApiAuthToolFailure({ code: - template.type === "oauth" - ? "oauth_connection_missing" - : "credential_secret_missing", + template.type === "oauth" ? "oauth_connection_missing" : "connection_value_missing", message: `Connection "${credential.connection}" for "${integration}" has no resolvable credential value. Re-authenticate or update the connection.`, owner: credential.owner, integration, @@ -1134,7 +1132,7 @@ export const openApiPlugin = definePlugin((options?: OpenApiPluginOptions) => { if (!ok) { if (result.status === 401 || result.status === 403) { return openApiAuthToolFailure({ - code: "credential_rejected", + code: "connection_rejected", status: result.status, message: `Upstream rejected credentials for "${integration}" with HTTP ${result.status}. Re-authenticate or update the connection "${credential.connection}" before retrying this tool.`, owner: credential.owner, diff --git a/packages/plugins/openapi/src/sdk/upstream-failures.test.ts b/packages/plugins/openapi/src/sdk/upstream-failures.test.ts index 42e4b7e427..ba23894239 100644 --- a/packages/plugins/openapi/src/sdk/upstream-failures.test.ts +++ b/packages/plugins/openapi/src/sdk/upstream-failures.test.ts @@ -191,7 +191,7 @@ describe("OpenAPI upstream failure modes", () => { }), ); - it.effect("upstream 401 is classified as credential_rejected", () => + it.effect("upstream 401 is classified as connection_rejected", () => Effect.gen(function* () { const server = yield* startScriptedServer(() => ({ status: 401, @@ -205,7 +205,7 @@ describe("OpenAPI upstream failure modes", () => { expect(result).toMatchObject({ ok: false, error: { - code: "credential_rejected", + code: "connection_rejected", status: 401, message: expect.stringContaining("Upstream rejected credentials"), details: { From ed184e96d138d6143ac2b07728f1be509704a79d Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Mon, 8 Jun 2026 20:08:07 -0700 Subject: [PATCH 2/2] Fix typecheck and scope the connection guard to empty bindings - openapi: narrow the `openApiAuthToolFailure` helper's `credentialKind` param to `secret | oauth | upstream` to match the core `credential.kind` (the stray `connection` member tripped the type check). - Only reject a connection that supplies no credential input at all (empty `values`/`inputs` -> empty item_ids). An empty-STRING value is a valid binding that no-auth integrations (e.g. MCP) rely on and yields a non-empty item_ids, so it is no longer rejected. Replace the blank-value test with one that locks in the empty-string-is-allowed behavior. --- packages/core/sdk/src/connections.test.ts | 35 ++++++++++++---------- packages/core/sdk/src/executor.ts | 24 ++++++--------- packages/plugins/openapi/src/sdk/plugin.ts | 2 +- 3 files changed, 29 insertions(+), 32 deletions(-) diff --git a/packages/core/sdk/src/connections.test.ts b/packages/core/sdk/src/connections.test.ts index 84b3146a59..3dde97afb1 100644 --- a/packages/core/sdk/src/connections.test.ts +++ b/packages/core/sdk/src/connections.test.ts @@ -162,12 +162,14 @@ describe("connections.create", () => { }), ); - // A connection is "born wired": for a non-OAuth template it must reference at - // least one non-empty credential input. An empty binding produces a + // A connection is "born wired": it must reference at least one credential + // input. An empty binding (an empty `values`/`inputs` map) produces a // credential with no credential — it persists, produces a full tool catalog, // and then fails every invocation with `connection_value_missing`. These - // cases must be rejected at create. (An external `from` that resolves to null - // is a DIFFERENT, supported case — covered above — and is not rejected here.) + // cases must be rejected at create. (An empty-STRING value is allowed — no-auth + // integrations bind one deliberately; and an external `from` that resolves to + // null is a supported case — both covered by their own tests — so neither is + // rejected here.) it.effect("rejects an empty `values` map and persists nothing", () => Effect.gen(function* () { const executor = yield* setup(); @@ -204,20 +206,21 @@ describe("connections.create", () => { }), ); - it.effect("rejects a blank pasted value", () => + it.effect("allows an empty-string value (no-auth integrations bind one)", () => Effect.gen(function* () { const executor = yield* setup(); - const result = yield* Effect.result( - executor.connections.create({ - owner: "org", - name: ConnectionName.make("blank"), - integration: INTEG, - template: TEMPLATE, - value: " ", - }), - ); - expect(Result.isFailure(result)).toBe(true); - expect(yield* executor.connections.list()).toEqual([]); + const connection = yield* executor.connections.create({ + owner: "org", + name: ConnectionName.make("noauth"), + integration: INTEG, + template: TEMPLATE, + value: "", + }); + // The binding exists (non-empty item_ids), so tools are produced; the + // empty value itself is the integration's concern, surfaced at invoke. + expect(String(connection.address)).toBe("tools.vercel.org.noauth"); + const tools = yield* executor.tools.list(); + expect(tools.map((t) => String(t.name)).sort()).toEqual(["deploy", "list"]); }), ); }); diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 7c4f09b740..b3acfc9811 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -1786,27 +1786,21 @@ export const createExecutor = "value" in i.origin); const external = inputs.filter((i) => "from" in i.origin); - // A connection is born wired: it must reference at least one non-empty - // credential input. An empty binding (no inputs, or a blank pasted - // secret) is a credential with no credential — it would persist, produce - // a full tool catalog, and then fail every invocation with - // `connection_value_missing`. Reject it here. (OAuth connections are - // minted via `mintOAuthConnection`, not this path; an external `from` - // reference is allowed to resolve to null — the item may be written later - // or removed upstream — and is surfaced at invoke time, not here.) + // A connection is born wired: it must reference at least one credential + // input. An empty binding (no inputs at all — e.g. an empty `values`/ + // `inputs` map) is a credential with no credential: it would persist, + // produce a full tool catalog, and then fail every invocation with + // `connection_value_missing`. Reject it here. (An empty-string value is + // NOT rejected — no-auth integrations like MCP deliberately bind one, and + // it yields a non-empty `item_ids`. OAuth connections are minted via + // `mintOAuthConnection`, not this path; an external `from` reference may + // resolve to null and is surfaced at invoke time, not here.) if (inputs.length === 0) { return yield* new StorageError({ message: "A connection must supply at least one credential input.", cause: undefined, }); } - const blankPasted = pasted.find((i) => "value" in i.origin && i.origin.value.trim() === ""); - if (blankPasted) { - return yield* new StorageError({ - message: `Credential input "${blankPasted.variable}" cannot be empty.`, - cause: undefined, - }); - } let providerKey: string; const itemIds: Record = {}; if (external.length > 0 && pasted.length > 0) { diff --git a/packages/plugins/openapi/src/sdk/plugin.ts b/packages/plugins/openapi/src/sdk/plugin.ts index 4613c60f65..df432fec7a 100644 --- a/packages/plugins/openapi/src/sdk/plugin.ts +++ b/packages/plugins/openapi/src/sdk/plugin.ts @@ -356,7 +356,7 @@ const openApiAuthToolFailure = (failure: { readonly owner: "org" | "user"; readonly integration: string; readonly connection: string; - readonly credentialKind: "secret" | "connection" | "oauth" | "upstream"; + readonly credentialKind: "secret" | "oauth" | "upstream"; readonly credentialLabel?: string; readonly status?: number; readonly details?: unknown;