Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/quiet-graphs-report.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"executor": patch
---

**Fix: GraphQL connections now reject credentials when schema introspection fails and show actionable tool sync diagnostics**
134 changes: 134 additions & 0 deletions e2e/scenarios/graphql-introspection-health.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
import { randomBytes } from "node:crypto";

import { expect } from "@effect/vitest";
import { Effect } from "effect";
import { composePluginApi } from "@executor-js/api/server";
import { connectEmulator } from "@executor-js/emulate";
import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api";
import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared";
import { variable } from "@executor-js/sdk/http-auth";

import { createEmulatorInstance } from "../src/emulator-instance";
import { scenario } from "../src/scenario";
import { Api, Browser, Target } from "../src/services";

const api = composePluginApi([graphqlHttpPlugin()] as const);
const unique = (prefix: string): string => `${prefix}_${randomBytes(4).toString("hex")}`;

scenario(
"GraphQL · failed introspection blocks connection creation with an actionable error",
{},
Effect.gen(function* () {
const target = yield* Target;
const browser = yield* Browser;
const { client: makeApiClient } = yield* Api;
const identity = yield* target.newIdentity();
const client = yield* makeApiClient(api, identity);
const slug = unique("graphql_health");
const emulatorBaseUrl = yield* createEmulatorInstance("github", "graphql-health");
const emulator = yield* Effect.promise(() =>
connectEmulator({ baseUrl: emulatorBaseUrl, service: "github" }),
);

yield* Effect.promise(() =>
emulator.faults.arm({
match: { method: "POST", pathPattern: "/graphql" },
response: { status: 401, body: { message: "Bad credentials" } },
times: 10,
}),
);

yield* client.graphql.addIntegration({
payload: {
endpoint: `${emulatorBaseUrl}/graphql`,
slug,
name: "GraphQL health",
authenticationTemplate: [
{
slug: "header",
type: "apiKey",
headers: { Authorization: [variable("token")] },
},
],
},
});

yield* Effect.gen(function* () {
yield* browser.session(identity, async ({ page, step }) => {
await step("Open the connection flow", async () => {
await page.goto(`/integrations/${slug}?addAccount=1&owner=org&template=header`, {
waitUntil: "networkidle",
});
await page.getByRole("heading", { name: /Add connection · GraphQL health/ }).waitFor();
});

await step("Submit a credential rejected during schema introspection", async () => {
const dialog = page.getByRole("dialog", {
name: /Add connection · GraphQL health/,
});
await dialog.getByRole("textbox", { name: "Authorization" }).fill("invalid-token");
await dialog.getByRole("button", { name: "Continue" }).click();

const alert = dialog.getByRole("alert");
await alert.waitFor();
const message = await alert.textContent();
expect(message).toContain("The endpoint rejected the credential with HTTP 401.");
expect(message).toContain("Check the credential and selected authentication method.");
await dialog.getByText("Step 1 of 2").waitFor();
expect(
await page.getByText("No connections yet").count(),
"the rejected credential is not saved",
).toBe(1);
});
});

// The low-level API can still import an existing credential reference
// without the browser's preflight. This models connections created before
// the fix and proves their failed tool sync is no longer a silent zero.
yield* client.connections.create({
payload: {
owner: "org",
name: ConnectionName.make("legacy"),
integration: IntegrationSlug.make(slug),
template: AuthTemplateSlug.make("header"),
value: "invalid-token",
},
});

yield* browser.session(identity, async ({ page, step }) => {
await step("A failed existing connection explains the empty tool catalogue", async () => {
await page.goto(`/integrations/${slug}?tab=tools`, { waitUntil: "networkidle" });
await page.getByText("Connection rejected", { exact: true }).first().waitFor();
await page
.getByText("The endpoint rejected the credential with HTTP 401.", {
exact: false,
})
.waitFor();
await page.getByRole("button", { name: "Check and sync tools" }).waitFor();
});

await step("The account row carries the same actionable health verdict", async () => {
await page.getByRole("tab", { name: "Accounts" }).click();
await page.getByText("Expired", { exact: true }).waitFor();
await page
.getByText("Check the credential and selected authentication method.", {
exact: false,
})
.waitFor();
});
});
}).pipe(
Effect.ensuring(
Effect.all(
[
client.integrations
.remove({ params: { slug: IntegrationSlug.make(slug) } })
.pipe(Effect.ignore),
Effect.promise(() => emulator.faults.clear()).pipe(Effect.ignore),
],
{ concurrency: "unbounded" },
),
),
);
}),
);
22 changes: 17 additions & 5 deletions packages/core/sdk/src/core-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
type Owner,
} from "./ids";
import { definePlugin, tool, type StaticToolSchema } from "./plugin";
import { HealthCheckResult } from "./health-check";
import { ToolPolicyActionSchema } from "./policies";
import type { Tool } from "./tool";

Expand Down Expand Up @@ -76,6 +77,7 @@ const ConnectionOutput = Schema.Struct({
oauthClient: Schema.NullOr(Schema.String),
oauthClientOwner: Schema.NullOr(OwnerSchema),
oauthScope: Schema.NullOr(Schema.String),
lastHealth: Schema.NullOr(HealthCheckResult),
});

const ConnectionsListInput = Schema.Struct({
Expand All @@ -102,6 +104,7 @@ const ConnectionListItem = Schema.Struct({
oauthClientOwner: Schema.NullOr(OwnerSchema),
oauthScopeCount: Schema.NullOr(Schema.Number),
oauthScope: Schema.optional(Schema.NullOr(Schema.String)),
lastHealth: Schema.NullOr(HealthCheckResult),
});
const ConnectionsListOutput = Schema.Struct({
connections: Schema.Array(ConnectionListItem),
Expand Down Expand Up @@ -171,6 +174,7 @@ const ToolOutput = Schema.Struct({
});
const ConnectionsRefreshOutput = Schema.Struct({
tools: Schema.Array(ToolOutput),
lastHealth: Schema.NullOr(HealthCheckResult),
});

const RemovedOutput = Schema.Struct({ removed: Schema.Boolean });
Expand Down Expand Up @@ -373,6 +377,7 @@ const connectionToOutput = (connection: Connection) => ({
oauthClient: connection.oauthClient == null ? null : String(connection.oauthClient),
oauthClientOwner: connection.oauthClientOwner ?? null,
oauthScope: connection.oauthScope ?? null,
lastHealth: connection.lastHealth ?? null,
});

/** Number of space-separated grants in an `oauthScope` string, or null when
Expand All @@ -396,6 +401,7 @@ const connectionToListItem = (connection: Connection, verbose: boolean) => ({
oauthClient: connection.oauthClient == null ? null : String(connection.oauthClient),
oauthClientOwner: connection.oauthClientOwner ?? null,
oauthScopeCount: oauthScopeCount(connection.oauthScope),
lastHealth: connection.lastHealth ?? null,
...(verbose ? { oauthScope: connection.oauthScope ?? null } : {}),
});

Expand Down Expand Up @@ -556,7 +562,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = {
tool({
name: "connections.list",
description:
"List saved connections (the credential for one integration). Never returns the credential value. Optionally filter by integration or owner. OAuth scopes are summarized as `oauthScopeCount` by default; pass `verbose: true` to include the full `oauthScope` grant string per connection.",
"List saved connections and their last health verdict. Never returns credential values. Optionally filter by integration or owner. OAuth scopes are summarized as `oauthScopeCount` by default; pass `verbose: true` to include the full `oauthScope` grant string per connection.",
inputSchema: ConnectionsListInputStd,
outputSchema: ConnectionsListOutputStd,
execute: (input: typeof ConnectionsListInput.Type, { ctx }) =>
Expand Down Expand Up @@ -627,7 +633,7 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = {
tool({
name: "connections.refresh",
description:
"Re-run an integration's tool production for a saved connection, replacing that connection's persisted tools.",
"Re-run an integration's tool production for a saved connection. Returns the tools and the resulting health verdict so an empty catalog is distinguishable from a failed sync.",
inputSchema: ConnectionRefInputStd,
outputSchema: ConnectionsRefreshOutputStd,
// Refresh replaces a connection's persisted tool set; for a mutable
Expand All @@ -636,9 +642,15 @@ export const coreToolsPlugin = definePlugin((options: CoreToolsPluginOptions = {
// `sources.refresh`.
annotations: { requiresApproval: true },
execute: (input: typeof ConnectionRefInput.Type, { ctx }) =>
Effect.map(ctx.connections.refresh(connectionRefFromInput(input)), (tools) => ({
tools: tools.map(toolToOutput),
})),
Effect.gen(function* () {
const ref = connectionRefFromInput(input);
const tools = yield* ctx.connections.refresh(ref);
const connection = yield* ctx.connections.get(ref);
return {
tools: tools.map(toolToOutput),
lastHealth: connection?.lastHealth ?? null,
};
}),
}),
// removed: tools.list — the cross-connection tool catalog is an
// executor-surface read, not exposed on PluginCtx.
Expand Down
74 changes: 73 additions & 1 deletion packages/core/sdk/src/executor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
import { definePlugin } from "./plugin";
import type { CredentialProvider } from "./provider";
import { IntegrationDetectionResult } from "./types";
import { makeTestExecutor } from "./testing";
import { makeTestExecutor, memoryCredentialsPlugin } from "./testing";
import { serveOAuthTestServer } from "./testing/oauth-test-server";

// removed: v1 secret browser-handoff, source.configure, case-insensitive tool-id
Expand Down Expand Up @@ -112,6 +112,25 @@ const demoPlugin = definePlugin(() => ({
}),
}))();

const diagnosticsPlugin = definePlugin(() => ({
id: "diagnostics" as const,
storage: () => ({}),
resolveTools: () =>
Effect.succeed({
tools: [],
incomplete: true,
incompleteReason: "Schema introspection was rejected",
}),
extension: (ctx) => ({
seed: () =>
ctx.core.integrations.register({
slug: IntegrationSlug.make("diagnostics"),
description: "Diagnostics",
config: {},
}),
}),
}))();

const detector = (id: string, confidence: IntegrationDetectionResult["confidence"]) =>
definePlugin(() => ({
id,
Expand Down Expand Up @@ -269,6 +288,59 @@ describe("createExecutor", () => {
}),
);

it.effect("surfaces failed tool sync diagnostics through connection tools", () =>
Effect.gen(function* () {
const executor = yield* makeTestExecutor({
plugins: [memoryCredentialsPlugin(), diagnosticsPlugin] as const,
coreTools: {},
});
yield* executor.diagnostics.seed();

yield* executor.execute(
ToolAddress.make("executor.coreTools.connections.create"),
{
owner: "org",
name: "main",
integration: "diagnostics",
template: "none",
},
{ onElicitation: "accept-all" },
);

const listed = yield* executor.execute(
ToolAddress.make("executor.coreTools.connections.list"),
{ integration: "diagnostics" },
);
expect(listed).toMatchObject({
connections: [
{
lastHealth: {
status: "degraded",
detail: "Tool sync failing: Schema introspection was rejected",
},
},
],
});

const refreshed = yield* executor.execute(
ToolAddress.make("executor.coreTools.connections.refresh"),
{
owner: "org",
name: "main",
integration: "diagnostics",
},
{ onElicitation: "accept-all" },
);
expect(refreshed).toMatchObject({
tools: [],
lastHealth: {
status: "degraded",
detail: "Tool sync failing: Schema introspection was rejected",
},
});
}),
);

it.effect("hands pasted credential entry to the web UI", () =>
Effect.gen(function* () {
const executor = yield* makeTestExecutor({
Expand Down
12 changes: 12 additions & 0 deletions packages/plugins/graphql/src/sdk/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,18 @@ export class GraphqlIntrospectionError extends Schema.TaggedErrorClass<GraphqlIn
"GraphqlIntrospectionError",
{
message: Schema.String,
status: Schema.optional(Schema.Number),
reason: Schema.optional(
Schema.Literals([
"network",
"http",
"invalid-json",
"invalid-shape",
"missing-schema",
"graphql-errors",
]),
),
upstreamMessage: Schema.optional(Schema.String),
},
) {}

Expand Down
15 changes: 14 additions & 1 deletion packages/plugins/graphql/src/sdk/introspect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,7 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* (
() =>
new GraphqlIntrospectionError({
message: "Failed to reach GraphQL endpoint",
reason: "network",
}),
),
);
Expand All @@ -285,6 +286,9 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* (
message: upstreamMessage
? `Introspection failed with status ${response.status}: ${upstreamMessage}`
: `Introspection failed with status ${response.status}`,
status: response.status,
reason: "http",
...(upstreamMessage ? { upstreamMessage } : {}),
});
}

Expand All @@ -294,6 +298,8 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* (
() =>
new GraphqlIntrospectionError({
message: `Failed to parse introspection response as JSON`,
status: response.status,
reason: "invalid-json",
}),
),
);
Expand All @@ -303,22 +309,29 @@ export const introspect = Effect.fn("GraphQL.introspect")(function* (
() =>
new GraphqlIntrospectionError({
message: "Introspection response has an invalid shape",
status: response.status,
reason: "invalid-shape",
}),
),
);

if (json.errors && Array.isArray(json.errors) && json.errors.length > 0) {
const upstreamMessage = firstUpstreamErrorMessage(json);
const upstreamMessage = upstreamTextMessage(firstUpstreamErrorMessage(json) ?? "");
return yield* new GraphqlIntrospectionError({
message: upstreamMessage
? `Introspection returned ${json.errors.length} error(s): ${upstreamMessage}`
: `Introspection returned ${json.errors.length} error(s)`,
status: response.status,
reason: "graphql-errors",
...(upstreamMessage ? { upstreamMessage } : {}),
});
}

if (!json.data?.__schema) {
return yield* new GraphqlIntrospectionError({
message: "Introspection response missing __schema",
status: response.status,
reason: "missing-schema",
});
}

Expand Down
Loading