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:
Petr Sloup
2026-04-12 11:29:20 +02:00
committed by GitHub
parent bb5fa6403e
commit 2c918754c2
4 changed files with 159 additions and 3 deletions

View File

@@ -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

View 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);
});
});

View File

@@ -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;
}

View File

@@ -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,
};