fix(qqbot): guard image-size probe against SSRF (#63495)
* fix(qqbot): replace raw fetch in image-size probe with SSRF-guarded fetchRemoteMedia Replace the bare fetch() in getImageSizeFromUrl() with fetchRemoteMedia() from the plugin SDK, closing the blind SSRF via markdown image dimension probing (GHSA-2767-2q9v-9326). fetchRemoteMedia options: maxBytes 65536, maxRedirects 0, generic public-network-only SSRF policy (no hostname allowlist, blocks private/reserved/loopback/link-local/metadata IPs after DNS resolution). Also fixes the repo-root resolution in scripts/lib/ts-guard-utils.mjs which caused lint:tmp:no-raw-channel-fetch to miss extension files entirely. The guard now walks up to .git instead of hardcoding two parent traversals, and the allowlist is refreshed with all pre-existing raw fetch callsites that became visible. * fix(qqbot): guard image-size probe against SSRF (#63495) (thanks @dims) --------- Co-authored-by: sliverp <870080352@qq.com>
This commit is contained in:
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
|
||||
- WhatsApp/outbound queue: drain queued WhatsApp deliveries when the listener reconnects without dropping reconnect-delayed sends after a special TTL or rewriting retry history, so disconnect-window outbound messages can recover once the channel is ready again. (#46299) Thanks @manuel-claw.
|
||||
- Tools/web_fetch: add an opt-in `tools.web.fetch.ssrfPolicy.allowRfc2544BenchmarkRange` config so fake-IP proxy environments that resolve public sites into `198.18.0.0/15` can use `web_fetch` without weakening the default SSRF block. (#61830) Thanks @xing-xing-coder.
|
||||
- Config validation: surface the actual offending field for strict-schema union failures in bindings, including top-level unexpected keys on the matching ACP branch. (#40841) Thanks @Hollychou924.
|
||||
- QQBot/security: replace raw `fetch()` in the image-size probe with SSRF-guarded `fetchRemoteMedia`, fix `resolveRepoRoot()` to walk up to `.git` instead of hardcoding two parent levels, and refresh the raw-fetch allowlist to match the corrected scan. (#63495) Thanks @dims.
|
||||
|
||||
## 2026.4.9
|
||||
|
||||
|
||||
@@ -48,10 +48,17 @@ vi.mock("./runtime.js", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/image-size.js", () => ({
|
||||
const imageSizeMocks = vi.hoisted(() => ({
|
||||
getImageSize: vi.fn(),
|
||||
formatQQBotMarkdownImage: vi.fn((url: string) => ``),
|
||||
hasQQBotImageSize: vi.fn(() => false),
|
||||
formatQQBotMarkdownImage: vi.fn(),
|
||||
hasQQBotImageSize: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./utils/image-size.js", () => ({
|
||||
getImageSize: (...args: unknown[]) => imageSizeMocks.getImageSize(...args),
|
||||
formatQQBotMarkdownImage: (...args: unknown[]) =>
|
||||
imageSizeMocks.formatQQBotMarkdownImage(...args),
|
||||
hasQQBotImageSize: (...args: unknown[]) => imageSizeMocks.hasQQBotImageSize(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
@@ -95,6 +102,9 @@ describe("qqbot outbound deliver", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
runtimeMocks.chunkMarkdownText.mockImplementation((text: string) => [text]);
|
||||
imageSizeMocks.getImageSize.mockResolvedValue(null);
|
||||
imageSizeMocks.formatQQBotMarkdownImage.mockImplementation((url: string) => ``);
|
||||
imageSizeMocks.hasQQBotImageSize.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("sends plain replies through the shared text chunk sender", async () => {
|
||||
@@ -168,4 +178,51 @@ describe("qqbot outbound deliver", () => {
|
||||
);
|
||||
expect(outboundMocks.sendPhoto).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
describe("private-network image URL degradation", () => {
|
||||
it("sends markdown reply with fallback dimensions when getImageSize returns null", async () => {
|
||||
imageSizeMocks.getImageSize.mockResolvedValue(null);
|
||||
|
||||
await sendPlainReply(
|
||||
{},
|
||||
"Look at this: ",
|
||||
buildEvent(),
|
||||
buildAccountContext(true),
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
[],
|
||||
);
|
||||
|
||||
// getImageSize was called with the private-network URL
|
||||
expect(imageSizeMocks.getImageSize).toHaveBeenCalledWith("https://10.0.0.1/internal.png");
|
||||
// formatQQBotMarkdownImage was called with null size (triggers default dimensions)
|
||||
expect(imageSizeMocks.formatQQBotMarkdownImage).toHaveBeenCalledWith(
|
||||
"https://10.0.0.1/internal.png",
|
||||
null,
|
||||
);
|
||||
// Message was still sent (not crashed)
|
||||
expect(apiMocks.sendC2CMessage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends markdown reply with fallback when getImageSize throws", async () => {
|
||||
imageSizeMocks.getImageSize.mockRejectedValue(new Error("SSRF blocked"));
|
||||
|
||||
await sendPlainReply(
|
||||
{},
|
||||
"Check ",
|
||||
buildEvent(),
|
||||
buildAccountContext(true),
|
||||
sendWithRetry,
|
||||
consumeQuoteRef,
|
||||
[],
|
||||
);
|
||||
|
||||
// formatQQBotMarkdownImage still called with null (catch path in outbound-deliver)
|
||||
expect(imageSizeMocks.formatQQBotMarkdownImage).toHaveBeenCalledWith(
|
||||
"https://169.254.169.254/latest/meta-data/",
|
||||
null,
|
||||
);
|
||||
expect(apiMocks.sendC2CMessage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
import { Buffer } from "buffer";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mediaRuntimeMocks = vi.hoisted(() => ({
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/media-runtime", () => ({
|
||||
fetchRemoteMedia: (...args: unknown[]) => mediaRuntimeMocks.fetchRemoteMedia(...args),
|
||||
}));
|
||||
|
||||
import { getImageSizeFromUrl, parseImageSize } from "./image-size.js";
|
||||
|
||||
/** Build a minimal valid PNG header with the given dimensions. */
|
||||
function buildPngHeader(width: number, height: number): Buffer {
|
||||
const buf = Buffer.alloc(24);
|
||||
// PNG signature
|
||||
buf[0] = 0x89;
|
||||
buf[1] = 0x50;
|
||||
buf[2] = 0x4e;
|
||||
buf[3] = 0x47;
|
||||
buf[4] = 0x0d;
|
||||
buf[5] = 0x0a;
|
||||
buf[6] = 0x1a;
|
||||
buf[7] = 0x0a;
|
||||
// IHDR chunk length
|
||||
buf.writeUInt32BE(13, 8);
|
||||
// "IHDR"
|
||||
buf.write("IHDR", 12, "ascii");
|
||||
// Width and height
|
||||
buf.writeUInt32BE(width, 16);
|
||||
buf.writeUInt32BE(height, 20);
|
||||
return buf;
|
||||
}
|
||||
|
||||
describe("getImageSizeFromUrl", () => {
|
||||
beforeEach(() => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockReset();
|
||||
});
|
||||
|
||||
describe("fetchRemoteMedia options contract", () => {
|
||||
it("passes maxBytes, maxRedirects, ssrfPolicy, and headers", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: buildPngHeader(800, 600),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
await getImageSizeFromUrl("https://cdn.example.com/photo.png");
|
||||
|
||||
expect(mediaRuntimeMocks.fetchRemoteMedia).toHaveBeenCalledOnce();
|
||||
const opts = mediaRuntimeMocks.fetchRemoteMedia.mock.calls[0][0];
|
||||
|
||||
expect(opts.url).toBe("https://cdn.example.com/photo.png");
|
||||
expect(opts.maxBytes).toBe(65_536);
|
||||
expect(opts.maxRedirects).toBe(0);
|
||||
// Generic public-network-only policy: no hostname allowlist
|
||||
expect(opts.ssrfPolicy).toEqual({});
|
||||
expect(opts.requestInit.headers).toEqual({
|
||||
Range: "bytes=0-65535",
|
||||
"User-Agent": "QQBot-Image-Size-Detector/1.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("threads caller abort signal through requestInit", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: buildPngHeader(100, 100),
|
||||
});
|
||||
|
||||
await getImageSizeFromUrl("https://cdn.example.com/img.png", 3000);
|
||||
|
||||
const opts = mediaRuntimeMocks.fetchRemoteMedia.mock.calls[0][0];
|
||||
expect(opts.requestInit.signal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
});
|
||||
|
||||
describe("SSRF blocking (fetchRemoteMedia rejects)", () => {
|
||||
it("returns null when fetchRemoteMedia throws for loopback", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(
|
||||
new Error("SSRF blocked: loopback address"),
|
||||
);
|
||||
|
||||
const result = await getImageSizeFromUrl("https://127.0.0.1/img.png");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when fetchRemoteMedia throws for IPv6 loopback", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(
|
||||
new Error("SSRF blocked: loopback address"),
|
||||
);
|
||||
|
||||
const result = await getImageSizeFromUrl("https://[::1]/img.png");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when fetchRemoteMedia throws for link-local/metadata", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(
|
||||
new Error("SSRF blocked: link-local address"),
|
||||
);
|
||||
|
||||
const result = await getImageSizeFromUrl("https://169.254.169.254/latest/meta-data/");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when fetchRemoteMedia throws for RFC1918 addresses", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(
|
||||
new Error("SSRF blocked: private address"),
|
||||
);
|
||||
|
||||
const result = await getImageSizeFromUrl("https://10.0.0.1/img.png");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null on http error from fetchRemoteMedia", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockRejectedValueOnce(new Error("HTTP 403 Forbidden"));
|
||||
|
||||
const result = await getImageSizeFromUrl("https://cdn.example.com/forbidden.png");
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("happy path", () => {
|
||||
it("returns parsed dimensions for a valid PNG", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: buildPngHeader(1920, 1080),
|
||||
contentType: "image/png",
|
||||
});
|
||||
|
||||
const size = await getImageSizeFromUrl("https://cdn.example.com/banner.png");
|
||||
|
||||
expect(size).toEqual({ width: 1920, height: 1080 });
|
||||
});
|
||||
|
||||
it("returns null when the buffer is not a recognized image format", async () => {
|
||||
mediaRuntimeMocks.fetchRemoteMedia.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("not an image"),
|
||||
contentType: "text/html",
|
||||
});
|
||||
|
||||
const size = await getImageSizeFromUrl("https://cdn.example.com/notimage.html");
|
||||
|
||||
expect(size).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseImageSize", () => {
|
||||
it("parses PNG dimensions", () => {
|
||||
const size = parseImageSize(buildPngHeader(640, 480));
|
||||
expect(size).toEqual({ width: 640, height: 480 });
|
||||
});
|
||||
|
||||
it("returns null for unrecognized data", () => {
|
||||
expect(parseImageSize(Buffer.from("hello"))).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null for empty buffer", () => {
|
||||
expect(parseImageSize(Buffer.alloc(0))).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,8 @@
|
||||
*/
|
||||
|
||||
import { Buffer } from "buffer";
|
||||
import { fetchRemoteMedia } from "openclaw/plugin-sdk/media-runtime";
|
||||
import type { SsrFPolicy } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { debugLog } from "./debug-log.js";
|
||||
|
||||
export interface ImageSize {
|
||||
@@ -143,8 +145,18 @@ export function parseImageSize(buffer: Buffer): ImageSize | null {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* SSRF policy for image-dimension probing. Generic public-network-only blocking
|
||||
* (no hostname allowlist) because markdown image URLs can legitimately point to
|
||||
* any public host, not just QQ-owned CDNs.
|
||||
*/
|
||||
const IMAGE_PROBE_SSRF_POLICY: SsrFPolicy = {};
|
||||
|
||||
/**
|
||||
* Fetch image dimensions from a public URL using only the first 64 KB.
|
||||
*
|
||||
* Uses {@link fetchRemoteMedia} with SSRF guard to block probes against
|
||||
* private/reserved/loopback/link-local/metadata destinations.
|
||||
*/
|
||||
export async function getImageSizeFromUrl(
|
||||
url: string,
|
||||
@@ -154,33 +166,31 @@ export async function getImageSizeFromUrl(
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
// Request only the first 64 KB, which is enough for common headers.
|
||||
const response = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Range: "bytes=0-65535",
|
||||
"User-Agent": "QQBot-Image-Size-Detector/1.0",
|
||||
},
|
||||
});
|
||||
try {
|
||||
const { buffer } = await fetchRemoteMedia({
|
||||
url,
|
||||
maxBytes: 65_536,
|
||||
maxRedirects: 0,
|
||||
ssrfPolicy: IMAGE_PROBE_SSRF_POLICY,
|
||||
requestInit: {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
Range: "bytes=0-65535",
|
||||
"User-Agent": "QQBot-Image-Size-Detector/1.0",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok && response.status !== 206) {
|
||||
debugLog(`[image-size] Failed to fetch ${url}: ${response.status}`);
|
||||
return null;
|
||||
const size = parseImageSize(buffer);
|
||||
if (size) {
|
||||
debugLog(
|
||||
`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`,
|
||||
);
|
||||
}
|
||||
return size;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const buffer = Buffer.from(arrayBuffer);
|
||||
|
||||
const size = parseImageSize(buffer);
|
||||
if (size) {
|
||||
debugLog(
|
||||
`[image-size] Got size from URL: ${size.width}x${size.height} - ${url.slice(0, 60)}...`,
|
||||
);
|
||||
}
|
||||
|
||||
return size;
|
||||
} catch (err) {
|
||||
debugLog(`[image-size] Error fetching ${url.slice(0, 60)}...: ${String(err)}`);
|
||||
return null;
|
||||
|
||||
@@ -14,41 +14,61 @@ const sourceRoots = ["src/channels", "src/routing", "src/line", "extensions"];
|
||||
// Temporary allowlist for legacy callsites. New raw fetch callsites in channel/plugin runtime
|
||||
// code should be rejected and migrated to fetchWithSsrFGuard/shared channel helpers.
|
||||
const allowedRawFetchCallsites = new Set([
|
||||
bundledPluginCallsite("bluebubbles", "src/types.ts", 133),
|
||||
bundledPluginCallsite("feishu", "src/streaming-card.ts", 31),
|
||||
bundledPluginCallsite("feishu", "src/streaming-card.ts", 101),
|
||||
bundledPluginCallsite("feishu", "src/streaming-card.ts", 143),
|
||||
bundledPluginCallsite("feishu", "src/streaming-card.ts", 199),
|
||||
bundledPluginCallsite("googlechat", "src/api.ts", 22),
|
||||
bundledPluginCallsite("googlechat", "src/api.ts", 43),
|
||||
bundledPluginCallsite("googlechat", "src/api.ts", 63),
|
||||
bundledPluginCallsite("googlechat", "src/api.ts", 188),
|
||||
bundledPluginCallsite("googlechat", "src/auth.ts", 82),
|
||||
bundledPluginCallsite("matrix", "src/directory-live.ts", 41),
|
||||
bundledPluginCallsite("matrix", "src/matrix/client/config.ts", 171),
|
||||
bundledPluginCallsite("mattermost", "src/mattermost/client.ts", 211),
|
||||
bundledPluginCallsite("mattermost", "src/mattermost/monitor.ts", 230),
|
||||
bundledPluginCallsite("mattermost", "src/mattermost/probe.ts", 27),
|
||||
bundledPluginCallsite("minimax", "oauth.ts", 62),
|
||||
bundledPluginCallsite("minimax", "oauth.ts", 93),
|
||||
bundledPluginCallsite("msteams", "src/graph.ts", 39),
|
||||
bundledPluginCallsite("nextcloud-talk", "src/room-info.ts", 92),
|
||||
bundledPluginCallsite("nextcloud-talk", "src/send.ts", 107),
|
||||
bundledPluginCallsite("nextcloud-talk", "src/send.ts", 198),
|
||||
bundledPluginCallsite("talk-voice", "index.ts", 27),
|
||||
bundledPluginCallsite("thread-ownership", "index.ts", 105),
|
||||
bundledPluginCallsite("voice-call", "src/providers/plivo.ts", 95),
|
||||
bundledPluginCallsite("voice-call", "src/providers/telnyx.ts", 61),
|
||||
bundledPluginCallsite("voice-call", "src/providers/tts-openai.ts", 111),
|
||||
bundledPluginCallsite("bluebubbles", "src/test-harness.ts", 128),
|
||||
bundledPluginCallsite("bluebubbles", "src/types.ts", 181),
|
||||
bundledPluginCallsite("browser", "src/browser/cdp.helpers.ts", 235),
|
||||
bundledPluginCallsite("browser", "src/browser/client-fetch.ts", 217),
|
||||
bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 24),
|
||||
bundledPluginCallsite("browser", "src/browser/test-fetch.ts", 27),
|
||||
bundledPluginCallsite("chutes", "models.ts", 535),
|
||||
bundledPluginCallsite("chutes", "models.ts", 542),
|
||||
bundledPluginCallsite("discord", "src/monitor/gateway-plugin.ts", 322),
|
||||
bundledPluginCallsite("discord", "src/monitor/gateway-plugin.ts", 360),
|
||||
bundledPluginCallsite("discord", "src/voice-message.ts", 298),
|
||||
bundledPluginCallsite("discord", "src/voice-message.ts", 333),
|
||||
bundledPluginCallsite("elevenlabs", "speech-provider.ts", 295),
|
||||
bundledPluginCallsite("elevenlabs", "tts.ts", 116),
|
||||
bundledPluginCallsite("feishu", "src/monitor.webhook.test-helpers.ts", 25),
|
||||
bundledPluginCallsite("github-copilot", "login.ts", 48),
|
||||
bundledPluginCallsite("github-copilot", "login.ts", 80),
|
||||
bundledPluginCallsite("googlechat", "src/auth.ts", 83),
|
||||
bundledPluginCallsite("huggingface", "models.ts", 142),
|
||||
bundledPluginCallsite("kilocode", "provider-models.ts", 130),
|
||||
bundledPluginCallsite("matrix", "src/matrix/sdk/transport.ts", 112),
|
||||
bundledPluginCallsite("microsoft-foundry", "onboard.ts", 479),
|
||||
bundledPluginCallsite("microsoft", "speech-provider.ts", 132),
|
||||
bundledPluginCallsite("minimax", "oauth.ts", 66),
|
||||
bundledPluginCallsite("minimax", "oauth.ts", 107),
|
||||
bundledPluginCallsite("minimax", "tts.ts", 52),
|
||||
bundledPluginCallsite("msteams", "src/graph.ts", 47),
|
||||
bundledPluginCallsite("msteams", "src/sdk.ts", 292),
|
||||
bundledPluginCallsite("msteams", "src/sdk.ts", 333),
|
||||
bundledPluginCallsite("ollama", "src/stream.ts", 649),
|
||||
bundledPluginCallsite("openai", "tts.ts", 133),
|
||||
bundledPluginCallsite("qa-channel", "src/bus-client.ts", 41),
|
||||
bundledPluginCallsite("qa-channel", "src/bus-client.ts", 221),
|
||||
bundledPluginCallsite("qa-lab", "src/docker-up.runtime.ts", 274),
|
||||
bundledPluginCallsite("qa-lab", "src/gateway-child.ts", 488),
|
||||
bundledPluginCallsite("qa-lab", "src/suite.ts", 330),
|
||||
bundledPluginCallsite("qa-lab", "src/suite.ts", 341),
|
||||
bundledPluginCallsite("qa-lab", "web/src/app.ts", 15),
|
||||
bundledPluginCallsite("qa-lab", "web/src/app.ts", 23),
|
||||
bundledPluginCallsite("qa-lab", "web/src/app.ts", 31),
|
||||
bundledPluginCallsite("qqbot", "src/api.ts", 102),
|
||||
bundledPluginCallsite("qqbot", "src/api.ts", 237),
|
||||
bundledPluginCallsite("qqbot", "src/stt.ts", 81),
|
||||
bundledPluginCallsite("qqbot", "src/tools/channel.ts", 180),
|
||||
bundledPluginCallsite("qqbot", "src/utils/audio-convert.ts", 377),
|
||||
bundledPluginCallsite("signal", "src/install-signal-cli.ts", 224),
|
||||
bundledPluginCallsite("slack", "src/monitor/media.ts", 96),
|
||||
bundledPluginCallsite("slack", "src/monitor/media.ts", 115),
|
||||
bundledPluginCallsite("slack", "src/monitor/media.ts", 120),
|
||||
bundledPluginCallsite("tlon", "src/tlon-api.ts", 185),
|
||||
bundledPluginCallsite("tlon", "src/tlon-api.ts", 235),
|
||||
bundledPluginCallsite("tlon", "src/tlon-api.ts", 289),
|
||||
bundledPluginCallsite("venice", "models.ts", 552),
|
||||
bundledPluginCallsite("vercel-ai-gateway", "models.ts", 181),
|
||||
bundledPluginCallsite("voice-call", "src/providers/twilio/api.ts", 23),
|
||||
bundledPluginCallsite("telegram", "src/api-fetch.ts", 8),
|
||||
bundledPluginCallsite("discord", "src/send.outbound.ts", 363),
|
||||
bundledPluginCallsite("discord", "src/voice-message.ts", 268),
|
||||
bundledPluginCallsite("discord", "src/voice-message.ts", 312),
|
||||
bundledPluginCallsite("slack", "src/monitor/media.ts", 55),
|
||||
bundledPluginCallsite("slack", "src/monitor/media.ts", 59),
|
||||
bundledPluginCallsite("slack", "src/monitor/media.ts", 73),
|
||||
bundledPluginCallsite("slack", "src/monitor/media.ts", 99),
|
||||
]);
|
||||
|
||||
function isRawFetchCall(expression) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { promises as fs } from "node:fs";
|
||||
import { existsSync, promises as fs } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
@@ -14,6 +14,18 @@ function getTypeScript() {
|
||||
const baseTestSuffixes = [".test.ts", ".test-utils.ts", ".test-harness.ts", ".e2e-harness.ts"];
|
||||
|
||||
export function resolveRepoRoot(importMetaUrl) {
|
||||
// Walk up from the caller's directory until we find the repo root (.git).
|
||||
// This handles callers at any depth (scripts/*.mjs, scripts/lib/*.mjs, etc.)
|
||||
// instead of assuming a fixed number of parent traversals.
|
||||
let dir = path.dirname(fileURLToPath(importMetaUrl));
|
||||
const { root } = path.parse(dir);
|
||||
while (dir !== root) {
|
||||
if (existsSync(path.join(dir, ".git"))) {
|
||||
return dir;
|
||||
}
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
// Fallback: two levels up (original behavior).
|
||||
return path.resolve(path.dirname(fileURLToPath(importMetaUrl)), "..", "..");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveRepoRoot } from "../../scripts/lib/ts-guard-utils.mjs";
|
||||
|
||||
/**
|
||||
* Regression tests for resolveRepoRoot().
|
||||
*
|
||||
* The original implementation went up exactly two levels from the caller's
|
||||
* import.meta.url, which broke for scripts at scripts/*.mjs (one level below
|
||||
* root) — it overshot to the repo's parent directory.
|
||||
*/
|
||||
describe("resolveRepoRoot", () => {
|
||||
it("resolves correctly from a scripts/lib/*.mjs path (two levels below root)", () => {
|
||||
const fakeUrl = pathToFileURL(path.resolve("scripts", "lib", "some-guard-utils.mjs")).href;
|
||||
const root = resolveRepoRoot(fakeUrl);
|
||||
|
||||
expect(existsSync(path.join(root, ".git"))).toBe(true);
|
||||
expect(existsSync(path.join(root, "package.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves correctly from a scripts/*.mjs path (one level below root)", () => {
|
||||
const fakeUrl = pathToFileURL(path.resolve("scripts", "check-no-raw-channel-fetch.mjs")).href;
|
||||
const root = resolveRepoRoot(fakeUrl);
|
||||
|
||||
expect(existsSync(path.join(root, ".git"))).toBe(true);
|
||||
expect(existsSync(path.join(root, "package.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("resolves correctly from a deeply nested extension path", () => {
|
||||
const fakeUrl = pathToFileURL(
|
||||
path.resolve("extensions", "qqbot", "src", "utils", "hypothetical.mjs"),
|
||||
).href;
|
||||
const root = resolveRepoRoot(fakeUrl);
|
||||
|
||||
expect(existsSync(path.join(root, ".git"))).toBe(true);
|
||||
expect(existsSync(path.join(root, "package.json"))).toBe(true);
|
||||
});
|
||||
|
||||
it("all caller depths resolve to the same root", () => {
|
||||
const fromLib = resolveRepoRoot(pathToFileURL(path.resolve("scripts", "lib", "a.mjs")).href);
|
||||
const fromScripts = resolveRepoRoot(pathToFileURL(path.resolve("scripts", "b.mjs")).href);
|
||||
const fromExtension = resolveRepoRoot(
|
||||
pathToFileURL(path.resolve("extensions", "qqbot", "c.mjs")).href,
|
||||
);
|
||||
|
||||
expect(fromLib).toBe(fromScripts);
|
||||
expect(fromScripts).toBe(fromExtension);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user