mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 16:30:57 +00:00
fix(proxy): preserve multipart form data
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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<string, unknown>) {
|
||||
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<typeof MockUndiciFormData>;
|
||||
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: "",
|
||||
|
||||
@@ -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<string, unknown>),
|
||||
...(normalizeInitForUndici(init) as Record<string, unknown>),
|
||||
dispatcher: resolveAgent(),
|
||||
}) as unknown as Promise<Response>) 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<string, unknown>),
|
||||
...(normalizeInitForUndici(init) as Record<string, unknown>),
|
||||
dispatcher: agent,
|
||||
}) as unknown as Promise<Response>) as typeof fetch;
|
||||
} catch (err) {
|
||||
|
||||
Reference in New Issue
Block a user