diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 236e9b64a..31f315721 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -216,7 +216,7 @@ Compaction replaces older turns with a structured, workflow-aware summary rather ### Web Tools and Providers (`src/web/`) -`web_search` and `web_fetch` are always-on core tools (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`) — no plugin or API key required. `web_fetch` runs in-process on Bun's native `fetch()`: it resolves and rejects private/loopback/link-local targets before every request and again after each redirect hop (`src/tools/ssrf-guard.ts`), caps responses at 5 MB, and converts HTML to markdown by default (`text`/`markdown`/`html` formats). It opens with a browser User-Agent and retries once with an honest `corbits/` UA if the first attempt is rejected as bot traffic. `web_search` calls a keyless hosted MCP search provider (Exa's public endpoint by default, `https://mcp.exa.ai/mcp`; Parallel selectable via `CORBITS_WEB_SEARCH_PROVIDER=parallel`) through the same MCP client used for configured MCP servers (`src/mcp/client.ts`), with an optional bearer key via `CORBITS_WEB_SEARCH_API_KEY`. Both tools get their own permission classes — `webfetch` (approval scoped to the requested URL) and `websearch` (scoped to the tool as a whole) — distinct from shell and file classes, defaulting to ask-with-persistable-allow like any other consequential tool. SSRF protection lives in the tool boundary itself rather than being delegated to external provider infrastructure. +`web_search` and `web_fetch` are always-on core tools (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`) — no plugin or API key required. `web_fetch` runs in-process on Bun's native `fetch()`: it resolves and rejects private/loopback/link-local targets before every request and again after each redirect hop (`src/tools/ssrf-guard.ts`), caps responses at 5 MB, and converts HTML to markdown by default (`text`/`markdown`/`html` formats). It opens with a browser User-Agent and retries once with an honest `corbits/` UA if the first attempt is rejected as bot traffic. `web_search` calls a keyless hosted MCP search provider (Exa's public endpoint by default, `https://mcp.exa.ai/mcp`; Parallel selectable via `CORBITS_WEB_SEARCH_PROVIDER=parallel`) through the same MCP client used for configured MCP servers (`src/mcp/client.ts`), with an optional bearer key via `CORBITS_WEB_SEARCH_API_KEY`. When a `kind: "web"` plugin is selected (`settings.web` override or the single enabled web plugin), its `WebProvider` backs both tools instead — resolved in `src/tui/runner/session.ts` via `resolveWebProviderFromPlugins` and injected through `createAgentToolset({ webProvider })` — and tool calls render under its brand (e.g. "Exa Search"). Both tools get their own permission classes — `webfetch` (approval scoped to the requested URL) and `websearch` (scoped to the tool as a whole) — distinct from shell and file classes, defaulting to ask-with-persistable-allow like any other consequential tool. SSRF protection lives in the tool boundary itself rather than being delegated to external provider infrastructure. ### Director-Layer Tools (`src/agent/director.ts`) diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index fde3f24a0..980ef07da 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -518,7 +518,7 @@ See `docs/PLUGINS.md` for the full design. Summary: - Every installable plugin exports a `manifest` (`{ id, name, kind, description?, credentials? }`) with `kind` one of `web | command | workflow | tool | agent`. - Plugins are auto-discovered from `plugins/`, `/.corbits/plugins/`, and `~/.corbits/plugins/`, plus any explicit file/dir paths in `settings.pluginPaths`. When `settings.discoverClaudePlugins` is true, plugins listed in `~/.claude/plugins/installed_plugins.json` are also loaded (install paths only; still require enable). The `/plugins` UI's "add by path" action (Alt+A) loads a plugin from anywhere on disk, validates its manifest, and persists the path. Alt+X removes by stamped origin: owned user/project installs (and path-origin plugins whose directory sits under those roots) delete the discovered directory under `~/.corbits/plugins` or `/.corbits/plugins` after confirmation and write `settings.plugins[id].enabled: false` (the key is never dropped, so in-session command gating holds); path plugins revoke trust and drop unique `pluginPaths` entries (a shared marketplace root is kept); bundled repo plugins write `enabled: false` and stay listed; Claude marketplace installs write `enabled: false` and never touch `~/.claude`. Disk/path entries are still removed so the plugin is gone after restart. Removal takes effect in-session (commands re-gate live; tools stay until restart). Discovery resolves relative imports to absolute first (`loadPluginEntry`). Project-local plugins require per-cwd trust (`~/.corbits/trust/.json`); path plugins use global path trust (`~/.corbits/trust/path-plugins.json`) so they keep working across project directories. Untrusted origins load metadata-only until granted. -- **Explicit enable:** nothing is wired in until `settings.plugins[id].enabled` is true, except the repo-origin `defaultEnabled` case: when `origin === "repo"` AND `manifest.defaultEnabled` is true AND `settings.plugins[id]` is missing, the plugin auto-enables (this is how first-party `corbits-skills` is on out of the gate). An explicit `enabled: false` still disables. Marketplace (user / project / path / claude) `defaultEnabled` is ignored — those plugins stay opt-in. `command` → `registerCommandPlugins` registers slash commands (live on enable); `tool` → `resolveToolPlugins` instantiates `createToolPlugin(credentials)` and appends the tools to the posix toolset assembled in `src/tui/runner.ts` (via `tools.ts` helpers). `web` → `web_search`/`web_fetch` are now always-on core built-ins (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`), not plugin-backed; a discovered `kind: "web"` plugin is retained for brand-display resolution only (`resolveWebProviderFromPlugins`/`webBrand` in `src/web/plugin-provider.ts`) and no longer supplies the tool implementation. +- **Explicit enable:** nothing is wired in until `settings.plugins[id].enabled` is true, except the repo-origin `defaultEnabled` case: when `origin === "repo"` AND `manifest.defaultEnabled` is true AND `settings.plugins[id]` is missing, the plugin auto-enables (this is how first-party `corbits-skills` is on out of the gate). An explicit `enabled: false` still disables. Marketplace (user / project / path / claude) `defaultEnabled` is ignored — those plugins stay opt-in. `command` → `registerCommandPlugins` registers slash commands (live on enable); `tool` → `resolveToolPlugins` instantiates `createToolPlugin(credentials)` and appends the tools to the posix toolset assembled in `src/tui/runner.ts` (via `tools.ts` helpers). `web` → `web_search`/`web_fetch` are always-on core built-ins (`src/tools/web-search.ts`, `src/tools/web-fetch.ts`); when a `kind: "web"` plugin is selected (`settings.web` override or the single enabled web plugin), its `WebProvider` backs both tools (`resolveWebProviderFromPlugins` in `src/web/plugin-provider.ts`, injected via `createAgentToolset({ webProvider })`), and tool calls render under its brand (`webBrand`, e.g. "Exa Search"). With no web plugin selected the core backends serve the tools. - **Tool consent:** a `tool` plugin runs in-process, so it is wired in only when enabled AND `consented`. The `/plugins` UI prompts a one-time y/n consent recorded in `settings.plugins[id].consented`. - Configure via `/plugins`, which writes `settings.plugins` (enabled / consented / credentials), `settings.web`, and `settings.pluginPaths` to the global settings file. Credentials live in the global file because it carries secrets — the project-local settings file rejects credential keys. When a web plugin is active its tool calls render under its brand (e.g. "Exa Search"). Example: `{ "web": "exa", "plugins": { "exa": { "enabled": true, "credentials": { "apiKey": "..." } } } }`. diff --git a/docs/PLUGINS.md b/docs/PLUGINS.md index 5ddfe255e..9c346e551 100644 --- a/docs/PLUGINS.md +++ b/docs/PLUGINS.md @@ -234,9 +234,17 @@ plugin slash commands in the picker. ### Web providers Manifest type, `kind`, `settings.plugins` / `web` / `pluginPaths`, web -resolution, tool-name branding, `/plugins` UI, and add-by-path. Web is the +resolution, provider-backed tools, tool-name branding, `/plugins` UI, and add-by-path. Web is the first kind-selector surface; other kinds share the same discovery and settings -shape. +shape. When a web plugin is selected its `WebProvider` backs `web_search`/`web_fetch` +(injected via `createAgentToolset({ webProvider })`); with none selected the +always-on core backends serve the tools. The provider path takes query/url +only: `web_search` forwards just the query (numResults/type/livecrawl/ +contextMaxCharacters are ignored) and `web_fetch` forwards just the URL +(format/timeout are ignored); the SSRF guard still runs before delegating. +`WebProvider.fetch` receives the pre-checked URL; server-side redirects +inside the provider are provider trust (no per-hop re-check, unlike the +core `runWebFetch` fallback, which re-checks every redirect hop). ### Command plugins and enable gating diff --git a/src/agent/tools.ts b/src/agent/tools.ts index a57631cfe..c33fe76ea 100644 --- a/src/agent/tools.ts +++ b/src/agent/tools.ts @@ -103,6 +103,7 @@ import { createWebSearchTool, disposeWebSearchClients, } from "../tools/web-search.js"; +import type { WebProvider } from "../web/types.js"; import { createApplyPatchTool } from "./apply-patch-tool.js"; import { createUseSkillTool } from "./use-skill.js"; import { searchSkillCatalog } from "./skill-search.js"; @@ -275,6 +276,11 @@ export interface AgentToolsetArgs { outerRetryDelayMs?: number; retryPolicy?: RetryPolicy; }; + // Selected kind:"web" plugin backend. When present, web_search/web_fetch + // delegate to it (CL-9885); when omitted the always-on core backends serve + // them. TUI sessions pass the resolved active provider; every other caller + // omits it and keeps core behavior. + webProvider?: WebProvider; /** * Retained so callers that still pass the Codex family flag do not break. * Proxies are no longer mounted; hidden aliases dispatch onto engine tools. @@ -673,10 +679,14 @@ export async function createAgentToolset( ? { extraDeniedPaths: args.secretGuardExtraDeniedPaths } : {}), }), - builtinExaEnabled + builtinExaEnabled && args.webProvider === undefined ? createExaMCPWebFetchTool({ connect: waitForBuiltinExaConnection }) - : createWebFetchTool(), - createWebSearchTool(), + : createWebFetchTool( + args.webProvider !== undefined + ? { provider: args.webProvider } + : undefined, + ), + createWebSearchTool(args.webProvider), ...orchestratorTools, stringTool({ definition: manageTasksDefinition, @@ -974,11 +984,16 @@ export async function createAgentToolset( }; const swapBuiltinExaToNative = (): void => { + // A selected web plugin owns web_fetch — the Exa/native swap must not + // clobber the plugin-backed tool. + if (args.webProvider !== undefined) return; failBuiltinExaWaiters("built-in Exa MCP was disconnected"); mountWebFetch(createWebFetchTool()); }; const remountBuiltinExaAlias = (): void => { + // A selected web plugin owns web_fetch — see swapBuiltinExaToNative. + if (args.webProvider !== undefined) return; failBuiltinExaWaiters("built-in Exa MCP was disconnected"); builtinExaConnection = new Promise((resolve) => { resolveBuiltinExaConnection = resolve; diff --git a/src/tools/ssrf-guard.test.ts b/src/tools/ssrf-guard.test.ts index d32561fa8..5d459217d 100644 --- a/src/tools/ssrf-guard.test.ts +++ b/src/tools/ssrf-guard.test.ts @@ -33,6 +33,18 @@ describe("checkUrlForSsrf", () => { expect((await checkUrlForSsrf(url)).ok).toBe(false); } }); + test("bracketed IPv6 loopback refuses as a private address, not a DNS error", async () => { + expect(isPrivateAddress("[::1]")).toBe(true); + expect(isPrivateAddress("[::ffff:127.0.0.1]")).toBe(true); + for (const url of ["http://[::1]/", "http://[::ffff:127.0.0.1]/"]) { + const result = await checkUrlForSsrf(url); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.reason).toMatch(/private\/loopback\/link-local/); + expect(result.reason).not.toMatch(/Could not resolve/); + } + } + }); test("allows the eval fixture URL exactly when EVAL_HTTP_URL is set", async () => { const prior = process.env.EVAL_HTTP_URL; process.env.EVAL_HTTP_URL = "http://127.0.0.1:54321/"; diff --git a/src/tools/ssrf-guard.ts b/src/tools/ssrf-guard.ts index 1b7283e1c..c0ec210fd 100644 --- a/src/tools/ssrf-guard.ts +++ b/src/tools/ssrf-guard.ts @@ -29,17 +29,48 @@ function isPrivateIPv6(ip: string): boolean { if (normalized.startsWith("fe80:")) return true; // link-local if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true; // unique local if (normalized.startsWith("::ffff:")) { - // IPv4-mapped IPv6; re-check the embedded v4 address. + // IPv4-mapped IPv6; re-check the embedded v4 address. WHATWG URL + // parsing renders the tail hex ("::ffff:7f00:1") rather than dotted + // ("::ffff:127.0.0.1"), so decode either form. const mapped = normalized.slice("::ffff:".length); - if (isIP(mapped) === 4) return isPrivateIPv4(mapped); + if (mapped.includes(".")) { + if (isIP(mapped) === 4) return isPrivateIPv4(mapped); + } else { + const decoded = mappedTailToIPv4(mapped); + if (decoded !== undefined) return isPrivateIPv4(decoded); + } } return false; } +// Decodes the hex tail of a "::ffff:" mapped address (one or two groups, +// the low 32 bits) back to dotted IPv4. Returns undefined when the tail is +// not plain hex, leaving the caller to fall through. +function mappedTailToIPv4(tail: string): string | undefined { + const groups = tail.split(":"); + if (groups.length < 1 || groups.length > 2) return undefined; + if ( + groups.some( + (g) => g.length === 0 || g.length > 4 || !/^[0-9a-f]+$/.test(g), + ) + ) + return undefined; + const hi = groups.length === 2 ? parseInt(groups[0] as string, 16) : 0; + const lo = parseInt(groups[groups.length - 1] as string, 16); + return `${(hi >>> 8) & 255}.${hi & 255}.${(lo >>> 8) & 255}.${lo & 255}`; +} + export function isPrivateAddress(ip: string): boolean { - const version = isIP(ip); - if (version === 4) return isPrivateIPv4(ip); - if (version === 6) return isPrivateIPv6(ip); + // URL.hostname keeps IPv6 brackets ("[::1]"), which isIP rejects; strip + // them so bracketed literals classify directly instead of falling through + // to the DNS fail-closed path. + const host = + ip.length >= 2 && ip.startsWith("[") && ip.endsWith("]") + ? ip.slice(1, -1) + : ip; + const version = isIP(host); + if (version === 4) return isPrivateIPv4(host); + if (version === 6) return isPrivateIPv6(host); return true; // not a literal IP; caller must resolve first } @@ -88,9 +119,18 @@ export async function checkUrlForSsrf( return { ok: false, reason: "Requests to localhost are not allowed." }; } - const literalVersion = isIP(hostname); + // URL.hostname preserves IPv6 brackets ("[::1]"); strip them before isIP + // so bracketed literals classify as private directly, not via DNS + // fail-closed. The refusal message keeps the original hostname. + const literalHost = + hostname.length >= 2 && + hostname.startsWith("[") && + hostname.endsWith("]") + ? hostname.slice(1, -1) + : hostname; + const literalVersion = isIP(literalHost); if (literalVersion !== 0) { - if (isPrivateAddress(hostname)) { + if (isPrivateAddress(literalHost)) { return { ok: false, reason: `Requests to private/loopback/link-local address ${hostname} are not allowed.`, diff --git a/src/tools/web-fetch.ts b/src/tools/web-fetch.ts index 857ab31ec..9c3c87d0c 100644 --- a/src/tools/web-fetch.ts +++ b/src/tools/web-fetch.ts @@ -7,6 +7,7 @@ import { checkUrlForSsrf } from "./ssrf-guard.js"; import { htmlToMarkdown, htmlToText } from "./html-convert.js"; import { COMMAND_NAME } from "../branding.js"; import type { MCPClient } from "../mcp/client.js"; +import type { WebProvider } from "../web/types.js"; import pkg from "../../package.json" with { type: "json" }; export const MAX_FETCH_BYTES = 5 * 1024 * 1024; // 5 MB @@ -260,12 +261,35 @@ export async function runWebFetch( }; } -export function createWebFetchTool(options?: WebFetchTimingOptions): AgentTool { +export function createWebFetchTool( + options?: WebFetchTimingOptions & { provider?: WebProvider }, +): AgentTool { return stringTool({ definition: webFetchDefinition, - handler: async (rawArgs: Record): Promise => { + handler: async ( + rawArgs: Record, + signal: AbortSignal, + ): Promise => { const parsed = parseWebFetchArgs(rawArgs); if (!parsed.ok) return parsed.error; + // A selected kind:"web" plugin backs the tool; the in-process native + // fetch below is the fallback to core backends when none is selected. + // The provider path takes query/url only: format/timeout options are + // ignored there (core fallback honors them). The SSRF guard runs on + // both paths so a blocked URL never reaches the provider. + // provider.fetch is opaque: unlike runWebFetch below (manual redirects + // with a per-hop SSRF re-check), server-side redirects inside the + // provider are provider trust — it receives the pre-checked URL only. + if (options?.provider !== undefined) { + const provider = options.provider; + const ssrf = await checkUrlForSsrf(parsed.url); + if (!ssrf.ok) return `Error: ${ssrf.reason}`; + try { + return await provider.fetch(parsed.url, signal); + } catch (err) { + return `Error: web_fetch (${provider.name}) failed: ${err instanceof Error ? err.message : String(err)}`; + } + } const outcome = await runWebFetch( parsed.url, parsed.format, diff --git a/src/tools/web-search.ts b/src/tools/web-search.ts index f47541d82..cc9a93eb4 100644 --- a/src/tools/web-search.ts +++ b/src/tools/web-search.ts @@ -6,6 +6,7 @@ import type { ToolDefinition } from "@intx/types/runtime"; import { connectMCPServer, type MCPClient } from "../mcp/client.js"; import type { MCPServerConfig } from "../config/settings.js"; import { EXA_MCP_URL } from "../mcp/exa.js"; +import type { WebProvider, WebResult } from "../web/types.js"; export { EXA_MCP_URL } from "../mcp/exa.js"; @@ -144,7 +145,16 @@ function toolNameFor(provider: WebSearchProviderId): string { return provider === "parallel" ? "web_search" : "web_search_exa"; } -export function createWebSearchTool(): AgentTool { +// Render plugin provider results in the same plain-text shape the core MCP +// backends return, so callers cannot tell which backend served the query. +export function formatWebResults(results: WebResult[]): string { + if (results.length === 0) return "No results."; + return results + .map((r) => `- ${r.title} (${r.url})\n ${r.snippet}`) + .join("\n"); +} + +export function createWebSearchTool(provider?: WebProvider): AgentTool { return stringTool({ definition: webSearchDefinition, handler: async ( @@ -155,15 +165,25 @@ export function createWebSearchTool(): AgentTool { if (parsed instanceof type.errors) { return "Error: web_search requires a non-empty query."; } - const provider = resolveWebSearchProvider(); + // A selected kind:"web" plugin backs the tool (query only; the other + // search options are ignored on the provider path); with none + // selected the tools fall back to core backends. + if (provider !== undefined) { + try { + return formatWebResults(await provider.search(parsed.query, signal)); + } catch (err) { + return `Error: web_search (${provider.name}) failed: ${err instanceof Error ? err.message : String(err)}`; + } + } + const backend = resolveWebSearchProvider(); try { - const client = await getClient(provider); + const client = await getClient(backend); const args = - provider === "parallel" ? parallelArgs(parsed) : exaArgs(parsed); - const result = await client.call(toolNameFor(provider), args, signal); + backend === "parallel" ? parallelArgs(parsed) : exaArgs(parsed); + const result = await client.call(toolNameFor(backend), args, signal); return result.length > 0 ? result : "No results."; } catch (err) { - return `Error: web_search (${provider}) failed: ${err instanceof Error ? err.message : String(err)}`; + return `Error: web_search (${backend}) failed: ${err instanceof Error ? err.message : String(err)}`; } }, }); diff --git a/src/tui/runner/session.ts b/src/tui/runner/session.ts index 836be81a9..da3987131 100644 --- a/src/tui/runner/session.ts +++ b/src/tui/runner/session.ts @@ -371,6 +371,9 @@ export async function assembleTUISession( isWorkflowActive: () => workflowHostHolder.instance?.isActive() === true, completeWorkflowStep: (stepId) => workflowHostHolder.instance?.complete(stepId) ?? "not-current", + // CL-9885: the resolved web plugin backs web_search/web_fetch instead of + // being brand-display only; omitted keeps the always-on core backends. + ...(activeWeb !== undefined ? { webProvider: activeWeb.provider } : {}), ...(extraToolPlugins.length > 0 ? { extraToolPlugins } : {}), onOperatorGate: (question, options) => new Promise((resolve) => { diff --git a/src/web/plugin-provider.test.ts b/src/web/plugin-provider.test.ts index ec1a6377d..a646948ee 100644 --- a/src/web/plugin-provider.test.ts +++ b/src/web/plugin-provider.test.ts @@ -97,7 +97,7 @@ describe("resolveWebProviderFromPlugins", () => { expect(active?.provider.name).toBe("exa"); }); - test("returns undefined (falls back to local) when the factory throws", async () => { + test("returns undefined (falls back to core backends) when the factory throws", async () => { const candidates: WebPluginCandidate[] = [ { id: "exa", diff --git a/src/web/plugin-provider.ts b/src/web/plugin-provider.ts index 7d86f971c..b8a0a9242 100644 --- a/src/web/plugin-provider.ts +++ b/src/web/plugin-provider.ts @@ -27,7 +27,7 @@ export function collectWebPlugins( // Pick the active web plugin: an explicit `web` override wins; otherwise the // single enabled web plugin is used. Returns undefined when none applies, in -// which case web_search/web_fetch are not registered (web is plugin-only). +// which case web_search/web_fetch fall back to core backends. export function selectWebPlugin( candidates: WebPluginCandidate[], pluginConfig: Record, @@ -54,8 +54,8 @@ export interface ActiveWebProvider { } // Build the active web provider from the discovered candidates and stored -// config. On failure logs to stderr and returns undefined so the run proceeds -// with web tools disabled rather than crashing. +// config. On failure logs to stderr and returns undefined so the run falls +// back to core backends rather than crashing. export async function resolveWebProviderFromPlugins(args: { candidates: WebPluginCandidate[]; pluginConfig: Record; @@ -75,7 +75,7 @@ export async function resolveWebProviderFromPlugins(args: { !args.candidates.some((c) => c.id === args.webOverride) ) { process.stderr.write( - `web-provider: settings.web "${args.webOverride}" matches no discovered web plugin; web tools disabled.\n`, + `web-provider: settings.web "${args.webOverride}" matches no discovered web plugin; falling back to core backends.\n`, ); } return undefined; @@ -87,7 +87,7 @@ export async function resolveWebProviderFromPlugins(args: { } catch (err) { const message = err instanceof Error ? err.message : String(err); process.stderr.write( - `web-provider: failed to start plugin "${selected.id}", web tools disabled: ${scrubSecrets(message)}\n`, + `web-provider: failed to start plugin "${selected.id}", falling back to core backends: ${scrubSecrets(message)}\n`, ); return undefined; } diff --git a/src/web/web-provider-tools.test.ts b/src/web/web-provider-tools.test.ts new file mode 100644 index 000000000..b80fa5dc5 --- /dev/null +++ b/src/web/web-provider-tools.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, test } from "bun:test"; +import { createWebSearchTool, formatWebResults } from "../tools/web-search.js"; +import { createWebFetchTool } from "../tools/web-fetch.js"; +import { createAgentToolset } from "../agent/tools.js"; +import { createPermissionGate } from "../permission/gate.js"; +import type { WebProvider, WebResult } from "./types.js"; + +// CL-9885: the resolved kind:"web" plugin must back web_search/web_fetch, +// not just brand their display names. Core backends stay the fallback. + +const neverAbortedSignal = new AbortController().signal; + +interface StubCalls { + searches: string[]; + fetches: string[]; +} + +function stubProvider( + stub: { + searchImpl?: (query: string) => Promise; + fetchImpl?: (url: string) => Promise; + } = {}, +): { provider: WebProvider; calls: StubCalls } { + const calls: StubCalls = { searches: [], fetches: [] }; + const provider: WebProvider = { + name: "Stub", + search: async (query: string, _signal: AbortSignal) => { + calls.searches.push(query); + if (stub.searchImpl !== undefined) return stub.searchImpl(query); + return [{ title: "t", url: "https://example.com", snippet: "s" }]; + }, + fetch: async (url: string, _signal: AbortSignal) => { + calls.fetches.push(url); + if (stub.fetchImpl !== undefined) return stub.fetchImpl(url); + return "stub body"; + }, + }; + return { provider, calls }; +} + +describe("formatWebResults", () => { + test("empty results stay in the empty-but-ok taxonomy", () => { + expect(formatWebResults([])).toBe("No results."); + }); + + test("renders title, url, and snippet per result", () => { + expect( + formatWebResults([ + { title: "A", url: "https://a.example", snippet: "alpha" }, + { title: "B", url: "https://b.example", snippet: "beta" }, + ]), + ).toBe("- A (https://a.example)\n alpha\n- B (https://b.example)\n beta"); + }); +}); + +describe("createWebSearchTool with a web provider", () => { + test("delegates the query to the provider", async () => { + const { provider, calls } = stubProvider(); + const tool = createWebSearchTool(provider); + if (tool.kind !== "string") throw new Error("expected a string tool"); + const result = await tool.handler({ query: "q" }, neverAbortedSignal); + expect(calls.searches).toEqual(["q"]); + expect(result).toBe("- t (https://example.com)\n s"); + }); + + test("provider failure is an Error result, not a throw", async () => { + const { provider } = stubProvider({ + searchImpl: async () => { + throw new Error("down"); + }, + }); + const tool = createWebSearchTool(provider); + if (tool.kind !== "string") throw new Error("expected a string tool"); + const result = await tool.handler({ query: "q" }, neverAbortedSignal); + expect(result).toBe("Error: web_search (Stub) failed: down"); + }); + + test("an empty query never reaches the provider", async () => { + const { provider, calls } = stubProvider(); + const tool = createWebSearchTool(provider); + if (tool.kind !== "string") throw new Error("expected a string tool"); + const result = await tool.handler({ query: "" }, neverAbortedSignal); + expect(result).toContain("Error"); + expect(calls.searches).toEqual([]); + }); +}); + +describe("createWebFetchTool with a web provider", () => { + test("delegates the url to the provider", async () => { + const { provider, calls } = stubProvider(); + const tool = createWebFetchTool({ provider }); + if (tool.kind !== "string") throw new Error("expected a string tool"); + const result = await tool.handler( + { url: "https://example.com" }, + neverAbortedSignal, + ); + expect(calls.fetches).toEqual(["https://example.com"]); + expect(result).toBe("stub body"); + }); + + test("provider failure is an Error result, not a throw", async () => { + const { provider } = stubProvider({ + fetchImpl: async () => { + throw new Error("down"); + }, + }); + const tool = createWebFetchTool({ provider }); + if (tool.kind !== "string") throw new Error("expected a string tool"); + const result = await tool.handler( + { url: "https://example.com" }, + neverAbortedSignal, + ); + expect(result).toBe("Error: web_fetch (Stub) failed: down"); + }); + + test("invalid args never reach the provider", async () => { + const { provider, calls } = stubProvider(); + const tool = createWebFetchTool({ provider }); + if (tool.kind !== "string") throw new Error("expected a string tool"); + const result = await tool.handler({}, neverAbortedSignal); + expect(result).toContain("Error"); + expect(calls.fetches).toEqual([]); + }); + + test("a loopback URL never reaches the provider", async () => { + const { provider, calls } = stubProvider(); + const tool = createWebFetchTool({ provider }); + if (tool.kind !== "string") throw new Error("expected a string tool"); + const result = await tool.handler( + { url: "http://127.0.0.1/" }, + neverAbortedSignal, + ); + expect(result).toContain("Error"); + expect(calls.fetches).toEqual([]); + }); + + test("a file URL never reaches the provider", async () => { + const { provider, calls } = stubProvider(); + const tool = createWebFetchTool({ provider }); + if (tool.kind !== "string") throw new Error("expected a string tool"); + const result = await tool.handler( + { url: "file:///etc/passwd" }, + neverAbortedSignal, + ); + expect(result).toContain("Error"); + expect(calls.fetches).toEqual([]); + }); +}); + +describe("createAgentToolset with webProvider", () => { + test("web_search and web_fetch run on the selected provider", async () => { + const { provider, calls } = stubProvider(); + const toolset = await createAgentToolset({ + cwd: process.cwd(), + permissionGate: createPermissionGate({ + approvals: [], + interactive: false, + skipPermissions: true, + reactorGated: false, + }), + onOperatorGate: async () => ({ kind: "cancel" }), + webProvider: provider, + }); + try { + const searchResult = await toolset.dynamicRunner.run( + { id: "s1", name: "web_search", arguments: { query: "q" } }, + neverAbortedSignal, + ); + const fetchResult = await toolset.dynamicRunner.run( + { + id: "f1", + name: "web_fetch", + arguments: { url: "https://example.com" }, + }, + neverAbortedSignal, + ); + expect(calls.searches).toEqual(["q"]); + expect(calls.fetches).toEqual(["https://example.com"]); + expect(searchResult.content).toBe("- t (https://example.com)\n s"); + expect(fetchResult.content).toBe("stub body"); + } finally { + await toolset.dispose(); + } + }); +});