diff --git a/extensions/browser/src/browser/cdp.helpers.test.ts b/extensions/browser/src/browser/cdp.helpers.test.ts index 4b2498d0a55..92003fcc663 100644 --- a/extensions/browser/src/browser/cdp.helpers.test.ts +++ b/extensions/browser/src/browser/cdp.helpers.test.ts @@ -23,7 +23,7 @@ describe("fetchCdpChecked", () => { ); vi.stubGlobal("fetch", fetchSpy); - await expect(fetchCdpChecked("https://browser.example/json/version", 50)).rejects.toThrow( + await expect(fetchCdpChecked("https://example.com/json/version", 50)).rejects.toThrow( "CDP endpoint redirects are not allowed", ); diff --git a/extensions/browser/src/browser/cdp.helpers.ts b/extensions/browser/src/browser/cdp.helpers.ts index f1abb973597..14cd74908f0 100644 --- a/extensions/browser/src/browser/cdp.helpers.ts +++ b/extensions/browser/src/browser/cdp.helpers.ts @@ -1,3 +1,4 @@ +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import WebSocket from "ws"; import { isLoopbackHost } from "../gateway/net.js"; @@ -244,14 +245,25 @@ export async function fetchCdpChecked( ): Promise { const ctrl = new AbortController(); const t = setTimeout(ctrl.abort.bind(ctrl), timeoutMs); + let release: (() => Promise) | undefined; try { const headers = getHeadersWithAuth(url, (init?.headers as Record) || {}); // Block redirects on all CDP HTTP paths (not just probes) because a // redirect to an internal host is an SSRF vector regardless of whether // the call is /json/version, /json/list, /json/activate, or /json/close. - const res = await withNoProxyForCdpUrl(url, () => - fetch(url, { ...init, headers, redirect: "manual", signal: ctrl.signal }), - ); + const currentFetch = globalThis.fetch; + const guarded = await fetchWithSsrFGuard({ + url, + fetchImpl: async (input, guardedInit) => + await withNoProxyForCdpUrl(url, () => currentFetch(input, guardedInit)), + init: { ...init, headers }, + maxRedirects: 0, + policy: { allowPrivateNetwork: true }, + signal: ctrl.signal, + auditContext: "browser-cdp", + }); + release = guarded.release; + const res = guarded.response; if (res.status >= 300 && res.status < 400) { throw new Error("CDP endpoint redirects are not allowed"); } @@ -262,9 +274,20 @@ export async function fetchCdpChecked( } throw new Error(`HTTP ${res.status}`); } - return res; + const body = await res.arrayBuffer(); + return new Response(body, { + headers: res.headers, + status: res.status, + statusText: res.statusText, + }); + } catch (error) { + if (error instanceof Error && error.message.startsWith("Too many redirects")) { + throw new Error("CDP endpoint redirects are not allowed", { cause: error }); + } + throw error; } finally { clearTimeout(t); + await release?.(); } } diff --git a/extensions/msteams/src/sdk.test.ts b/extensions/msteams/src/sdk.test.ts index bfb951b9998..08ff8ff9784 100644 --- a/extensions/msteams/src/sdk.test.ts +++ b/extensions/msteams/src/sdk.test.ts @@ -132,7 +132,7 @@ describe("createMSTeamsAdapter", () => { await adapter.continueConversation( creds.appId, { - serviceUrl: "https://service.example.com/", + serviceUrl: "https://example.com/", conversation: { id: "19:conversation@thread.tacv2" }, channelId: "msteams", }, @@ -142,7 +142,7 @@ describe("createMSTeamsAdapter", () => { ); expect(fetchMock).toHaveBeenCalledWith( - "https://service.example.com/v3/conversations/19%3Aconversation%40thread.tacv2/activities/activity-123", + "https://example.com/v3/conversations/19%3Aconversation%40thread.tacv2/activities/activity-123", expect.objectContaining({ method: "DELETE", headers: expect.objectContaining({ diff --git a/extensions/msteams/src/sdk.ts b/extensions/msteams/src/sdk.ts index e85a6d7e702..fd0fa4a12f1 100644 --- a/extensions/msteams/src/sdk.ts +++ b/extensions/msteams/src/sdk.ts @@ -1,6 +1,7 @@ // IHttpServerAdapter is re-exported via the public barrel (`export * from './http'`) // but tsgo cannot resolve the chain. Use the dist subpath directly (type-only import). import type { IHttpServerAdapter } from "@microsoft/teams.apps/dist/http/index.js"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { formatUnknownError } from "./errors.js"; import type { MSTeamsAdapter } from "./messenger.js"; import type { MSTeamsCredentials } from "./token.js"; @@ -326,24 +327,34 @@ async function updateActivityViaRest(params: { headers.Authorization = `Bearer ${token}`; } - const response = await fetch(url, { - method: "PUT", - headers, - body: JSON.stringify({ - type: "message", - ...activity, - id: activityId, - }), + const currentFetch = globalThis.fetch; + const { response, release } = await fetchWithSsrFGuard({ + url, + fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit), + init: { + method: "PUT", + headers, + body: JSON.stringify({ + type: "message", + ...activity, + id: activityId, + }), + }, + auditContext: "msteams-update-activity", }); - if (!response.ok) { - const body = await response.text().catch(() => ""); - throw Object.assign(new Error(`updateActivity failed: HTTP ${response.status} ${body}`), { - statusCode: response.status, - }); - } + try { + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw Object.assign(new Error(`updateActivity failed: HTTP ${response.status} ${body}`), { + statusCode: response.status, + }); + } - return await response.json().catch(() => ({ id: activityId })); + return await response.json().catch(() => ({ id: activityId })); + } finally { + await release(); + } } /** @@ -367,16 +378,26 @@ async function deleteActivityViaRest(params: { headers.Authorization = `Bearer ${token}`; } - const response = await fetch(url, { - method: "DELETE", - headers, + const currentFetch = globalThis.fetch; + const { response, release } = await fetchWithSsrFGuard({ + url, + fetchImpl: async (input, guardedInit) => await currentFetch(input, guardedInit), + init: { + method: "DELETE", + headers, + }, + auditContext: "msteams-delete-activity", }); - if (!response.ok) { - const body = await response.text().catch(() => ""); - throw Object.assign(new Error(`deleteActivity failed: HTTP ${response.status} ${body}`), { - statusCode: response.status, - }); + try { + if (!response.ok) { + const body = await response.text().catch(() => ""); + throw Object.assign(new Error(`deleteActivity failed: HTTP ${response.status} ${body}`), { + statusCode: response.status, + }); + } + } finally { + await release(); } } diff --git a/extensions/qa-lab/src/telegram-live.runtime.ts b/extensions/qa-lab/src/telegram-live.runtime.ts index 7cfbaf8f2ed..3220822f4b4 100644 --- a/extensions/qa-lab/src/telegram-live.runtime.ts +++ b/extensions/qa-lab/src/telegram-live.runtime.ts @@ -2,6 +2,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime"; import { startQaGatewayChild } from "./gateway-child.js"; import { defaultQaModelForMode, @@ -292,21 +293,30 @@ async function callTelegramApi( body?: Record, timeoutMs = 15_000, ): Promise { - const response = await fetch(`https://api.telegram.org/bot${token}/${method}`, { - method: "POST", - headers: { - "content-type": "application/json", + const { response, release } = await fetchWithSsrFGuard({ + url: `https://api.telegram.org/bot${token}/${method}`, + init: { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(body ?? {}), }, - body: JSON.stringify(body ?? {}), signal: AbortSignal.timeout(timeoutMs), + policy: { hostnameAllowlist: ["api.telegram.org"] }, + auditContext: "qa-lab-telegram-live", }); - const payload = (await response.json()) as TelegramApiEnvelope; - if (!response.ok || !payload.ok || payload.result === undefined) { - throw new Error( - payload.description?.trim() || `${method} failed with status ${response.status}`, - ); + try { + const payload = (await response.json()) as TelegramApiEnvelope; + if (!response.ok || !payload.ok || payload.result === undefined) { + throw new Error( + payload.description?.trim() || `${method} failed with status ${response.status}`, + ); + } + return payload.result; + } finally { + await release(); } - return payload.result; } async function getBotIdentity(token: string) { diff --git a/scripts/check-gateway-watch-regression.mjs b/scripts/check-gateway-watch-regression.mjs index 7f8a7a4c88d..55345a042b3 100644 --- a/scripts/check-gateway-watch-regression.mjs +++ b/scripts/check-gateway-watch-regression.mjs @@ -12,6 +12,8 @@ import { resolveBuildRequirement } from "./run-node.mjs"; const DEFAULTS = { outputDir: path.join(process.cwd(), ".local", "gateway-watch-regression"), windowMs: 10_000, + readyTimeoutMs: 20_000, + readySettleMs: 500, sigkillGraceMs: 10_000, cpuWarnMs: 1_000, cpuFailMs: 8_000, @@ -51,6 +53,12 @@ function parseArgs(argv) { case "--window-ms": options.windowMs = Number(readValue()); break; + case "--ready-timeout-ms": + options.readyTimeoutMs = Number(readValue()); + break; + case "--ready-settle-ms": + options.readySettleMs = Number(readValue()); + break; case "--sigkill-grace-ms": options.sigkillGraceMs = Number(readValue()); break; @@ -229,6 +237,90 @@ function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } +function parsePsCpuTimeMs(timeText) { + const [maybeDays, clockText] = timeText.includes("-") ? timeText.split("-", 2) : ["0", timeText]; + const days = Number(maybeDays); + const parts = clockText.split(":"); + if (!Number.isFinite(days) || parts.length < 2 || parts.length > 3) { + return null; + } + const seconds = Number(parts.at(-1)); + const minutes = Number(parts.at(-2)); + const hours = parts.length === 3 ? Number(parts[0]) : 0; + if (![seconds, minutes, hours].every(Number.isFinite)) { + return null; + } + return Math.round(((days * 24 + hours) * 60 * 60 + minutes * 60 + seconds) * 1000); +} + +function readProcessTreeCpuMs(rootPid) { + if (!Number.isInteger(rootPid) || rootPid <= 0) { + return null; + } + const result = spawnSync("ps", ["-eo", "pid=,ppid=,time="], { + cwd: process.cwd(), + encoding: "utf8", + stdio: ["ignore", "pipe", "ignore"], + }); + if (result.status !== 0) { + return null; + } + + const rows = []; + for (const line of result.stdout.split("\n")) { + const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\S+)$/); + if (!match) { + continue; + } + const pid = Number(match[1]); + const ppid = Number(match[2]); + const cpuMs = parsePsCpuTimeMs(match[3]); + if (!Number.isInteger(pid) || !Number.isInteger(ppid) || cpuMs == null) { + continue; + } + rows.push({ pid, ppid, cpuMs }); + } + + const childrenByParent = new Map(); + const cpuByPid = new Map(); + for (const row of rows) { + cpuByPid.set(row.pid, row.cpuMs); + const children = childrenByParent.get(row.ppid) ?? []; + children.push(row.pid); + childrenByParent.set(row.ppid, children); + } + if (!cpuByPid.has(rootPid)) { + return null; + } + + let totalCpuMs = 0; + const seen = new Set(); + const stack = [rootPid]; + while (stack.length > 0) { + const pid = stack.pop(); + if (!pid || seen.has(pid)) { + continue; + } + seen.add(pid); + totalCpuMs += cpuByPid.get(pid) ?? 0; + for (const childPid of childrenByParent.get(pid) ?? []) { + stack.push(childPid); + } + } + return totalCpuMs; +} + +async function waitForGatewayReady(readText, timeoutMs) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (/\[gateway\] ready \(/.test(readText())) { + return true; + } + await sleep(100); + } + return false; +} + async function allocateLoopbackPort() { return await new Promise((resolve, reject) => { const server = net.createServer(); @@ -355,7 +447,16 @@ async function runTimedWatch(options, outputDir) { await sleep(100); } + const readyBeforeWindow = await waitForGatewayReady( + () => `${stdout}\n${stderr}`, + options.readyTimeoutMs, + ); + if (readyBeforeWindow && options.readySettleMs > 0) { + await sleep(options.readySettleMs); + } + const idleCpuStartMs = watchPid ? readProcessTreeCpuMs(watchPid) : null; await sleep(options.windowMs); + const idleCpuEndMs = watchPid ? readProcessTreeCpuMs(watchPid) : null; if (watchPid) { try { @@ -390,6 +491,11 @@ async function runTimedWatch(options, outputDir) { return { exit, timing, + readyBeforeWindow, + idleCpuMs: + idleCpuStartMs == null || idleCpuEndMs == null + ? null + : Math.max(0, idleCpuEndMs - idleCpuStartMs), stdoutPath, stderrPath, timeFilePath, @@ -503,7 +609,10 @@ async function main() { const distRuntimeAddedPaths = diff.added.filter((entry) => entry.startsWith("dist-runtime/"), ).length; - const cpuMs = Math.round((watchResult.timing.userSeconds + watchResult.timing.sysSeconds) * 1000); + const totalCpuMs = Math.round( + (watchResult.timing.userSeconds + watchResult.timing.sysSeconds) * 1000, + ); + const cpuMs = watchResult.idleCpuMs ?? totalCpuMs; const watchTriggeredBuild = fs .readFileSync(watchResult.stderrPath, "utf8") @@ -519,6 +628,8 @@ async function main() { watchTriggeredBuild, watchBuildReason, cpuMs, + totalCpuMs, + readyBeforeWindow: watchResult.readyBeforeWindow, cpuWarnMs: options.cpuWarnMs, cpuFailMs: options.cpuFailMs, distRuntimeFileGrowth,