Skip to content

Commit 1175ccb

Browse files
authored
fix(openapi): send a default User-Agent on upstream tool calls (#1071)
GitHub and some other upstreams reject requests that lack a User-Agent header with HTTP 403 ("Request forbidden by administrative rules"). The invoke path sent none, so these calls failed and were surfaced as credential rejections, sending users to re-check tokens that were fine. Send a default "executor" User-Agent. It is applied before operation header params and resolved auth headers, so a spec- or connection-provided User-Agent still wins.
1 parent 1afdb46 commit 1175ccb

2 files changed

Lines changed: 116 additions & 0 deletions

File tree

packages/plugins/openapi/src/sdk/invoke.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,13 @@ const resolvePath = Effect.fn("OpenApi.resolvePath")(function* (
126126
return resolved;
127127
});
128128

129+
// GitHub (and some other upstreams) reject requests that lack a User-Agent
130+
// header with a 403 ("Request forbidden by administrative rules"), which is
131+
// indistinguishable from a credential rejection downstream. Send a default so
132+
// those calls succeed. It is applied before operation header params and
133+
// resolved auth headers, so a spec- or connection-provided User-Agent wins.
134+
const DEFAULT_USER_AGENT = "executor";
135+
129136
const applyHeaders = (
130137
request: HttpClientRequest.HttpClientRequest,
131138
headers: Record<string, string>,
@@ -631,6 +638,10 @@ export const invoke = Effect.fn("OpenApi.invoke")(function* (
631638

632639
let request = HttpClientRequest.make(operation.method.toUpperCase() as "GET")(path);
633640

641+
// Default first so operation header params and resolved auth headers below
642+
// can override it; the upstream still gets a User-Agent if nothing else sets one.
643+
request = HttpClientRequest.setHeader(request, "User-Agent", DEFAULT_USER_AGENT);
644+
634645
for (const [name, value] of Object.entries(sourceQueryParams)) {
635646
request = HttpClientRequest.setUrlParam(request, name, value);
636647
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { expect, it } from "@effect/vitest";
2+
import { Effect, Option } from "effect";
3+
import { FetchHttpClient } from "effect/unstable/http";
4+
import { createServer, type IncomingHttpHeaders, type Server } from "node:http";
5+
6+
import { invokeWithLayer } from "./invoke";
7+
import { OperationBinding, OperationParameter } from "./types";
8+
9+
// Captures the headers of the real outbound request the invoke path sends.
10+
const withServer = <A>(
11+
f: (input: { readonly baseUrl: string; readonly headers: IncomingHttpHeaders[] }) => Promise<A>,
12+
) =>
13+
new Promise<A>((resolve, reject) => {
14+
const headers: IncomingHttpHeaders[] = [];
15+
const server: Server = createServer((request, response) => {
16+
headers.push(request.headers);
17+
response.statusCode = 200;
18+
response.setHeader("content-type", "application/json");
19+
response.end(JSON.stringify({ ok: true }));
20+
});
21+
22+
server.listen(0, "127.0.0.1", () => {
23+
const address = server.address();
24+
if (!address || typeof address === "string") {
25+
server.close();
26+
// oxlint-disable-next-line executor/no-promise-reject, executor/no-error-constructor -- boundary: Node listen callback is adapted into the test Promise failure path
27+
reject(new Error("Server did not bind to a TCP port"));
28+
return;
29+
}
30+
f({ baseUrl: `http://127.0.0.1:${address.port}`, headers })
31+
.then(resolve, reject)
32+
.finally(() => server.close());
33+
});
34+
});
35+
36+
const pathParam = (name: string) =>
37+
OperationParameter.make({
38+
name,
39+
location: "path",
40+
required: true,
41+
schema: Option.some({ type: "string" }),
42+
style: Option.none(),
43+
explode: Option.none(),
44+
allowReserved: Option.none(),
45+
description: Option.none(),
46+
});
47+
48+
// GitHub 403s any request without a User-Agent header, which downstream looks
49+
// identical to a credential rejection. The invoke path must always send one.
50+
it.effect("sends a default User-Agent so upstreams like GitHub don't 403", () =>
51+
Effect.promise(() =>
52+
withServer(async ({ baseUrl, headers }) => {
53+
const operation = OperationBinding.make({
54+
method: "get",
55+
servers: [],
56+
pathTemplate: "/repos/{owner}/{repo}/pulls",
57+
requestBody: Option.none(),
58+
responseBody: Option.none(),
59+
parameters: [pathParam("owner"), pathParam("repo")],
60+
});
61+
62+
await Effect.runPromise(
63+
invokeWithLayer(
64+
operation,
65+
{ owner: "risedle", repo: "aime" },
66+
baseUrl,
67+
{},
68+
{},
69+
FetchHttpClient.layer,
70+
),
71+
);
72+
73+
expect(headers[0]?.["user-agent"]).toBe("executor");
74+
}),
75+
),
76+
);
77+
78+
// A connection- or spec-provided User-Agent must still win over the default.
79+
it.effect("lets a resolved User-Agent override the default", () =>
80+
Effect.promise(() =>
81+
withServer(async ({ baseUrl, headers }) => {
82+
const operation = OperationBinding.make({
83+
method: "get",
84+
servers: [],
85+
pathTemplate: "/ping",
86+
requestBody: Option.none(),
87+
responseBody: Option.none(),
88+
parameters: [],
89+
});
90+
91+
await Effect.runPromise(
92+
invokeWithLayer(
93+
operation,
94+
{},
95+
baseUrl,
96+
{ "User-Agent": "acme-app/2.0" },
97+
{},
98+
FetchHttpClient.layer,
99+
),
100+
);
101+
102+
expect(headers[0]?.["user-agent"]).toBe("acme-app/2.0");
103+
}),
104+
),
105+
);

0 commit comments

Comments
 (0)