diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index afd677b59..c790404fa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -8,6 +8,8 @@ ### Fixed +- Fixed built-in web search discovery so model aliases sharing a provider endpoint no longer multiply serial fallback attempts, aborting a search stops pending native authentication discovery, and dotted private host spellings are rejected before auth or fetch ([upstream #5](https://github.com/code-yeongyu/pi-websearch/pull/5), [upstream #6](https://github.com/code-yeongyu/pi-websearch/pull/6)). + ### Removed ## [2026.7.11] - 2026-07-11 diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md b/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md index dca993be9..ad8182973 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/changes.md @@ -1,6 +1,6 @@ # changes.md — websearch (vendored) -Vendored from [`code-yeongyu/pi-websearch`](https://github.com/code-yeongyu/pi-websearch) at `0b9b44e83eef46121758f22965aadf59544faccf`. +Vendored from [`code-yeongyu/pi-websearch`](https://github.com/code-yeongyu/pi-websearch) at `06e3ec457e86d299c20808954e18f20b23cc7a64`. ## Senpi adaptations vs upstream @@ -8,7 +8,7 @@ Vendored from [`code-yeongyu/pi-websearch`](https://github.com/code-yeongyu/pi-w - `@mariozechner/pi-tui` -> `@earendil-works/pi-tui` - `@mariozechner/pi-coding-agent` type/tool imports -> senpi local `../../types.ts` / `../../../types.ts` - relative `.js` import suffixes -> `.ts` -- No behavior changes versus upstream. The `web_search` tool is registered unconditionally; native provider search bypass remains upstream's provider-based check (`openai` / `anthropic`) so Anthropic-protocol third-party providers such as `kimi-coding` can still use the provider-backed tool. +- Senpi forwards the tool `AbortSignal` into native route discovery so cancellation stops waiting for pending authentication before any provider request begins, and canonicalizes one permitted terminal DNS dot in route identity so dotted and undotted aliases share one candidate. Otherwise behavior matches upstream: the `web_search` tool is registered unconditionally, and native provider search bypass remains upstream's provider-based check (`openai` / `anthropic`) so Anthropic-protocol third-party providers such as `kimi-coding` can still use the provider-backed tool. ## Conflict zones diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts index 1d8f877cb..48603228d 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/native.ts @@ -1,3 +1,5 @@ +import { createHash } from "node:crypto"; + import { isAllowedProviderBaseUrl } from "./provider-endpoints.ts"; import type { SearchProvider, SearchProviderEntry } from "./types.ts"; @@ -21,6 +23,11 @@ interface NativeProviderMapping { resource: string; } +interface NativeEntryOptions { + id?: string; + signal?: AbortSignal; +} + function nativeMapping(model: NativeModelInfo): NativeProviderMapping | null { if ( model.provider === "openai" && @@ -65,26 +72,72 @@ function nativeMapping(model: NativeModelInfo): NativeProviderMapping | null { } function buildEndpointUrl(baseUrl: string, resource: string): string { - const trimmed = baseUrl.replace(/\/+$/, ""); + let configured: URL; + try { + configured = new URL(baseUrl); + } catch { + return baseUrl; + } + const trimmedPath = configured.pathname.replace(/\/+$/, ""); const resourceSlash = `/${resource}`; - if (trimmed.endsWith(resourceSlash)) return trimmed; - if (/\/v\d+$/.test(trimmed)) return `${trimmed}${resourceSlash}`; - return `${trimmed}/v1${resourceSlash}`; + if (trimmedPath.endsWith(resourceSlash)) { + configured.pathname = trimmedPath; + } else if (/\/v\d+$/.test(trimmedPath)) { + configured.pathname = `${trimmedPath}${resourceSlash}`; + } else { + configured.pathname = `${trimmedPath}/v1${resourceSlash}`; + } + configured.hash = ""; + return configured.href; } -export async function buildNativeEntry( +function nativeRouteKey(model: NativeModelInfo): string | null { + const mapping = nativeMapping(model); + if (!mapping) return null; + const baseUrl = buildEndpointUrl(model.baseUrl, mapping.resource); + if (!isAllowedProviderBaseUrl(baseUrl)) return null; + const routeUrl = new URL(baseUrl); + routeUrl.hostname = routeUrl.hostname.replace(/\.$/, ""); + return `${mapping.provider}|${routeUrl.href}`; +} + +function discoveredNativeEntryId(provider: SearchProvider, routeKey: string): string { + const routeFingerprint = createHash("sha256").update(routeKey).digest("hex").slice(0, 16); + return `native-${provider}-${routeFingerprint}`; +} + +async function buildNativeEntryForModel( model: NativeModelInfo | undefined, modelRegistry: NativeModelRegistry | undefined, - id = "native", + options: NativeEntryOptions = {}, ): Promise { if (!model || !modelRegistry) return null; + const { id = "native", signal } = options; const mapping = nativeMapping(model); if (!mapping) return null; const baseUrl = buildEndpointUrl(model.baseUrl, mapping.resource); if (!isAllowedProviderBaseUrl(baseUrl)) return null; - const auth = await modelRegistry.getApiKeyAndHeaders(model); + signal?.throwIfAborted(); + const authPromise = modelRegistry.getApiKeyAndHeaders(model); + let auth: NativeAuthResult; + if (!signal) { + auth = await authPromise; + } else { + signal.throwIfAborted(); + let removeAbortListener = (): void => {}; + const abortPromise = new Promise((_resolve, reject) => { + const onAbort = (): void => reject(signal.reason); + signal.addEventListener("abort", onAbort, { once: true }); + removeAbortListener = () => signal.removeEventListener("abort", onAbort); + }); + try { + auth = await Promise.race([authPromise, abortPromise]); + } finally { + removeAbortListener(); + } + } if (!auth.ok || !auth.apiKey) return null; return { @@ -96,43 +149,45 @@ export async function buildNativeEntry( priority: -1, }; } - -function nativeEntryKey(entry: SearchProviderEntry): string { - return `${entry.provider}:${entry.baseUrl ?? ""}:${entry.model ?? ""}`; -} - -function stableIdPart(value: string): string { - return ( - value - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, "") || "model" - ); -} - -function discoveredNativeEntryId(entry: SearchProviderEntry): string { - return `native-${entry.provider}-${stableIdPart(entry.model ?? "model")}`; -} - export async function buildNativeEntries( model: NativeModelInfo | undefined, modelRegistry: NativeModelRegistry | undefined, + signal?: AbortSignal, ): Promise { + signal?.throwIfAborted(); if (!modelRegistry) return []; const entries: SearchProviderEntry[] = []; - const activeEntry = await buildNativeEntry(model, modelRegistry); - if (activeEntry) entries.push(activeEntry); + const seenRoutes = new Set(); + + const activeRouteKey = model ? nativeRouteKey(model) : null; + if (activeRouteKey) { + seenRoutes.add(activeRouteKey); + const activeEntry = await buildNativeEntryForModel(model, modelRegistry, { signal }); + if (activeEntry) entries.push(activeEntry); + } + + if (!modelRegistry.getAvailable) return entries; - const seen = new Set(entries.map(nativeEntryKey)); - const availableModels = modelRegistry.getAvailable?.() ?? []; - for (const availableModel of availableModels) { - const entry = await buildNativeEntry(availableModel, modelRegistry, "native-discovered"); + for (const availableModel of modelRegistry.getAvailable()) { + const routeKey = nativeRouteKey(availableModel); + if (!routeKey || seenRoutes.has(routeKey)) continue; + seenRoutes.add(routeKey); + const entry = await buildNativeEntryForModel(availableModel, modelRegistry, { + id: "native-discovered", + signal, + }); if (!entry) continue; - const key = nativeEntryKey(entry); - if (seen.has(key)) continue; - seen.add(key); - entries.push({ ...entry, id: discoveredNativeEntryId(entry) }); + entries.push({ ...entry, id: discoveredNativeEntryId(entry.provider, routeKey) }); } + return entries; } + +export async function buildNativeEntry( + model: NativeModelInfo | undefined, + modelRegistry: NativeModelRegistry | undefined, + id = "native", +): Promise { + return buildNativeEntryForModel(model, modelRegistry, { id }); +} diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/provider-endpoints.ts b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/provider-endpoints.ts index 6fda53c3c..ef868f3a7 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/provider-endpoints.ts +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/provider-endpoints.ts @@ -31,7 +31,7 @@ function isPrivateIpv4(hostname: string): boolean { } function isPrivateHostname(hostname: string): boolean { - const normalized = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, ""); + const normalized = hostname.toLowerCase().replace(/^\[/, "").replace(/\]$/, "").replace(/\.$/, ""); return ( normalized === "localhost" || normalized.endsWith(".localhost") || @@ -55,6 +55,7 @@ export function isAllowedProviderBaseUrl(baseUrl: string): boolean { configured.protocol === "https:" && configured.username === "" && configured.password === "" && + !configured.hostname.endsWith("..") && !isPrivateHostname(configured.hostname) ); } diff --git a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/tool.ts b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/tool.ts index 0e9873089..fb686a312 100644 --- a/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/tool.ts +++ b/packages/coding-agent/src/core/extensions/builtin/websearch/websearch/tool.ts @@ -34,9 +34,13 @@ interface WebSearchToolContext { modelRegistry: NativeModelRegistry; } -async function configWithNativeRoute(config: WebsearchConfig, ctx?: WebSearchToolContext): Promise { +async function configWithNativeRoute( + config: WebsearchConfig, + ctx: WebSearchToolContext | undefined, + signal: AbortSignal | undefined, +): Promise { if (!config.auto) return config; - const nativeEntries = await buildNativeEntries(ctx?.model, ctx?.modelRegistry); + const nativeEntries = await buildNativeEntries(ctx?.model, ctx?.modelRegistry, signal); return nativeEntries.length > 0 ? { ...config, providers: [...nativeEntries, ...config.providers] } : config; } @@ -78,7 +82,7 @@ export function createWebSearchTool(getConfig: ConfigProvider): WebSearchTool { } const maxResults = loaded.config.providers[0]?.maxResults ?? 10; - const config = await configWithNativeRoute(loaded.config, ctx); + const config = await configWithNativeRoute(loaded.config, ctx, signal); const progressDetails: SearchProgressDetails = { phase: "searching", query: params.query, diff --git a/packages/coding-agent/test/websearch-native-route-dedup.test.ts b/packages/coding-agent/test/websearch-native-route-dedup.test.ts new file mode 100644 index 000000000..1bb1d8e46 --- /dev/null +++ b/packages/coding-agent/test/websearch-native-route-dedup.test.ts @@ -0,0 +1,221 @@ +import { describe, expect, it } from "vitest"; + +import { + buildNativeEntries, + type NativeModelInfo, + type NativeModelRegistry, +} from "../src/core/extensions/builtin/websearch/websearch/native.ts"; + +type DiscoveryRegistry = NativeModelRegistry & { + getAvailable(): NativeModelInfo[]; +}; + +function anthropicAliases(baseUrl: string): NativeModelInfo[] { + return Array.from({ length: 8 }, (_value, index) => ({ + provider: "anthropic", + id: index === 0 ? "claude-opus-4" : `claude-opus-4-${index}`, + baseUrl, + })); +} + +function zAiAliases(baseUrl: string): NativeModelInfo[] { + return Array.from({ length: 6 }, (_value, index) => ({ + provider: "z-ai", + id: `glm-4.6-${index}`, + baseUrl, + })); +} + +describe("vendored websearch native route discovery", () => { + it("#given fourteen aliases across two endpoints #when discovering native entries #then emits one opaque entry per route", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: true, apiKey: "native-test" }; + }, + getAvailable() { + return [ + ...anthropicAliases("https://gateway.example.com/v1"), + ...zAiAliases("https://gateway.example.com/v1"), + ]; + }, + }; + + // when + const firstEntries = await buildNativeEntries(undefined, modelRegistry); + const secondEntries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(firstEntries).toHaveLength(2); + const firstIds = firstEntries.map((entry) => entry.id); + expect(firstIds).toEqual(secondEntries.map((entry) => entry.id)); + expect(new Set(firstIds).size).toBe(2); + expect(firstIds.every((id) => id?.startsWith("native-"))).toBe(true); + expect(firstIds.join(" ")).not.toMatch(/gateway|claude|glm|native-test/); + expect(authModels).toEqual(["claude-opus-4", "glm-4.6-0", "claude-opus-4", "glm-4.6-0"]); + }); + + it("#given active route auth fails #when a discovered alias shares the route #then does not retry auth through the alias", async () => { + // given + const activeModel: NativeModelInfo = { + provider: "openai", + id: "gpt-5.5", + baseUrl: "https://gateway.example.com/v1", + }; + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return model.id === activeModel.id + ? { ok: false, error: "active unavailable" } + : { ok: true, apiKey: "alias-key" }; + }, + getAvailable() { + return [{ provider: "openai", id: "gpt-4.1", baseUrl: "https://gateway.example.com/v1" }]; + }, + }; + + // when + const entries = await buildNativeEntries(activeModel, modelRegistry); + + // then + expect(entries).toEqual([]); + expect(authModels).toEqual(["gpt-5.5"]); + }); + + it("#given unavailable aliases on one route #when discovering native entries #then resolves auth for only the first alias", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: false, error: "unavailable" }; + }, + getAvailable() { + return anthropicAliases("https://gateway.example.com/v1"); + }, + }; + + // when + const entries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(entries).toEqual([]); + expect(authModels).toEqual(["claude-opus-4"]); + }); + + it("#given dotted private route spellings #when discovering native entries #then rejects them before auth", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: true, apiKey: "native-test" }; + }, + getAvailable() { + return [ + { provider: "openai", id: "gpt-5.5", baseUrl: "https://localhost./v1" }, + { provider: "openai", id: "gpt-4.1", baseUrl: "https://127.1../v1" }, + ]; + }, + }; + + // when + const entries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(entries).toEqual([]); + expect(authModels).toEqual([]); + }); + + it("#given dotted and undotted public aliases #when discovering native entries #then emits one route", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: true, apiKey: "native-test" }; + }, + getAvailable() { + return [ + { provider: "openai", id: "gpt-5.5", baseUrl: "https://gateway.example.com./v1" }, + { provider: "openai", id: "gpt-4.1", baseUrl: "https://gateway.example.com/v1" }, + ]; + }, + }; + + // when + const entries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(entries).toHaveLength(1); + expect(entries[0]?.baseUrl).toBe("https://gateway.example.com./v1/responses"); + expect(authModels).toEqual(["gpt-5.5"]); + }); + + it("#given one provider on distinct endpoints #when discovering native entries #then preserves both route candidates", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: true, apiKey: "native-test" }; + }, + getAvailable() { + return [ + { provider: "openai", id: "gpt-4.1", baseUrl: "https://openai-a.example.com/v1" }, + { provider: "openai", id: "gpt-5.5", baseUrl: "https://openai-b.example.com/v1" }, + ]; + }, + }; + + // when + const entries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(entries).toHaveLength(2); + expect(entries.map((entry) => entry.baseUrl)).toEqual([ + "https://openai-a.example.com/v1/responses", + "https://openai-b.example.com/v1/responses", + ]); + expect(new Set(entries.map((entry) => entry.id)).size).toBe(2); + expect(authModels).toEqual(["gpt-4.1", "gpt-5.5"]); + }); + + it("#given query auth and fragment aliases #when building the endpoint #then preserves query and dedupes fragments", async () => { + // given + const authModels: string[] = []; + const modelRegistry: DiscoveryRegistry = { + async getApiKeyAndHeaders(model) { + authModels.push(model.id); + return { ok: true, apiKey: "native-test" }; + }, + getAvailable() { + return [ + { + provider: "openai", + id: "gpt-5.5", + baseUrl: "https://gateway.example.com/v1?token=secret#first", + }, + { + provider: "openai", + id: "gpt-4.1", + baseUrl: "https://gateway.example.com/v1?token=secret#second", + }, + ]; + }, + }; + + // when + const entries = await buildNativeEntries(undefined, modelRegistry); + + // then + expect(entries).toHaveLength(1); + expect(entries[0]?.baseUrl).toBe("https://gateway.example.com/v1/responses?token=secret"); + expect(entries[0]?.id).toBeTruthy(); + expect(entries[0]?.id).not.toContain("secret"); + expect(authModels).toEqual(["gpt-5.5"]); + }); +}); diff --git a/packages/coding-agent/test/websearch-native-tool.test.ts b/packages/coding-agent/test/websearch-native-tool.test.ts new file mode 100644 index 000000000..f3a1e3889 --- /dev/null +++ b/packages/coding-agent/test/websearch-native-tool.test.ts @@ -0,0 +1,186 @@ +import type { Api, Model } from "@earendil-works/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { DEFAULT_COMPACTION_SETTINGS } from "../src/core/compaction/index.ts"; +import { createWebSearchTool } from "../src/core/extensions/builtin/websearch/websearch/tool.ts"; +import type { + SearchProgressDetails, + WebsearchConfig, +} from "../src/core/extensions/builtin/websearch/websearch/types.ts"; +import type { ExtensionContext } from "../src/core/extensions/types.ts"; +import { ModelRegistry } from "../src/core/model-registry.ts"; + +function successfulSearchResponse(): Response { + return new Response( + JSON.stringify({ + output: [{ type: "web_search_call", action: { sources: [{ url: "https://results.example.com/native" }] } }], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ); +} + +function nativeModel(provider: string, id: string, api: Api, baseUrl: string): Model { + return { + provider, + id, + name: id, + api, + baseUrl, + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 16_384, + }; +} + +function toolContext(model: Model | undefined, modelRegistry: ModelRegistry): ExtensionContext { + return { + ui: Object.create(null) as ExtensionContext["ui"], + mode: "print", + hasUI: false, + cwd: process.cwd(), + sessionManager: Object.create(null) as ExtensionContext["sessionManager"], + modelRegistry, + model, + serviceTier: undefined, + isIdle: () => true, + isProjectTrusted: () => true, + signal: undefined, + abort: vi.fn(), + hasPendingMessages: () => false, + shutdown: vi.fn(), + getContextUsage: () => undefined, + getCompactionSettings: () => DEFAULT_COMPACTION_SETTINGS, + compact: vi.fn(), + getMessageRevision: () => 0, + applyCompaction: async () => ({ applied: false, reason: "rejected" }), + getSystemPrompt: () => "", + }; +} + +function autoConfig(): WebsearchConfig { + return { + strategy: "priority", + fallback: true, + auto: true, + providers: [ + { id: "configured-first", provider: "duckduckgo-html" }, + { id: "configured-second", provider: "z-ai", apiKey: "configured-key" }, + ], + }; +} + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("vendored websearch native tool", () => { + it("#given an active native model and same-route aliases #when the real tool is executed #then orders native routes before configured providers without duplication", async () => { + // given + const activeModel = nativeModel("openai", "gpt-5.5", "openai-responses", "https://gateway.example.com/v1"); + const authModels: string[] = []; + const modelRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); + vi.spyOn(modelRegistry, "getApiKeyAndHeaders").mockImplementation(async (model) => { + authModels.push(model.id); + return { ok: true, apiKey: `${model.provider}-native-key` }; + }); + vi.spyOn(modelRegistry, "getAvailable").mockReturnValue([ + nativeModel("openai", "gpt-4o-2025-06-01", "openai-responses", "https://gateway.example.com./v1"), + nativeModel("openai", "gpt-4.1", "openai-responses", "https://gateway.example.com/v1"), + nativeModel("anthropic", "claude-sonnet-4-20250514", "anthropic-messages", "https://anthropic.example.com"), + ]); + const progress: SearchProgressDetails[] = []; + const fetchMock = vi.fn(async () => successfulSearchResponse()); + vi.stubGlobal("fetch", fetchMock); + const tool = createWebSearchTool(() => ({ ok: true, config: autoConfig(), source: "test" })); + + // when + await tool.execute( + "native-route-order", + { query: "native route order" }, + undefined, + (update) => { + if (update.details && "phase" in update.details && update.details.phase === "searching") { + progress.push(update.details); + } + }, + toolContext(activeModel, modelRegistry), + ); + + // then + expect(progress).toHaveLength(1); + const providerLabels = progress[0]?.providerLabels ?? []; + expect(providerLabels).toHaveLength(4); + expect(providerLabels[0]).toBe("native/openai"); + expect(providerLabels[1]?.endsWith("/anthropic")).toBe(true); + expect(providerLabels[1]).not.toContain("claude"); + expect(providerLabels[2]).toBe("configured-first/duckduckgo-html"); + expect(providerLabels[3]).toBe("configured-second/z-ai"); + expect(providerLabels.filter((label) => label.endsWith("/openai"))).toEqual(["native/openai"]); + expect(authModels).toEqual(["gpt-5.5", "claude-sonnet-4-20250514"]); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("#given auto discovery and a pre-aborted signal #when the real tool is executed #then skips native auth and fetch", async () => { + // given + const abortReason = new DOMException("cancelled by test", "AbortError"); + const controller = new AbortController(); + controller.abort(abortReason); + const modelRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); + const authMock = vi.spyOn(modelRegistry, "getApiKeyAndHeaders"); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const tool = createWebSearchTool(() => ({ ok: true, config: autoConfig(), source: "test" })); + const activeModel = nativeModel("openai", "gpt-5.5", "openai-responses", "https://gateway.example.com/v1"); + + // when + const execution = tool.execute( + "pre-aborted", + { query: "should not search" }, + controller.signal, + undefined, + toolContext(activeModel, modelRegistry), + ); + + // then + await expect(execution).rejects.toBe(abortReason); + expect(authMock).not.toHaveBeenCalled(); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("#given native auth is pending #when the search is aborted #then rejects without waiting for auth", async () => { + // given + let markAuthStarted: (() => void) | undefined; + const authStarted = new Promise((resolve) => { + markAuthStarted = resolve; + }); + const modelRegistry = ModelRegistry.inMemory(AuthStorage.inMemory()); + vi.spyOn(modelRegistry, "getApiKeyAndHeaders").mockImplementation(async () => { + markAuthStarted?.(); + return new Promise(() => {}); + }); + const fetchMock = vi.fn(); + vi.stubGlobal("fetch", fetchMock); + const tool = createWebSearchTool(() => ({ ok: true, config: autoConfig(), source: "test" })); + const activeModel = nativeModel("openai", "gpt-5.5", "openai-responses", "https://gateway.example.com/v1"); + const controller = new AbortController(); + const abortReason = new DOMException("cancelled during auth", "AbortError"); + + // when + const execution = tool.execute( + "abort-during-auth", + { query: "should stop" }, + controller.signal, + undefined, + toolContext(activeModel, modelRegistry), + ); + await authStarted; + controller.abort(abortReason); + + // then + await expect(execution).rejects.toBe(abortReason); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/websearch-provider-endpoints.test.ts b/packages/coding-agent/test/websearch-provider-endpoints.test.ts new file mode 100644 index 000000000..08a5cf949 --- /dev/null +++ b/packages/coding-agent/test/websearch-provider-endpoints.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; + +import { isAllowedProviderBaseUrl } from "../src/core/extensions/builtin/websearch/websearch/provider-endpoints.ts"; + +describe("vendored websearch provider endpoint safety", () => { + it("#given private hosts with terminal DNS dots #when validating provider URLs #then rejects them", () => { + // given + const privateBaseUrls = [ + "https://localhost./search", + "https://sub.localhost./search", + "https://127.1../search", + "https://0177.0.0.1../search", + "https://2130706433../search", + "https://0x7f000001../search", + "https://10.1../search", + ]; + + for (const baseUrl of privateBaseUrls) { + // when + const allowed = isAllowedProviderBaseUrl(baseUrl); + + // then + expect(allowed).toBe(false); + } + }); + + it("#given a public FQDN with one terminal DNS dot #when validating the provider URL #then allows it", () => { + // given + const baseUrl = "https://search-gateway.example.com./search"; + + // when + const allowed = isAllowedProviderBaseUrl(baseUrl); + + // then + expect(allowed).toBe(true); + }); + + it("#given a public hostname with repeated terminal dots #when validating the provider URL #then rejects it", () => { + // given + const baseUrl = "https://search-gateway.example.com../search"; + + // when + const allowed = isAllowedProviderBaseUrl(baseUrl); + + // then + expect(allowed).toBe(false); + }); +});