mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(exec): disable onUpdate after run settlement to prevent gateway crash (#64349)
Co-authored-by: petr-sloup <13165948+petr-sloup@users.noreply.github.com> Co-authored-by: openperf <16864032@qq.com>
This commit is contained in:
@@ -14,6 +14,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Telegram: route approval button callback queries onto a separate sequentializer lane so plugin approval clicks can resolve immediately instead of deadlocking behind the blocked agent turn. (#64979) Thanks @nk3750.
|
||||
- Agents/Anthropic replay: preserve immutable signed-thinking replay safety across stored and live reruns, keep non-thinking embedded `tool_result` user blocks intact, and drop conflicting preserved tool IDs before validation so retries stop degrading into omitted tool calls. (#65126) Thanks @shakkernerd.
|
||||
- Telegram/direct sessions: keep commentary-only assistant fallback payloads out of visible direct delivery, so Codex planning chatter cannot leak into Telegram DMs when a run has no `final_answer` text. (#65112) Thanks @vincentkoc.
|
||||
- Infra/net: fix multipart FormData fields (including `model`) being silently dropped when a guarded runtime fetch body crosses a FormData implementation boundary, restoring OpenAI audio transcription requests that failed with HTTP 400. (#64349) Thanks @petr-sloup.
|
||||
|
||||
## 2026.4.11
|
||||
|
||||
|
||||
85
src/infra/net/runtime-fetch.test.ts
Normal file
85
src/infra/net/runtime-fetch.test.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchWithRuntimeDispatcher } from "./runtime-fetch.js";
|
||||
import { TEST_UNDICI_RUNTIME_DEPS_KEY } from "./undici-runtime.js";
|
||||
|
||||
class RuntimeFormData {
|
||||
readonly records: Array<{
|
||||
name: string;
|
||||
value: unknown;
|
||||
filename?: string;
|
||||
}> = [];
|
||||
|
||||
append(name: string, value: unknown, filename?: string): void {
|
||||
this.records.push({
|
||||
name,
|
||||
value,
|
||||
...(typeof filename === "string" ? { filename } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
*entries(): IterableIterator<[string, unknown]> {
|
||||
for (const record of this.records) {
|
||||
yield [record.name, record.value];
|
||||
}
|
||||
}
|
||||
|
||||
get [Symbol.toStringTag](): string {
|
||||
return "FormData";
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
Reflect.deleteProperty(globalThis as object, TEST_UNDICI_RUNTIME_DEPS_KEY);
|
||||
});
|
||||
|
||||
describe("fetchWithRuntimeDispatcher", () => {
|
||||
it("normalizes global FormData bodies into the runtime FormData implementation", async () => {
|
||||
const runtimeFetch = vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => {
|
||||
// init.body was rebuilt as RuntimeFormData by normalizeRuntimeFormData;
|
||||
// BodyInit and RuntimeFormData live in separate type namespaces so a double cast is needed.
|
||||
const body = init?.body as unknown as RuntimeFormData;
|
||||
expect(body).toBeInstanceOf(RuntimeFormData);
|
||||
expect(body.records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "model",
|
||||
value: "gpt-4o-transcribe",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
name: "file",
|
||||
filename: "clip.ogg",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
return new Response("ok", { status: 200 });
|
||||
});
|
||||
|
||||
(globalThis as Record<string, unknown>)[TEST_UNDICI_RUNTIME_DEPS_KEY] = {
|
||||
Agent: class MockAgent {},
|
||||
EnvHttpProxyAgent: class MockEnvHttpProxyAgent {},
|
||||
FormData: RuntimeFormData,
|
||||
ProxyAgent: class MockProxyAgent {},
|
||||
fetch: runtimeFetch,
|
||||
};
|
||||
|
||||
const form = new FormData();
|
||||
form.append("file", new Blob([new Uint8Array([1, 2, 3])], { type: "audio/ogg" }), "clip.ogg");
|
||||
form.append("model", "gpt-4o-transcribe");
|
||||
|
||||
const response = await fetchWithRuntimeDispatcher("https://example.com/upload", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-length": "999",
|
||||
"content-type": "multipart/form-data; boundary=stale",
|
||||
},
|
||||
body: form,
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(runtimeFetch).toHaveBeenCalledTimes(1);
|
||||
const sentInit = runtimeFetch.mock.calls[0]?.[1] as RequestInit;
|
||||
const sentHeaders = new Headers(sentInit.headers);
|
||||
expect(sentHeaders.has("content-length")).toBe(false);
|
||||
expect(sentHeaders.has("content-type")).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,74 @@
|
||||
import type { Dispatcher } from "undici";
|
||||
import { loadUndiciRuntimeDeps } from "./undici-runtime.js";
|
||||
import { loadUndiciRuntimeDeps, type UndiciRuntimeDeps } from "./undici-runtime.js";
|
||||
|
||||
export type DispatcherAwareRequestInit = RequestInit & { dispatcher?: Dispatcher };
|
||||
|
||||
type FetchLike = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
||||
|
||||
type RuntimeFormDataCtor = NonNullable<UndiciRuntimeDeps["FormData"]>;
|
||||
|
||||
type FormDataEntryValueWithOptionalName = FormDataEntryValue & { name?: 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 normalizeRuntimeFormData(
|
||||
body: unknown,
|
||||
RuntimeFormData: RuntimeFormDataCtor | undefined,
|
||||
): BodyInit | null | undefined {
|
||||
if (!isFormDataLike(body) || typeof RuntimeFormData !== "function") {
|
||||
return body as BodyInit | null | undefined;
|
||||
}
|
||||
if (body instanceof RuntimeFormData) {
|
||||
return body;
|
||||
}
|
||||
|
||||
const next = new RuntimeFormData();
|
||||
for (const [key, value] of body.entries()) {
|
||||
const namedValue = value as FormDataEntryValueWithOptionalName;
|
||||
// File.name is the standard filename property; skip empty/whitespace-only values
|
||||
const fileName =
|
||||
typeof namedValue.name === "string" && namedValue.name.trim() ? namedValue.name : undefined;
|
||||
if (fileName) {
|
||||
next.append(key, value, fileName);
|
||||
} else {
|
||||
next.append(key, value);
|
||||
}
|
||||
}
|
||||
// undici.FormData is structurally compatible with BodyInit but lives in a separate
|
||||
// type namespace; the cast avoids a cross-implementation assignability error.
|
||||
return next as unknown as BodyInit;
|
||||
}
|
||||
|
||||
function normalizeRuntimeRequestInit(
|
||||
init: DispatcherAwareRequestInit | undefined,
|
||||
RuntimeFormData: RuntimeFormDataCtor | undefined,
|
||||
): DispatcherAwareRequestInit | undefined {
|
||||
if (!init?.body) {
|
||||
return init;
|
||||
}
|
||||
|
||||
const body = normalizeRuntimeFormData(init.body, RuntimeFormData);
|
||||
if (body === init.body) {
|
||||
return init;
|
||||
}
|
||||
|
||||
const headers = new Headers(init.headers);
|
||||
headers.delete("content-length");
|
||||
headers.delete("content-type");
|
||||
return {
|
||||
...init,
|
||||
headers,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
||||
export function isMockedFetch(fetchImpl: FetchLike | undefined): boolean {
|
||||
if (typeof fetchImpl !== "function") {
|
||||
return false;
|
||||
@@ -16,9 +80,13 @@ export async function fetchWithRuntimeDispatcher(
|
||||
input: RequestInfo | URL,
|
||||
init?: DispatcherAwareRequestInit,
|
||||
): Promise<Response> {
|
||||
const runtimeFetch = loadUndiciRuntimeDeps().fetch as unknown as (
|
||||
const runtimeDeps = loadUndiciRuntimeDeps();
|
||||
const runtimeFetch = runtimeDeps.fetch as unknown as (
|
||||
input: RequestInfo | URL,
|
||||
init?: DispatcherAwareRequestInit,
|
||||
) => Promise<unknown>;
|
||||
return (await runtimeFetch(input, init)) as Response;
|
||||
return (await runtimeFetch(
|
||||
input,
|
||||
normalizeRuntimeRequestInit(init, runtimeDeps.FormData),
|
||||
)) as Response;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ export const TEST_UNDICI_RUNTIME_DEPS_KEY = "__OPENCLAW_TEST_UNDICI_RUNTIME_DEPS
|
||||
export type UndiciRuntimeDeps = {
|
||||
Agent: typeof import("undici").Agent;
|
||||
EnvHttpProxyAgent: typeof import("undici").EnvHttpProxyAgent;
|
||||
FormData?: typeof import("undici").FormData;
|
||||
ProxyAgent: typeof import("undici").ProxyAgent;
|
||||
fetch: typeof import("undici").fetch;
|
||||
};
|
||||
@@ -44,6 +45,7 @@ export function loadUndiciRuntimeDeps(): UndiciRuntimeDeps {
|
||||
return {
|
||||
Agent: undici.Agent,
|
||||
EnvHttpProxyAgent: undici.EnvHttpProxyAgent,
|
||||
FormData: undici.FormData,
|
||||
ProxyAgent: undici.ProxyAgent,
|
||||
fetch: undici.fetch,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user