From 60f80be46a990db2f35e207da0991048a7ac78e6 Mon Sep 17 00:00:00 2001 From: Vicky Kumar Date: Wed, 1 Jul 2026 20:45:21 +0530 Subject: [PATCH] feat: give each provider its real required headers (first-class, not host-templated) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Bearer providers differed only by hostname, which reads as generic even though there's no passthrough. Make each a genuine integration: it now carries the provider's real required headers, injected only when the agent didn't set them, so the agent needn't know each API's quirks. - providers.ts: add defaultHeaders per provider - GitHub: User-Agent (GitHub 403s without it) + Accept + X-GitHub-Api-Version - Anthropic: anthropic-version (required by the raw REST API) - Stripe: Stripe-Version (pins API behaviour) - Slack / SendGrid: correct Content-Type - proxy.ts: inject defaultHeaders when absent (agent can still override) - integration-stack.md: document the closed/curated stance + required headers Proven live: GET /github/user through the proxy with NO User-Agent from the agent returns 200 — the integration supplies GitHub's mandatory headers. Stripe still 200 with the pinned version. --- integration-stack.md | 19 +++++++++++++++ packages/blindfold/src/providers.ts | 36 ++++++++++++++++++++++++----- packages/blindfold/src/proxy.ts | 10 ++++++++ 3 files changed, 59 insertions(+), 6 deletions(-) diff --git a/integration-stack.md b/integration-stack.md index 5319424..ab7c2b0 100644 --- a/integration-stack.md +++ b/integration-stack.md @@ -88,6 +88,25 @@ its exact upstream host, its own sealed-secret name, and its auth scheme: cloud) and all three auth schemes. Longest-prefix routing; non-secret config (Twilio Account SID, AWS access-key-id / region) comes from env, never sealed. +**Closed and curated by design — no generic passthrough, and no generic-*feeling* +entries.** There is no catch-all route and no user-config/wildcard host; providers +are added only in code. To make the Bearer providers genuinely first-class (not +"a host with a bearer token"), each carries its **real required headers**, which +the integration injects only when the agent didn't set them: + +| Provider | Required headers the integration supplies | +|---|---| +| GitHub | `User-Agent` (GitHub **403s** without it), `Accept: application/vnd.github+json`, `X-GitHub-Api-Version` | +| Anthropic | `anthropic-version` (required by the raw REST API) | +| Stripe | `Stripe-Version` (pins API behaviour) | +| Slack | `Content-Type: application/json; charset=utf-8` | +| SendGrid | `Content-Type: application/json` | + +Proven live: `GET /github/user` through the proxy with **no `User-Agent` from the +agent** returns **200** — the integration supplied GitHub's mandatory headers. An +agent doesn't have to know each provider's quirks; the integration does. That's +the difference between a real integration and a generic host map. + ### 3. Proxy + type wiring - `ForwardRequest` gained an optional `auth` field that serialises 1:1 into the diff --git a/packages/blindfold/src/providers.ts b/packages/blindfold/src/providers.ts index 33d2953..bc929e5 100644 --- a/packages/blindfold/src/providers.ts +++ b/packages/blindfold/src/providers.ts @@ -43,6 +43,11 @@ export interface ResolvedProvider { auth: ForwardAuth; /** Bearer-only: header to plant the sentinel in. Defaults to Authorization. */ sentinelHeader?: SentinelHeader; + /** Provider-specific headers this API actually requires (version pins, + * Accept, User-Agent, …). Injected only when the agent didn't set them, so + * the integration knows the provider's conventions and the agent doesn't + * have to. This is what makes each entry a real integration, not a host. */ + defaultHeaders?: Record; } interface ProviderDef { @@ -56,6 +61,8 @@ interface ProviderDef { auth: () => ForwardAuth; /** Bearer-only override for where the sentinel goes. */ sentinelHeader?: SentinelHeader; + /** Provider-specific required headers (see ResolvedProvider.defaultHeaders). */ + defaultHeaders?: Record; } /** AWS-style timestamp `YYYYMMDDTHHMMSSZ` (UTC). Not a secret — the enclave has @@ -81,7 +88,8 @@ const PROVIDERS: ProviderDef[] = [ // ---- LLM providers (OpenAI-shaped, bearer). Back-compatible. -------------- { id: "openai", prefix: "/v1/", upstream: (p) => `https://api.openai.com${p}`, auth: () => ({ scheme: "bearer" }) }, { id: "openai", prefix: "/openai/", upstream: (p) => `https://api.openai.com${stripPrefix(p, "/openai/")}`, auth: () => ({ scheme: "bearer" }) }, - { id: "anthropic", prefix: "/anthropic/", upstream: (p) => `https://api.anthropic.com${stripPrefix(p, "/anthropic/")}`, auth: () => ({ scheme: "bearer" }) }, + // Anthropic REQUIRES the anthropic-version header on the raw REST API. + { id: "anthropic", prefix: "/anthropic/", upstream: (p) => `https://api.anthropic.com${stripPrefix(p, "/anthropic/")}`, auth: () => ({ scheme: "bearer" }), defaultHeaders: { "anthropic-version": "2023-06-01" } }, { id: "xai", prefix: "/x/", upstream: (p) => `https://api.x.ai${stripPrefix(p, "/x/")}`, auth: () => ({ scheme: "bearer" }) }, { id: "groq", prefix: "/groq/", upstream: (p) => `https://api.groq.com/openai${stripPrefix(p, "/groq/")}`, auth: () => ({ scheme: "bearer" }) }, @@ -99,40 +107,49 @@ const PROVIDERS: ProviderDef[] = [ sentinelHeader: { name: "x-goog-api-key", prefix: "" }, }, - // ---- Payments: Stripe (bearer, restricted keys, form-encoded bodies). ----- + // ---- Payments: Stripe (bearer; pins the API version so behaviour is stable). ----- { id: "stripe", prefix: "/stripe/", upstream: (p) => `https://api.stripe.com${stripPrefix(p, "/stripe/")}`, secretKey: "stripe_secret_key", auth: () => ({ scheme: "bearer" }), + defaultHeaders: { "stripe-version": "2024-06-20" }, }, - // ---- Dev infra: GitHub (bearer token). ------------------------------------ + // ---- Dev infra: GitHub. GitHub REJECTS requests with no User-Agent (403), + // and best practice is to pin the REST API version + set the Accept type. { id: "github", prefix: "/github/", upstream: (p) => `https://api.github.com${stripPrefix(p, "/github/")}`, secretKey: "github_token", auth: () => ({ scheme: "bearer" }), + defaultHeaders: { + accept: "application/vnd.github+json", + "x-github-api-version": "2022-11-28", + "user-agent": "blindfold", + }, }, - // ---- Email: SendGrid (bearer). -------------------------------------------- + // ---- Email: SendGrid (bearer; JSON v3 API). ------------------------------- { id: "sendgrid", prefix: "/sendgrid/", upstream: (p) => `https://api.sendgrid.com${stripPrefix(p, "/sendgrid/")}`, secretKey: "sendgrid_api_key", auth: () => ({ scheme: "bearer" }), + defaultHeaders: { "content-type": "application/json" }, }, - // ---- Comms: Slack (bearer bot token). ------------------------------------- + // ---- Comms: Slack (bearer bot token; Web API wants JSON+charset on POST). -- { id: "slack", prefix: "/slack/", upstream: (p) => `https://slack.com/api${stripPrefix(p, "/slack/")}`, secretKey: "slack_bot_token", auth: () => ({ scheme: "bearer" }), + defaultHeaders: { "content-type": "application/json; charset=utf-8" }, }, // ---- Telephony: Twilio (HTTP Basic — base64 computed IN the enclave). ----- @@ -175,7 +192,14 @@ export function resolveProvider(path: string): ResolvedProvider | null { .filter((d) => path.startsWith(d.prefix)) .sort((a, b) => b.prefix.length - a.prefix.length)[0]; if (!def) return null; - return { id: def.id, upstream: def.upstream(path), secretKey: def.secretKey, auth: def.auth(), sentinelHeader: def.sentinelHeader }; + return { + id: def.id, + upstream: def.upstream(path), + secretKey: def.secretKey, + auth: def.auth(), + sentinelHeader: def.sentinelHeader, + defaultHeaders: def.defaultHeaders, + }; } /** Names of the providers Blindfold ships first-class support for. */ diff --git a/packages/blindfold/src/proxy.ts b/packages/blindfold/src/proxy.ts index 85101c3..cda12c4 100644 --- a/packages/blindfold/src/proxy.ts +++ b/packages/blindfold/src/proxy.ts @@ -122,6 +122,16 @@ async function handle( removeHeader(headers, "authorization"); } + // Inject this provider's real required headers (e.g. GitHub's mandatory + // User-Agent, Anthropic's anthropic-version, Stripe's pinned API version) — + // only when the agent didn't set them, so the agent can still override. This + // is what makes each provider a real integration rather than a bare host. + for (const [name, value] of Object.entries(provider.defaultHeaders ?? {})) { + if (!headers.some(([k]) => k.toLowerCase() === name.toLowerCase())) { + headers.push([name, value]); + } + } + const forwardReq: ForwardRequest = { method: req.method ?? "GET", url: upstream,