diff --git a/integration-stack.md b/integration-stack.md index c602469..3b8ef9a 100644 --- a/integration-stack.md +++ b/integration-stack.md @@ -196,16 +196,22 @@ Running Stripe live surfaced real constraints in the **current T3 host** egress (`host:interfaces/http` `call`) — worth recording so nobody re-diagnoses them: 1. **The host parses request bodies as JSON.** A non-JSON payload fails with - `http.parse_payload: expected value at line 1 column 1`. JSON APIs (Gemini, - OpenAI, Anthropic) and read GETs work fully; form-encoded bodies do not. The - Stripe example works around this by putting params in the query string with an - empty body. -2. **On testnet, request headers aren't always forwarded.** Form-encoded Stripe - WRITES are flaky because the `content-type` header is intermittently dropped - (reads are 100% reliable). The example retries and reports honestly if the - egress is dropping headers on a given run. Neither is a Blindfold design - issue — auth and key protection are unaffected; these are host-egress - maturity gaps on testnet. + `http.parse_payload: expected value at line 1 column 1`. **✅ Solved + client-side:** the proxy now detects a `application/x-www-form-urlencoded` + body, moves its params into the URL query string, and sends an empty body — + so the host's JSON parser is never triggered and the agent's code is + unchanged (it can POST a normal form). Form-encoded providers (Stripe, + Twilio) accept params in the query string, so this is transparent. JSON APIs + are untouched. +2. **On testnet, request headers aren't always forwarded (residual host bug).** + Even with the params in the query string, Stripe still requires a + `content-type` header, and the testnet host **intermittently drops it** — so + a form write succeeds ~4/5 of the time and fails the rest with "check that + your POST content type…". Mitigations: the proxy always injects the correct + `content-type` for form providers (so it's present when the host does forward + it), and clients retry. The residual drop is genuinely host-side (reads are + 100%); it's the one item worth raising with the T3 team. Auth and key + protection are unaffected either way. ### basic + sigv4 proven LIVE (contract 0.5.4) diff --git a/packages/blindfold/src/providers.ts b/packages/blindfold/src/providers.ts index bc929e5..390c4ee 100644 --- a/packages/blindfold/src/providers.ts +++ b/packages/blindfold/src/providers.ts @@ -114,7 +114,7 @@ const PROVIDERS: ProviderDef[] = [ upstream: (p) => `https://api.stripe.com${stripPrefix(p, "/stripe/")}`, secretKey: "stripe_secret_key", auth: () => ({ scheme: "bearer" }), - defaultHeaders: { "stripe-version": "2024-06-20" }, + defaultHeaders: { "stripe-version": "2024-06-20", "content-type": "application/x-www-form-urlencoded" }, }, // ---- Dev infra: GitHub. GitHub REJECTS requests with no User-Agent (403), @@ -162,6 +162,7 @@ const PROVIDERS: ProviderDef[] = [ upstream: (p) => `https://api.twilio.com${stripPrefix(p, "/twilio/")}`, secretKey: "twilio_auth_token", auth: () => ({ scheme: "basic", username: process.env.TWILIO_ACCOUNT_SID || "" }), + defaultHeaders: { "content-type": "application/x-www-form-urlencoded" }, }, // ---- Cloud: AWS SES (SigV4 — secret SIGNS, never transmitted). ------------ diff --git a/packages/blindfold/src/proxy.ts b/packages/blindfold/src/proxy.ts index cc73b7a..10cfee9 100644 --- a/packages/blindfold/src/proxy.ts +++ b/packages/blindfold/src/proxy.ts @@ -132,11 +132,28 @@ async function handle( } } + // Work around the T3 host egress parsing every request body as JSON (a raw + // form-encoded body fails with `http.parse_payload: expected value…`). + // Form-encoded APIs (Stripe, Twilio, AWS query APIs) accept the same params + // in the query string, so move a form body into the URL and send no body. + // The agent's code is unchanged — it can POST a normal form and this adapts + // it. JSON bodies are left untouched (the host parses those fine). + let outboundUrl = upstream; + let outboundBody: string | undefined = body.length ? body.toString("utf8") : undefined; + const contentType = (headers.find(([k]) => k.toLowerCase() === "content-type")?.[1] ?? "").toLowerCase(); + if (outboundBody && contentType.includes("application/x-www-form-urlencoded")) { + const u = new URL(outboundUrl); + for (const [k, v] of new URLSearchParams(outboundBody)) u.searchParams.append(k, v); + outboundUrl = u.toString(); + outboundBody = undefined; + safeLog("info", { msg: "form_body_to_query", provider: provider.id }); + } + const forwardReq: ForwardRequest = { method: req.method ?? "GET", - url: upstream, + url: outboundUrl, headers, - body: body.length ? body.toString("utf8") : undefined, + body: outboundBody, secret_key: providerSecretKey, auth: provider.auth, };