diff --git a/CHANGELOG.md b/CHANGELOG.md index 277b80239a3..6fd0d0ee7d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Proxy/audio: convert standard `FormData` bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send `[object FormData]` when `HTTP_PROXY` or `HTTPS_PROXY` is configured. Fixes #48554. Thanks @dco5. - Gateway/diagnostics: include a bounded redacted startup error message in stability bundles, so crash-loop reports identify the failing plugin or contract without exposing secrets. Refs #75797. Thanks @ymebosma. - Gateway/pricing: abort in-flight model pricing catalog fetches when Gateway shutdown stops the refresh loop, and avoid post-stop cache writes or refresh timers. Fixes #72208. Thanks @rzcq. - Control UI/Talk: allow the OpenAI Realtime WebRTC offer endpoint through the Control UI CSP, configure browser sessions with explicit VAD/transcription input settings, and surface OpenAI realtime error/lifecycle events instead of leaving Talk stuck as live with no diagnostic. Fixes #73427. diff --git a/src/infra/net/proxy-fetch.test.ts b/src/infra/net/proxy-fetch.test.ts index 92b38ad1302..e6df13c2efd 100644 --- a/src/infra/net/proxy-fetch.test.ts +++ b/src/infra/net/proxy-fetch.test.ts @@ -13,43 +13,64 @@ const ORIGINAL_PROXY_ENV = Object.fromEntries( PROXY_ENV_KEYS.map((key) => [key, process.env[key]]), ) as Record<(typeof PROXY_ENV_KEYS)[number], string | undefined>; -const { ProxyAgent, EnvHttpProxyAgent, undiciFetch, proxyAgentSpy, envAgentSpy, getLastAgent } = - vi.hoisted(() => { - const undiciFetch = vi.fn(); - const proxyAgentSpy = vi.fn(); - const envAgentSpy = vi.fn(); - class ProxyAgent { - static lastCreated: ProxyAgent | undefined; - proxyUrl: string; - constructor(proxyUrl: string) { - this.proxyUrl = proxyUrl; - ProxyAgent.lastCreated = this; - proxyAgentSpy(proxyUrl); - } - } - class EnvHttpProxyAgent { - static lastCreated: EnvHttpProxyAgent | undefined; - constructor(public readonly options?: Record) { - EnvHttpProxyAgent.lastCreated = this; - envAgentSpy(options); - } +const { + ProxyAgent, + EnvHttpProxyAgent, + MockUndiciFormData, + undiciFetch, + proxyAgentSpy, + envAgentSpy, + getLastAgent, +} = vi.hoisted(() => { + const undiciFetch = vi.fn(); + const proxyAgentSpy = vi.fn(); + const envAgentSpy = vi.fn(); + class MockUndiciFormData { + readonly [Symbol.toStringTag] = "FormData"; + readonly entriesList: [string, unknown, string | undefined][] = []; + + append(key: string, value: unknown, filename?: string): void { + this.entriesList.push([key, value, filename]); } - return { - ProxyAgent, - EnvHttpProxyAgent, - undiciFetch, - proxyAgentSpy, - envAgentSpy, - getLastAgent: () => ProxyAgent.lastCreated, - }; - }); + get(key: string): unknown { + return this.entriesList.find(([entryKey]) => entryKey === key)?.[1] ?? null; + } + } + class ProxyAgent { + static lastCreated: ProxyAgent | undefined; + proxyUrl: string; + constructor(proxyUrl: string) { + this.proxyUrl = proxyUrl; + ProxyAgent.lastCreated = this; + proxyAgentSpy(proxyUrl); + } + } + class EnvHttpProxyAgent { + static lastCreated: EnvHttpProxyAgent | undefined; + constructor(public readonly options?: Record) { + EnvHttpProxyAgent.lastCreated = this; + envAgentSpy(options); + } + } + + return { + ProxyAgent, + EnvHttpProxyAgent, + MockUndiciFormData, + undiciFetch, + proxyAgentSpy, + envAgentSpy, + getLastAgent: () => ProxyAgent.lastCreated, + }; +}); const mockedModuleIds = ["undici"] as const; vi.mock("undici", () => ({ ProxyAgent, EnvHttpProxyAgent, + FormData: MockUndiciFormData, fetch: undiciFetch, })); @@ -112,6 +133,84 @@ describe("makeProxyFetch", () => { expect(proxyAgentSpy).toHaveBeenCalledOnce(); expect(secondDispatcher).toBe(firstDispatcher); }); + + it("converts global FormData bodies before dispatching through undici", async () => { + undiciFetch.mockResolvedValue({ ok: true }); + + const proxyFetch = makeProxyFetch("http://proxy.test:8080"); + const form = new globalThis.FormData(); + form.append("model", "whisper-1"); + form.append("file", new Blob([new Uint8Array(4)], { type: "audio/ogg" }), "voice.ogg"); + + await proxyFetch("https://api.example.com/v1/audio/transcriptions", { + method: "POST", + headers: { + "content-length": "999", + "content-type": "multipart/form-data; boundary=stale", + }, + body: form, + }); + + const passedInit = undiciFetch.mock.calls[0]?.[1]; + expect(passedInit?.body).toBeInstanceOf(MockUndiciFormData); + const passedBody = passedInit?.body as InstanceType; + expect(passedBody.get("model")).toBe("whisper-1"); + expect(passedBody.get("file")).toBeInstanceOf(Blob); + expect(passedBody.entriesList.find(([key]) => key === "file")?.[2]).toBe("voice.ogg"); + const sentHeaders = new Headers(passedInit?.headers); + expect(sentHeaders.has("content-length")).toBe(false); + expect(sentHeaders.has("content-type")).toBe(false); + }); + + it("keeps non-FormData bodies unchanged", async () => { + undiciFetch.mockResolvedValue({ ok: true }); + + const proxyFetch = makeProxyFetch("http://proxy.test:8080"); + const body = JSON.stringify({ hello: "world" }); + + await proxyFetch("https://api.example.com/json", { + method: "POST", + body, + }); + + expect(undiciFetch.mock.calls[0]?.[1]?.body).toBe(body); + }); + + it("keeps undici FormData instances unchanged", async () => { + undiciFetch.mockResolvedValue({ ok: true }); + + const proxyFetch = makeProxyFetch("http://proxy.test:8080"); + const form = new MockUndiciFormData(); + form.append("key", "value"); + + await proxyFetch("https://api.example.com/upload", { + method: "POST", + body: form as unknown as BodyInit, + }); + + expect(undiciFetch.mock.calls[0]?.[1]?.body).toBe(form); + }); + + it("converts FormData-like bodies from another implementation", async () => { + undiciFetch.mockResolvedValue({ ok: true }); + + const proxyFetch = makeProxyFetch("http://proxy.test:8080"); + const formLike = { + [Symbol.toStringTag]: "FormData", + *entries(): IterableIterator<[string, FormDataEntryValue]> { + yield ["model", "whisper-1"]; + }, + }; + + await proxyFetch("https://api.example.com/upload", { + method: "POST", + body: formLike as unknown as BodyInit, + }); + + const passedInit = undiciFetch.mock.calls[0]?.[1]; + expect(passedInit?.body).toBeInstanceOf(MockUndiciFormData); + expect(passedInit?.body.get("model")).toBe("whisper-1"); + }); }); describe("getProxyUrlFromFetch", () => { @@ -168,6 +267,30 @@ describe("resolveProxyFetchFromEnv", () => { ); }); + it("converts global FormData bodies when using proxy env fetch", async () => { + undiciFetch.mockResolvedValue({ ok: true }); + + const fetchFn = resolveProxyFetchFromEnv({ + HTTP_PROXY: "", + HTTPS_PROXY: "http://proxy.test:8080", + }); + expect(fetchFn).toBeDefined(); + + const form = new globalThis.FormData(); + form.append("file", new Blob([new Uint8Array(8)], { type: "audio/wav" }), "test.wav"); + form.append("model", "test-model"); + + await fetchFn!("https://api.example.com/v1/audio/transcriptions", { + method: "POST", + body: form, + }); + + const passedInit = undiciFetch.mock.calls[0]?.[1]; + expect(passedInit?.body).toBeInstanceOf(MockUndiciFormData); + expect(passedInit?.body.get("model")).toBe("test-model"); + expect(passedInit?.body.get("file")).toBeInstanceOf(Blob); + }); + it("returns proxy fetch when HTTP_PROXY is set", () => { const fetchFn = resolveProxyFetchFromEnv({ HTTPS_PROXY: "", diff --git a/src/infra/net/proxy-fetch.ts b/src/infra/net/proxy-fetch.ts index d78b6fe06be..dbe12131f82 100644 --- a/src/infra/net/proxy-fetch.ts +++ b/src/infra/net/proxy-fetch.ts @@ -1,4 +1,9 @@ -import { EnvHttpProxyAgent, ProxyAgent, fetch as undiciFetch } from "undici"; +import { + EnvHttpProxyAgent, + FormData as UndiciFormData, + ProxyAgent, + fetch as undiciFetch, +} from "undici"; import { logWarn } from "../../logger.js"; import { formatErrorMessage } from "../errors.js"; import { resolveEnvHttpProxyAgentOptions } from "./proxy-env.js"; @@ -8,6 +13,46 @@ type ProxyFetchWithMetadata = typeof fetch & { [PROXY_FETCH_PROXY_URL]?: string; }; +function isFormDataLike(value: unknown): value is FormData { + return ( + typeof value === "object" && + value !== null && + typeof (value as FormData).entries === "function" && + (value as { [Symbol.toStringTag]?: unknown })[Symbol.toStringTag] === "FormData" + ); +} + +function appendFormDataEntry(target: UndiciFormData, key: string, value: FormDataEntryValue): void { + if (typeof value === "string") { + target.append(key, value); + return; + } + const fileName = typeof value.name === "string" && value.name.trim() ? value.name : undefined; + if (fileName) { + target.append(key, value, fileName); + return; + } + target.append(key, value); +} + +function normalizeInitForUndici(init: RequestInit | undefined): RequestInit | undefined { + if (!init) { + return init; + } + const body = init.body; + if (!isFormDataLike(body) || body instanceof UndiciFormData) { + return init; + } + const form = new UndiciFormData(); + for (const [key, value] of body.entries()) { + appendFormDataEntry(form, key, value); + } + const headers = new Headers(init.headers); + headers.delete("content-length"); + headers.delete("content-type"); + return { ...init, headers, body: form as unknown as BodyInit }; +} + /** * Create a fetch function that routes requests through the given HTTP proxy. * Uses undici's ProxyAgent under the hood. @@ -24,7 +69,7 @@ export function makeProxyFetch(proxyUrl: string): typeof fetch { // on stream/body internals. Single cast at the boundary keeps the rest type-safe. const proxyFetch = ((input: RequestInfo | URL, init?: RequestInit) => undiciFetch(input as string | URL, { - ...(init as Record), + ...(normalizeInitForUndici(init) as Record), dispatcher: resolveAgent(), }) as unknown as Promise) as ProxyFetchWithMetadata; Object.defineProperty(proxyFetch, PROXY_FETCH_PROXY_URL, { @@ -62,7 +107,7 @@ export function resolveProxyFetchFromEnv( const agent = new EnvHttpProxyAgent(proxyOptions); return ((input: RequestInfo | URL, init?: RequestInit) => undiciFetch(input as string | URL, { - ...(init as Record), + ...(normalizeInitForUndici(init) as Record), dispatcher: agent, }) as unknown as Promise) as typeof fetch; } catch (err) {