mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-24 06:21:14 +00:00
fix(discord): bound stalled probe response reads (#106036)
* fix(discord): bound stalled probe response reads * fix(discord): bound application probe bodies * fix(discord): preserve probe result within status budget * style(discord): format probe timeout test * chore(deadcode): allowlist test-only timeout constant exports * refactor(discord): keep status probe budget in loader * fix(discord): enforce total probe deadline Co-authored-by: Alix-007 <li.long15@xydigit.com> --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
// Discord plugin module implements channel.loaders behavior.
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { DiscordProbe } from "./probe.js";
|
||||
|
||||
export const loadDiscordDirectoryConfigModule = createLazyRuntimeModule(
|
||||
() => import("./directory-config.js"),
|
||||
@@ -23,6 +24,18 @@ export const loadDiscordProviderRuntime = createLazyRuntimeModule(
|
||||
|
||||
export const loadDiscordProbeRuntime = createLazyRuntimeModule(() => import("./probe.runtime.js"));
|
||||
|
||||
export async function probeDiscordStatusAccount(params: {
|
||||
token: string;
|
||||
timeoutMs: number;
|
||||
}): Promise<DiscordProbe> {
|
||||
const startedAtMs = Date.now();
|
||||
const runtime = await loadDiscordProbeRuntime();
|
||||
// The gateway starts its hook deadline before lazy plugin loading. Carry only the remaining
|
||||
// budget into the probe or a cold import can let optional metadata outrun the caller.
|
||||
const remainingMs = Math.max(1, params.timeoutMs - Math.max(0, Date.now() - startedAtMs));
|
||||
return await runtime.probeDiscord(params.token, remainingMs, { includeApplication: true });
|
||||
}
|
||||
|
||||
export const loadDiscordAuditModule = createLazyRuntimeModule(() => import("./audit.js"));
|
||||
|
||||
export const loadDiscordSendModule = createLazyRuntimeModule(() => import("./send.js"));
|
||||
|
||||
@@ -569,12 +569,34 @@ describe("discordPlugin outbound", () => {
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 5000, {
|
||||
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", expect.any(Number), {
|
||||
includeApplication: true,
|
||||
});
|
||||
const forwardedTimeoutMs = Number(argAt(probeDiscordMock, 0, 1));
|
||||
expect(forwardedTimeoutMs).toBeGreaterThan(0);
|
||||
expect(forwardedTimeoutMs).toBeLessThanOrEqual(5_000);
|
||||
expect(runtimeProbeDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("subtracts lazy probe loading from the status budget", async () => {
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValueOnce(1_000).mockReturnValueOnce(1_200);
|
||||
probeDiscordMock.mockResolvedValue({ ok: true, elapsedMs: 1 });
|
||||
try {
|
||||
const cfg = createCfg();
|
||||
await discordPlugin.status!.probeAccount!({
|
||||
account: resolveAccount(cfg),
|
||||
timeoutMs: 5_000,
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(probeDiscordMock).toHaveBeenCalledWith("discord-token", 4_800, {
|
||||
includeApplication: true,
|
||||
});
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports missing voice permissions in targeted capabilities diagnostics", async () => {
|
||||
const fetchPermissionsSpy = vi
|
||||
.spyOn(sendModule, "fetchChannelPermissionsDiscord")
|
||||
|
||||
@@ -61,6 +61,7 @@ import {
|
||||
loadDiscordSendModule,
|
||||
loadDiscordTargetResolverModule,
|
||||
loadDiscordThreadBindingsManagerModule,
|
||||
probeDiscordStatusAccount,
|
||||
} from "./channel.loaders.js";
|
||||
import { openDiscordCommandDeployHashStore } from "./command-deploy-store.js";
|
||||
import { shouldSuppressLocalDiscordExecApprovalPrompt } from "./exec-approvals.js";
|
||||
@@ -531,9 +532,7 @@ export const discordPlugin: ChannelPlugin<ResolvedDiscordAccount, DiscordProbe>
|
||||
buildChannelSummary: ({ snapshot }) =>
|
||||
buildTokenChannelStatusSummary(snapshot, { includeMode: false }),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
(await loadDiscordProbeRuntime()).probeDiscord(account.token, timeoutMs, {
|
||||
includeApplication: true,
|
||||
}),
|
||||
await probeDiscordStatusAccount({ token: account.token, timeoutMs }),
|
||||
formatCapabilitiesProbe: ({ probe }) => {
|
||||
const discordProbe = probe as DiscordProbe | undefined;
|
||||
const lines = [];
|
||||
|
||||
@@ -31,6 +31,61 @@ function oversizedDiscordProbeJsonResponse(onCancel: () => void): Response {
|
||||
return response;
|
||||
}
|
||||
|
||||
function abortableTricklingDiscordProbeJsonResponse(
|
||||
signal: AbortSignal | null | undefined,
|
||||
onAbort: () => void,
|
||||
): Response {
|
||||
let interval: ReturnType<typeof setInterval> | undefined;
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const abort = () => {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
onAbort();
|
||||
controller.error(signal?.reason ?? new Error("request aborted"));
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(new TextEncoder().encode('{"id":"bot-1"'));
|
||||
interval = setInterval(() => controller.enqueue(new Uint8Array([0x20])), 10);
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
},
|
||||
cancel() {
|
||||
if (interval) {
|
||||
clearInterval(interval);
|
||||
}
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" }, status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
function abortableStalledDiscordJsonResponse(
|
||||
signal: AbortSignal | null | undefined,
|
||||
onAbort: () => void,
|
||||
): Response {
|
||||
return new Response(
|
||||
new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
const abort = () => {
|
||||
onAbort();
|
||||
controller.error(signal?.reason ?? new Error("request aborted"));
|
||||
};
|
||||
if (signal?.aborted) {
|
||||
abort();
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", abort, { once: true });
|
||||
},
|
||||
}),
|
||||
{ headers: { "content-type": "application/json" }, status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
describe("resolveDiscordPrivilegedIntentsFromFlags", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
@@ -141,6 +196,117 @@ describe("resolveDiscordPrivilegedIntentsFromFlags", () => {
|
||||
expect(cancelCount).toBe(1);
|
||||
});
|
||||
|
||||
it("times out and cancels stalled getMe probe JSON response bodies", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let abortCount = 0;
|
||||
const fetcher = withFetchPreconnect(async (_input, init) =>
|
||||
abortableStalledDiscordJsonResponse(init?.signal, () => {
|
||||
abortCount += 1;
|
||||
}),
|
||||
);
|
||||
|
||||
const probe = probeDiscord("MTIz.abc.def", 50, { fetcher });
|
||||
const assertion = expect(probe).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: "discord.probe.getMe: JSON response timed out after 50ms",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
await assertion;
|
||||
expect(abortCount).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("uses one total deadline across getMe headers and a trickling JSON body", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let abortCount = 0;
|
||||
const fetcher = withFetchPreconnect(async (_input, init) => {
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
return abortableTricklingDiscordProbeJsonResponse(init?.signal, () => {
|
||||
abortCount += 1;
|
||||
});
|
||||
});
|
||||
|
||||
const probe = probeDiscord("MTIz.abc.def", 50, { fetcher });
|
||||
const assertion = expect(probe).resolves.toMatchObject({
|
||||
ok: false,
|
||||
error: "discord.probe.getMe: JSON response timed out after 50ms",
|
||||
});
|
||||
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
await assertion;
|
||||
expect(abortCount).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds stalled application-summary response bodies during probes", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let abortCount = 0;
|
||||
const fetcher = withFetchPreconnect(async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
if (url.endsWith("/users/@me")) {
|
||||
return jsonResponse({ id: "bot-1", username: "openclaw" });
|
||||
}
|
||||
return abortableStalledDiscordJsonResponse(init?.signal, () => {
|
||||
abortCount += 1;
|
||||
});
|
||||
});
|
||||
|
||||
const probe = probeDiscord("MTIz.abc.def", 50, {
|
||||
fetcher,
|
||||
includeApplication: true,
|
||||
});
|
||||
const outerStatusResult = Promise.race([
|
||||
probe,
|
||||
new Promise<{ ok: false; timedOut: true }>((resolve) => {
|
||||
setTimeout(() => resolve({ ok: false, timedOut: true }), 50);
|
||||
}),
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
await expect(outerStatusResult).resolves.toMatchObject({
|
||||
ok: true,
|
||||
bot: { id: "bot-1", username: "openclaw" },
|
||||
});
|
||||
expect(abortCount).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("bounds stalled application-id response bodies", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
let abortCount = 0;
|
||||
const fetcher = withFetchPreconnect(async (_input, init) =>
|
||||
abortableStalledDiscordJsonResponse(init?.signal, () => {
|
||||
abortCount += 1;
|
||||
}),
|
||||
);
|
||||
|
||||
const lookup = fetchDiscordApplicationId("unparseable.token", 50, fetcher);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(50);
|
||||
|
||||
await expect(lookup).resolves.toBeUndefined();
|
||||
expect(abortCount).toBe(1);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("derives application id from parseable tokens before probing REST", async () => {
|
||||
let calls = 0;
|
||||
const fetcher = withFetchPreconnect(async () => {
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
// Discord plugin module implements probe behavior.
|
||||
import type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { buildTimeoutAbortSignal } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { resolveFetch } from "openclaw/plugin-sdk/fetch-runtime";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
|
||||
import { fetchWithTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import { DiscordApiError, fetchDiscord } from "./api.js";
|
||||
import { normalizeDiscordToken } from "./token.js";
|
||||
|
||||
const DISCORD_API_BASE = "https://discord.com/api/v10";
|
||||
const DISCORD_PROBE_GET_ME_LABEL = "discord.probe.getMe";
|
||||
const DISCORD_PROBE_JSON_MAX_BYTES = 16 * 1024 * 1024;
|
||||
const DISCORD_PROBE_COMPLETION_RESERVE_MAX_MS = 25;
|
||||
|
||||
export type DiscordProbe = BaseProbeResult & {
|
||||
status?: number | null;
|
||||
@@ -50,25 +54,14 @@ async function fetchDiscordApplicationMe(
|
||||
return await fetchDiscord<{ id?: string; flags?: number }>(
|
||||
"/oauth2/applications/@me",
|
||||
normalized,
|
||||
createDiscordTimeoutFetch(fetcher, timeoutMs),
|
||||
{ retry: { attempts: 1 } },
|
||||
fetcher,
|
||||
{ retry: { attempts: 1 }, timeoutMs },
|
||||
);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function createDiscordTimeoutFetch(fetcher: typeof fetch, timeoutMs: number): typeof fetch {
|
||||
const fetchImpl = getResolvedFetch(fetcher);
|
||||
return ((input: RequestInfo | URL, init?: RequestInit) =>
|
||||
fetchWithTimeout(
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url,
|
||||
init ?? {},
|
||||
timeoutMs,
|
||||
fetchImpl,
|
||||
)) as typeof fetch;
|
||||
}
|
||||
|
||||
export function resolveDiscordPrivilegedIntentsFromFlags(
|
||||
flags: number,
|
||||
): DiscordPrivilegedIntentsSummary {
|
||||
@@ -121,6 +114,24 @@ function getResolvedFetch(fetcher: typeof fetch): typeof fetch {
|
||||
return fetchImpl;
|
||||
}
|
||||
|
||||
async function readDiscordProbeGetMeJson(
|
||||
response: Response,
|
||||
timeoutMs: number,
|
||||
): Promise<{ id?: string; username?: string }> {
|
||||
const bytes = await readResponseWithLimit(response, DISCORD_PROBE_JSON_MAX_BYTES, {
|
||||
chunkTimeoutMs: timeoutMs,
|
||||
onIdleTimeout: ({ chunkTimeoutMs }) =>
|
||||
new Error(`${DISCORD_PROBE_GET_ME_LABEL}: JSON response stalled after ${chunkTimeoutMs}ms`),
|
||||
onOverflow: ({ maxBytes }) =>
|
||||
new Error(`${DISCORD_PROBE_GET_ME_LABEL}: JSON response exceeds ${maxBytes} bytes`),
|
||||
});
|
||||
try {
|
||||
return JSON.parse(new TextDecoder().decode(bytes)) as { id?: string; username?: string };
|
||||
} catch (cause) {
|
||||
throw new Error(`${DISCORD_PROBE_GET_ME_LABEL}: malformed JSON response`, { cause });
|
||||
}
|
||||
}
|
||||
|
||||
export async function probeDiscord(
|
||||
token: string,
|
||||
timeoutMs: number,
|
||||
@@ -145,29 +156,64 @@ export async function probeDiscord(
|
||||
}
|
||||
let res: Response | undefined;
|
||||
try {
|
||||
res = await fetchWithTimeout(
|
||||
`${DISCORD_API_BASE}/users/@me`,
|
||||
{ headers: { Authorization: `Bot ${normalized}` } },
|
||||
const getMeUrl = `${DISCORD_API_BASE}/users/@me`;
|
||||
const getMeTimeout = buildTimeoutAbortSignal({
|
||||
timeoutMs,
|
||||
getResolvedFetch(fetcher),
|
||||
);
|
||||
if (!res.ok) {
|
||||
result.status = res.status;
|
||||
result.error = `getMe failed (${res.status})`;
|
||||
return { ...result, elapsedMs: Date.now() - started };
|
||||
operation: DISCORD_PROBE_GET_ME_LABEL,
|
||||
url: getMeUrl,
|
||||
});
|
||||
try {
|
||||
res = await fetchWithTimeout(
|
||||
getMeUrl,
|
||||
{
|
||||
headers: { Authorization: `Bot ${normalized}` },
|
||||
signal: getMeTimeout.signal,
|
||||
},
|
||||
timeoutMs,
|
||||
getResolvedFetch(fetcher),
|
||||
);
|
||||
if (!res.ok) {
|
||||
result.status = res.status;
|
||||
result.error = `getMe failed (${res.status})`;
|
||||
return { ...result, elapsedMs: Date.now() - started };
|
||||
}
|
||||
let json: { id?: string; username?: string };
|
||||
try {
|
||||
json = await readDiscordProbeGetMeJson(res, timeoutMs);
|
||||
} catch (error) {
|
||||
if (getMeTimeout.signal?.aborted) {
|
||||
const message = `${DISCORD_PROBE_GET_ME_LABEL}: JSON response timed out after ${timeoutMs}ms`;
|
||||
if (error instanceof Error) {
|
||||
error.message = message;
|
||||
throw error;
|
||||
}
|
||||
throw new Error(message, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
result.ok = true;
|
||||
result.bot = {
|
||||
id: json.id ?? null,
|
||||
username: json.username ?? null,
|
||||
};
|
||||
} finally {
|
||||
// The timeout must outlive header receipt so the same caller budget covers body reads.
|
||||
getMeTimeout.cleanup();
|
||||
}
|
||||
const json = await readProviderJsonResponse<{ id?: string; username?: string }>(
|
||||
res,
|
||||
"discord.probe.getMe",
|
||||
);
|
||||
result.ok = true;
|
||||
result.bot = {
|
||||
id: json.id ?? null,
|
||||
username: json.username ?? null,
|
||||
};
|
||||
if (includeApplication) {
|
||||
result.application =
|
||||
(await fetchDiscordApplicationSummary(normalized, timeoutMs, fetcher)) ?? undefined;
|
||||
// Application metadata is optional. Keep its deadline inside the outer status budget so a
|
||||
// stalled secondary response cannot discard the already-resolved bot identity.
|
||||
const elapsedMs = Math.max(0, Date.now() - started);
|
||||
const completionReserveMs = Math.min(
|
||||
DISCORD_PROBE_COMPLETION_RESERVE_MAX_MS,
|
||||
Math.max(1, Math.floor(timeoutMs / 10)),
|
||||
);
|
||||
const applicationTimeoutMs = Math.floor(timeoutMs - elapsedMs - completionReserveMs);
|
||||
if (applicationTimeoutMs > 0) {
|
||||
result.application =
|
||||
(await fetchDiscordApplicationSummary(normalized, applicationTimeoutMs, fetcher)) ??
|
||||
undefined;
|
||||
}
|
||||
}
|
||||
return { ...result, elapsedMs: Date.now() - started };
|
||||
} catch (err) {
|
||||
@@ -229,7 +275,8 @@ export async function fetchDiscordApplicationId(
|
||||
const json = await fetchDiscord<{ id?: string }>(
|
||||
"/oauth2/applications/@me",
|
||||
normalized,
|
||||
createDiscordTimeoutFetch(fetcher, timeoutMs),
|
||||
fetcher,
|
||||
{ timeoutMs },
|
||||
);
|
||||
if (json?.id) {
|
||||
return json.id;
|
||||
|
||||
Reference in New Issue
Block a user