From e5336f8084f7f89f2a3b2c34211f9a44bf24e4ef Mon Sep 17 00:00:00 2001 From: George Kontridze Date: Tue, 6 Oct 2026 19:53:58 -0700 Subject: [PATCH] fix(graphql): send a default User-Agent on tool invocations --- .changeset/graphql-invocation-user-agent.md | 5 ++ e2e/scenarios/graphql-request-headers.test.ts | 77 +++++++++++++++++++ packages/plugins/graphql/src/sdk/invoke.ts | 3 + .../plugins/graphql/src/sdk/plugin.test.ts | 60 +++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 .changeset/graphql-invocation-user-agent.md create mode 100644 e2e/scenarios/graphql-request-headers.test.ts diff --git a/.changeset/graphql-invocation-user-agent.md b/.changeset/graphql-invocation-user-agent.md new file mode 100644 index 0000000000..ef67901ed0 --- /dev/null +++ b/.changeset/graphql-invocation-user-agent.md @@ -0,0 +1,5 @@ +--- +"@executor-js/plugin-graphql": patch +--- + +Set a default User-Agent on GraphQL query and mutation requests, matching introspection while preserving explicitly configured headers. diff --git a/e2e/scenarios/graphql-request-headers.test.ts b/e2e/scenarios/graphql-request-headers.test.ts new file mode 100644 index 0000000000..b3acf8dd5e --- /dev/null +++ b/e2e/scenarios/graphql-request-headers.test.ts @@ -0,0 +1,77 @@ +import { randomUUID } from "node:crypto"; +import { expect } from "@effect/vitest"; +import { Effect } from "effect"; +import { composePluginApi } from "@executor-js/api/server"; +import { graphqlHttpPlugin } from "@executor-js/plugin-graphql/api"; +import { + makeGreetingGraphqlSchema, + serveGraphqlTestServer, +} from "@executor-js/plugin-graphql/testing"; +import { AuthTemplateSlug, ConnectionName, IntegrationSlug } from "@executor-js/sdk/shared"; +import { scenario } from "../src/scenario"; +import { Api, Target } from "../src/services"; + +const api = composePluginApi([graphqlHttpPlugin()] as const); + +scenario( + "GraphQL ยท sandbox calls preserve default and custom request headers", + {}, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const { client: makeClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeClient(api, identity); + const upstream = yield* serveGraphqlTestServer({ schema: makeGreetingGraphqlSchema() }); + + for (const custom of [false, true]) { + const slug = IntegrationSlug.make(`graphql_headers_${randomUUID().replaceAll("-", "")}`); + yield* Effect.gen(function* () { + yield* client.graphql.addIntegration({ + payload: { + endpoint: upstream.endpoint, + slug, + headers: { + "X-API-Version": "1", + ...(custom ? { "uSeR-aGeNt": "example-client/2.0" } : {}), + }, + }, + }); + yield* client.connections.create({ + payload: { + owner: "org", + integration: slug, + name: ConnectionName.make("main"), + template: AuthTemplateSlug.make("none"), + value: "", + }, + }); + const executed = yield* client.executions.execute({ + payload: { + code: `return await tools.${slug}.org.main.query.hello({name: "Ada"});`, + autoApprove: true, + }, + }); + expect(executed.status).toBe("completed"); + expect(JSON.parse(executed.text)).toEqual({ ok: true, data: { hello: "Hello Ada" } }); + const requests = yield* upstream.requests; + expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe( + true, + ); + expect(requests.some((request) => request.payload.query?.startsWith("query Hello"))).toBe( + true, + ); + for (const request of requests) { + expect(request.headers["user-agent"]).toBe( + custom ? "example-client/2.0" : "executor-graphql", + ); + expect(request.headers["x-api-version"]).toBe("1"); + } + }).pipe( + Effect.ensuring(client.integrations.remove({ params: { slug } }).pipe(Effect.ignore)), + ); + yield* upstream.clearRequests; + } + }), + ), +); diff --git a/packages/plugins/graphql/src/sdk/invoke.ts b/packages/plugins/graphql/src/sdk/invoke.ts index bb2ee78be7..430a7f6146 100644 --- a/packages/plugins/graphql/src/sdk/invoke.ts +++ b/packages/plugins/graphql/src/sdk/invoke.ts @@ -107,6 +107,9 @@ export const invoke = Effect.fn("GraphQL.invoke")(function* ( let request = HttpClientRequest.post(requestEndpoint).pipe( HttpClientRequest.setHeader("Content-Type", "application/json"), + // Match introspection even on runtimes that do not supply a User-Agent. + // Resolved headers below can override the default, case-insensitively. + HttpClientRequest.setHeader("User-Agent", "executor-graphql"), HttpClientRequest.bodyJsonUnsafe({ query: operationString, variables: Object.keys(variables).length > 0 ? variables : undefined, diff --git a/packages/plugins/graphql/src/sdk/plugin.test.ts b/packages/plugins/graphql/src/sdk/plugin.test.ts index 45290fbfec..4b6f140019 100644 --- a/packages/plugins/graphql/src/sdk/plugin.test.ts +++ b/packages/plugins/graphql/src/sdk/plugin.test.ts @@ -483,6 +483,66 @@ describe("graphqlPlugin real protocol server", () => { }), ); + for (const headerName of [undefined, "User-Agent", "user-agent", "uSeR-aGeNt"]) { + it.effect( + `preserves request headers across introspection and invocation (${headerName ?? "default"})`, + () => + Effect.gen(function* () { + const server = yield* serveGreetingServer; + const executor = yield* makeExecutor(); + const userAgent = headerName ? "example-client/2.0" : "executor-graphql"; + yield* executor.graphql.addIntegration({ + endpoint: server.endpoint, + slug: "request_headers", + headers: { + Accept: "application/json", + "X-API-Version": "1", + ...(headerName ? { [headerName]: userAgent } : {}), + }, + authenticationTemplate: [ + { + slug: "header", + kind: "apikey", + placements: [{ carrier: "header", name: "Authorization" }], + }, + ], + }); + yield* createOrgConnection(executor, { + integration: "request_headers", + name: "main", + template: "header", + value: "test-credential", + }); + const query = yield* executor.execute( + toolAddr("request_headers", "main", "query.hello"), + { name: "Ada" }, + ); + const mutation = yield* executor.execute( + toolAddr("request_headers", "main", "mutation.setGreeting"), + { message: "hi" }, + ); + expect(query).toEqual({ ok: true, data: { hello: "Hello Ada" } }); + expect(mutation.ok).toBe(true); + const requests = yield* server.requests; + expect(requests.some((request) => request.payload.query?.includes("__schema"))).toBe( + true, + ); + expect(requests.some((request) => request.payload.query?.startsWith("query Hello"))).toBe( + true, + ); + expect( + requests.some((request) => request.payload.query?.startsWith("mutation SetGreeting")), + ).toBe(true); + for (const request of requests) { + expect(request.headers["user-agent"]).toBe(userAgent); + expect(request.headers.accept).toBe("application/json"); + expect(request.headers["x-api-version"]).toBe("1"); + expect(request.headers.authorization).toBe("test-credential"); + } + }), + ); + } + it.effect("sends named operations derived from the field name", () => Effect.gen(function* () { const server = yield* serveGreetingServer;