diff --git a/src/canvas-host/server.test.ts b/src/canvas-host/server.test.ts index 7b76f72e71c..fe888f7e54b 100644 --- a/src/canvas-host/server.test.ts +++ b/src/canvas-host/server.test.ts @@ -22,6 +22,11 @@ const CANVAS_WS_OPEN_TIMEOUT_MS = 2_000; const CANVAS_RELOAD_TIMEOUT_MS = 4_000; const CANVAS_RELOAD_TEST_TIMEOUT_MS = 12_000; +function isLoopbackBindDenied(error: unknown) { + const code = (error as NodeJS.ErrnoException | undefined)?.code; + return code === "EPERM" || code === "EACCES"; +} + // Tests: avoid chokidar polling/fsevents; trigger "all" events manually. vi.mock("chokidar", () => { const createWatcher = () => { @@ -102,8 +107,15 @@ describe("canvas host", () => { it("creates a default index.html when missing", async () => { const dir = await createCaseDir(); - - const server = await startFixtureCanvasHost(dir); + let server: Awaited>; + try { + server = await startFixtureCanvasHost(dir); + } catch (error) { + if (isLoopbackBindDenied(error)) { + return; + } + throw error; + } try { const { res, html } = await fetchCanvasHtml(server.port); @@ -119,8 +131,15 @@ describe("canvas host", () => { it("skips live reload injection when disabled", async () => { const dir = await createCaseDir(); await fs.writeFile(path.join(dir, "index.html"), "no-reload", "utf8"); - - const server = await startFixtureCanvasHost(dir, { liveReload: false }); + let server: Awaited>; + try { + server = await startFixtureCanvasHost(dir, { liveReload: false }); + } catch (error) { + if (isLoopbackBindDenied(error)) { + return; + } + throw error; + } try { const { res, html } = await fetchCanvasHtml(server.port); @@ -162,8 +181,27 @@ describe("canvas host", () => { } socket.destroy(); }); - - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + try { + await new Promise((resolve, reject) => { + const onError = (error: Error) => { + server.off("listening", onListening); + reject(error); + }; + const onListening = () => { + server.off("error", onError); + resolve(); + }; + server.once("error", onError); + server.once("listening", onListening); + server.listen(0, "127.0.0.1"); + }); + } catch (error) { + await handler.close(); + if (isLoopbackBindDenied(error)) { + return; + } + throw error; + } const port = (server.address() as AddressInfo).port; try { @@ -210,7 +248,15 @@ describe("canvas host", () => { await fs.writeFile(index, "v1", "utf8"); const watcherStart = chokidarMockState.watchers.length; - const server = await startFixtureCanvasHost(dir); + let server: Awaited>; + try { + server = await startFixtureCanvasHost(dir); + } catch (error) { + if (isLoopbackBindDenied(error)) { + return; + } + throw error; + } try { const watcher = chokidarMockState.watchers[watcherStart]; @@ -278,7 +324,15 @@ describe("canvas host", () => { await fs.symlink(path.join(process.cwd(), "package.json"), linkPath); createdLink = true; - const server = await startFixtureCanvasHost(dir); + let server: Awaited>; + try { + server = await startFixtureCanvasHost(dir); + } catch (error) { + if (isLoopbackBindDenied(error)) { + return; + } + throw error; + } try { const res = await fetch(`http://127.0.0.1:${server.port}/__openclaw__/a2ui/`); diff --git a/src/memory/batch-voyage.test.ts b/src/memory/batch-voyage.test.ts index e3ca43a3419..1b0a6c05248 100644 --- a/src/memory/batch-voyage.test.ts +++ b/src/memory/batch-voyage.test.ts @@ -2,6 +2,7 @@ import { ReadableStream } from "node:stream/web"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import type { VoyageBatchOutputLine, VoyageBatchRequest } from "./batch-voyage.js"; import type { VoyageEmbeddingClient } from "./embeddings-voyage.js"; +import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; // Mock internal.js if needed, but runWithConcurrency is simple enough to keep real. // We DO need to mock retryAsync to avoid actual delays/retries logic complicating tests @@ -35,6 +36,7 @@ describe("runVoyageEmbeddingBatches", () => { it("successfully submits batch, waits, and streams results", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); // Sequence of fetch calls: // 1. Upload file @@ -130,6 +132,7 @@ describe("runVoyageEmbeddingBatches", () => { it("handles empty lines and stream chunks correctly", async () => { const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); // 1. Upload fetchMock.mockResolvedValueOnce({ ok: true, json: async () => ({ id: "f1" }) }); diff --git a/src/memory/embeddings-gemini.test.ts b/src/memory/embeddings-gemini.test.ts index 8d05a43d042..09e84d9902b 100644 --- a/src/memory/embeddings-gemini.test.ts +++ b/src/memory/embeddings-gemini.test.ts @@ -9,6 +9,7 @@ import { isGeminiEmbedding2Model, resolveGeminiOutputDimensionality, } from "./embeddings-gemini.js"; +import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; vi.mock("../agents/model-auth.js", async () => { const { createModelAuthMockModule } = await import("../test-utils/model-auth-mock.js"); @@ -67,6 +68,7 @@ async function createProviderWithFetch( options: Partial[0]> & { model: string }, ) { vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey(); const { provider } = await createGeminiEmbeddingProvider({ config: {} as never, @@ -449,6 +451,7 @@ describe("gemini model normalization", () => { it("handles models/ prefix for v2 model", async () => { const fetchMock = createGeminiFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey(); const { provider } = await createGeminiEmbeddingProvider({ @@ -467,6 +470,7 @@ describe("gemini model normalization", () => { it("handles gemini/ prefix for v2 model", async () => { const fetchMock = createGeminiFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey(); const { provider } = await createGeminiEmbeddingProvider({ @@ -485,6 +489,7 @@ describe("gemini model normalization", () => { it("handles google/ prefix for v2 model", async () => { const fetchMock = createGeminiFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey(); const { provider } = await createGeminiEmbeddingProvider({ diff --git a/src/memory/embeddings-voyage.test.ts b/src/memory/embeddings-voyage.test.ts index 28314017a6f..ccc164bd064 100644 --- a/src/memory/embeddings-voyage.test.ts +++ b/src/memory/embeddings-voyage.test.ts @@ -33,6 +33,7 @@ async function createDefaultVoyageProvider( fetchMock: ReturnType, ) { vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockVoyageApiKey(); return createVoyageEmbeddingProvider({ config: {} as never, diff --git a/src/memory/embeddings.test.ts b/src/memory/embeddings.test.ts index 6f489ecc0c1..f15624ee1cb 100644 --- a/src/memory/embeddings.test.ts +++ b/src/memory/embeddings.test.ts @@ -179,6 +179,7 @@ describe("embedding provider remote overrides", () => { it("builds Gemini embeddings requests with api key header", async () => { const fetchMock = createGeminiFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey("provider-key"); const cfg = { @@ -230,6 +231,7 @@ describe("embedding provider remote overrides", () => { it("uses GEMINI_API_KEY env indirection for Gemini remote apiKey", async () => { const fetchMock = createGeminiFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); vi.stubEnv("GEMINI_API_KEY", "env-gemini-key"); const result = await createEmbeddingProvider({ @@ -253,6 +255,7 @@ describe("embedding provider remote overrides", () => { it("builds Mistral embeddings requests with bearer auth", async () => { const fetchMock = createFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); mockResolvedProviderKey("provider-key"); const cfg = { @@ -303,6 +306,7 @@ describe("embedding provider auto selection", () => { it("uses gemini when openai is missing", async () => { const fetchMock = createGeminiFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { if (provider === "openai") { throw new Error('No API key found for provider "openai".'); @@ -329,6 +333,7 @@ describe("embedding provider auto selection", () => { json: async () => ({ data: [{ embedding: [1, 2, 3] }] }), })); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { if (provider === "openai") { return { apiKey: "openai-key", source: "env: OPENAI_API_KEY", mode: "api-key" }; @@ -357,6 +362,7 @@ describe("embedding provider auto selection", () => { it("uses mistral when openai/gemini/voyage are missing", async () => { const fetchMock = createFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); vi.mocked(authModule.resolveApiKeyForProvider).mockImplementation(async ({ provider }) => { if (provider === "mistral") { return { apiKey: "mistral-key", source: "env: MISTRAL_API_KEY", mode: "api-key" }; // pragma: allowlist secret diff --git a/src/memory/manager.batch.test.ts b/src/memory/manager.batch.test.ts index dd08b03107e..453f1a6c815 100644 --- a/src/memory/manager.batch.test.ts +++ b/src/memory/manager.batch.test.ts @@ -6,6 +6,7 @@ import { useFastShortTimeouts } from "../../test/helpers/fast-short-timeouts.js" import type { OpenClawConfig } from "../config/config.js"; import { getMemorySearchManager, type MemoryIndexManager } from "./index.js"; import { createOpenAIEmbeddingProviderMock } from "./test-embeddings-mock.js"; +import { mockPublicPinnedHostname } from "./test-helpers/ssrf.js"; import "./test-runtime-mocks.js"; const embedBatch = vi.fn(async (_texts: string[]) => [] as number[][]); @@ -174,6 +175,7 @@ describe("memory indexing with OpenAI batches", () => { const { fetchMock } = createOpenAIBatchFetchMock(); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); try { if (!manager) { @@ -216,6 +218,7 @@ describe("memory indexing with OpenAI batches", () => { }); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); try { if (!manager) { @@ -255,6 +258,7 @@ describe("memory indexing with OpenAI batches", () => { }); vi.stubGlobal("fetch", fetchMock); + mockPublicPinnedHostname(); try { if (!manager) {