Skip to content
This repository was archived by the owner on Nov 8, 2026. It is now read-only.

Commit fb3136d

Browse files
justinhelmerJustin Helmer
andauthored
fix(web): retain safe Brave failure details (#3429)
Co-authored-by: Justin Helmer <justin@coreplane.ai>
1 parent 0372755 commit fb3136d

3 files changed

Lines changed: 194 additions & 9 deletions

File tree

‎docs/reference/specs/web-tools.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ Agents can read a web page the user links and search the web for current informa
1010
1. **URL reading is provider-agnostic and in-process** (Option B): `web_fetch` fetches an http(s) URL and returns its text (HTML is stripped to readable text), running in the bot process via an injected `web` capability — no Executor, no workspace. A no-repo agent can use it.
1111
2. **web_fetch is SSRF-hardened, connect-time**: only `http`/`https` schemes; literal internal addresses are refused synchronously — including every IPv6 form of an internal address (loopback, `10/8`, `172.16/12`, `192.168/16`, link-local `169.254/16` incl. cloud metadata, ULA, and IPv4-mapped/-compat/NAT64 IPv6 like `::ffff:169.254.169.254`, expanded fully rather than string-matched) — and internal hostnames (`localhost`, `*.local`, `*.internal`). For hostnames, the production fetch uses an undici dispatcher whose `lookup` resolves and refuses internal IPs **at connect time and connects to exactly that address** — so the validated IP is the connected IP (no DNS-rebinding TOCTOU). Redirect targets are re-validated on every hop (sync guard) and re-connect through the same dispatcher. Responses are size-capped (~1 MB) and time-bounded (~12 s); blocks/timeouts/HTTP errors return a clear message, never a crash.
1212
- **Binary links reach the model as vision/document input (M1b)**: when the response `content-type` (parameters stripped, case-folded) is `image/jpeg|png|gif|webp` or `application/pdf`, `web_fetch` returns a parts list — a text header plus an `image` / `document` content part (base64) — instead of text, so the model *sees* the picture or reads the PDF. This is the second producer of image/document parts after Slack attachments ([slack-channel.md](slack-channel.md)), and the reason `tool_result.content` is `string | ToolResultPart[]` (`src/core/chatMessage.ts`). Per-file caps match the attachment path (5 MiB image, 10 MiB PDF); an over-cap binary is refused with a message (never truncated — a cut image is garbage), and other `image/*` types (e.g. SVG) are named as unsupported rather than sent to a model that would reject them. Text URLs are unchanged. The parts reach the model through the relay ([harness-pi.md](harness-pi.md) item 7): an image as pi's image block inside the tool result, a document as its descriptor (name, type, size), pi's own adapters carrying them to the provider. Run-visibility summaries render binary parts as `[image image/png, N bytes]` — the base64 never enters the stream.
13-
3. **web_search is a seam with ≥2 implementations**: `WebSearch` has a real `BraveWebSearch` adapter (keyed by `BRAVE_SEARCH_API_KEY`) and a `NullWebSearch`. `makeWebCapability` selects Brave when the key is present, Null otherwise. With no key, `web_search` returns a clear "not configured" message (and notes URL reading still works) — it never breaks the [run](../vocabulary.md#run).
13+
3. **web_search is a seam with ≥2 implementations**: `WebSearch` has a real `BraveWebSearch` adapter (keyed by `BRAVE_SEARCH_API_KEY`) and a `NullWebSearch`. `makeWebCapability` selects Brave when the key is present, Null otherwise. With no key, `web_search` returns a clear "not configured" message (and notes URL reading still works) — it never breaks the [run](../vocabulary.md#run). A failed Brave response keeps its observed HTTP status, validated `ErrorResponse` code and request ID, bounded rate-limit window values, and one redacted, capped detail line in the tool result. An unparseable body keeps status without inventing provider fields. A successful response with a null or missing web section yields no results; other invalid web shapes yield a decode failure. A transport failure has no HTTP status or remote message. Neither failure retries the request or exposes the search URL, key, raw body or headers. The adapter follows [Brave's error shape](https://api-dashboard.search.brave.com/api-reference/web/search/post) and [rate-limit headers](https://api-dashboard.search.brave.com/documentation/guides/rate-limiting).
1414
4. **Enablement by toolset**: `web_fetch` (URL reading) is in the `full` (coding), `readonly` (review), `assistant` (general) and `explore` toolsets — broadly available; `web_search` is gated to the `web` toolset, held by the research-capable agent, and the `explore` toolset, whose long investigation reads the web beside the repository it runs ([agent-explore.md](agent-explore.md) item 2). `general` reads a linked URL but does not search ([agent-general.md](agent-general.md) item 1).
1515
5. **A dedicated `research` agent** (`toolset: "web"`, `machine: "none"`) answers research/URL questions with search + fetch and no workspace; since [github-tools.md](github-tools.md) item 6 its toolset also holds the GitHub READ tools, so a question about one of our repos is answered from the repo over the App credential (a github.com URL of ours is read with `github_file`/`github_tree`, not `web_fetch`), and its prompt forbids concluding a repo is inaccessible from a public-web 404. `general` directs web-research asks to `agent:research`.
1616
6. **The capability is injected, not global**: the dispatcher builds `web` from `process.env` per run; when absent (e.g., a run that didn't inject it), the tools report themselves unavailable rather than throwing.
@@ -34,6 +34,6 @@ Agents can read a web page the user links and search the web for current informa
3434
| Live: `agent:research` describes an image link / summarizes a PDF link end-to-end | `[agent]` In Slack: `@switchboard agent:research what does https://<public>.png show?` → the answer describes the picture; `… summarize https://<public>.pdf` → the answer cites the PDF's content. |
3535
| Timeout/HTTP-error handled gracefully | `[unit]` `::web_fetch tool::reports a timeout gracefully`, `::reports non-2xx status` |
3636
| web_search formats seam results; missing key degrades gracefully | `[unit]` `::web_search tool::formats results from the search seam`, `::degrades gracefully when search is not configured`, `::handles empty query and missing capability`, `::reports a generic search failure` |
37-
| Brave adapter calls the API correctly and parses results; ≥2 impls selectable | `[unit]` `::BraveWebSearch adapter::calls the Brave API with the key header and parses results`, `::throws on non-200`; `::makeWebCapability::selects Brave when a key is present, Null otherwise`, `::NullWebSearch throws WebSearchUnavailableError` |
37+
| Brave adapter calls the API correctly and parses results; null web results are empty; a failed response keeps safe observed details and a failed transport stays status-free; ≥2 impls selectable | `[unit]` `src/tools/web.test.ts::BraveWebSearch adapter::calls the Brave API with the key header and parses results`, `::treats a null Brave web section as no results`, `::throws on non-200`, `::keeps a Brave 429 code, request ID, rate limits and redacted detail on the tool result`, `::reports Brave transport failure without inventing an HTTP response or exposing the request URL`, `::uses the observed status and omits invalid Brave fields while capping the detail`, `::keeps HTTP status when Brave sends an unparseable error body`, `::reports a malformed successful Brave response as a decode failure`; `::makeWebCapability::selects Brave when a key is present, Null otherwise`, `::NullWebSearch throws WebSearchUnavailableError` |
3838
| Enablement: web_fetch broad (incl. `assistant`), web_search gated to the `web` and `explore` toolsets; general holds `assistant` without web_search or bash | `[unit]` `src/tools/web.test.ts::toolset + agent wiring::*` |
3939
| Live: `agent:research` answers a question / summarizes a URL end-to-end | `[agent]` In Slack: `@switchboard agent:research <a question needing current sources>` → the answer cites sources found via `web_search`; `… summarize <public URL>` → the answer reflects that page's content. The `web_search` half requires `BRAVE_SEARCH_API_KEY` to be provisioned on the deployment; without it the run must still answer, saying search is not configured. |

‎src/tools/web.test.ts‎

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -522,12 +522,88 @@ describe("BraveWebSearch adapter", () => {
522522
expect(results).toEqual([{ title: "Cat", url: "https://cats", snippet: "meow" }]);
523523
});
524524

525+
it("treats a null Brave web section as no results", async () => {
526+
const search = new BraveWebSearch(new Secret("key123", "BRAVE_SEARCH_API_KEY"), async () =>
527+
Response.json({ type: "search", web: null }),
528+
);
529+
const out = await webSearchTool.run({ query: "cats" }, ctxWith({ search }));
530+
expect(out).toBe('No results for "cats".');
531+
});
532+
525533
it("throws on non-200", async () => {
526534
const fetchSpy = vi.fn<FetchLike>(async () => fakeResponse({ status: 429 }));
527535
await expect(new BraveWebSearch(new Secret("k", "BRAVE_SEARCH_API_KEY"), fetchSpy).search("x")).rejects.toThrow(
528536
/429/,
529537
);
530538
});
539+
540+
it("keeps a Brave 429 code, request ID, rate limits and redacted detail on the tool result", async () => {
541+
const fetchSpy = vi.fn<FetchLike>(async () =>
542+
Response.json(
543+
{
544+
type: "ErrorResponse",
545+
error: {
546+
id: "request-123",
547+
status: 429,
548+
code: "RATE_LIMITED",
549+
detail:
550+
"Try https://private.example/path?token=secret or ghp_123456789012345678901234567890 for cats\nPRIVATE_SECOND_LINE",
551+
},
552+
},
553+
{
554+
status: 429,
555+
headers: { "x-ratelimit-remaining": "0, 1000", "x-ratelimit-reset": "1, 1419704" },
556+
},
557+
),
558+
);
559+
const search = new BraveWebSearch(new Secret("key123", "BRAVE_SEARCH_API_KEY"), fetchSpy);
560+
const out = await webSearchTool.run({ query: "cats" }, ctxWith({ search }));
561+
562+
expect(fetchSpy).toHaveBeenCalledOnce();
563+
expect(out).toBe(
564+
"web_search failed: Brave search HTTP 429; code RATE_LIMITED; request request-123; remaining 0,1000; reset 1,1419704s; detail Try «redacted-url» or «redacted-github-token» for «redacted-query»",
565+
);
566+
expect(out).not.toMatch(/private\.example|PRIVATE_SECOND_LINE|ghp_1234|key123/);
567+
});
568+
569+
it("reports Brave transport failure without inventing an HTTP response or exposing the request URL", async () => {
570+
const search = new BraveWebSearch(new Secret("key123", "BRAVE_SEARCH_API_KEY"), async () => {
571+
throw new Error("GET https://api.search.brave.com/search?q=PRIVATE_QUERY failed");
572+
});
573+
const out = await webSearchTool.run({ query: "PRIVATE_QUERY" }, ctxWith({ search }));
574+
expect(out).toBe("web_search failed: Brave search transport failed before a response");
575+
});
576+
577+
it("uses the observed status and omits invalid Brave fields while capping the detail", async () => {
578+
const search = new BraveWebSearch(new Secret("key123", "BRAVE_SEARCH_API_KEY"), async () =>
579+
Response.json(
580+
{
581+
type: "ErrorResponse",
582+
error: { id: "https://private.example/id", status: 401, code: "BAD;TOKEN", detail: "A".repeat(300) },
583+
},
584+
{ status: 429, headers: { "x-ratelimit-remaining": "0, PRIVATE", "x-ratelimit-reset": "-1" } },
585+
),
586+
);
587+
const out = await webSearchTool.run({ query: "cats" }, ctxWith({ search }));
588+
expect(out).toBe(`web_search failed: Brave search HTTP 429; detail ${"A".repeat(200)}…`);
589+
});
590+
591+
it("keeps HTTP status when Brave sends an unparseable error body", async () => {
592+
const search = new BraveWebSearch(
593+
new Secret("key123", "BRAVE_SEARCH_API_KEY"),
594+
async () => new Response("PRIVATE_HTML_BODY", { status: 403 }),
595+
);
596+
const out = await webSearchTool.run({ query: "cats" }, ctxWith({ search }));
597+
expect(out).toBe("web_search failed: Brave search HTTP 403");
598+
});
599+
600+
it("reports a malformed successful Brave response as a decode failure", async () => {
601+
const search = new BraveWebSearch(new Secret("key123", "BRAVE_SEARCH_API_KEY"), async () =>
602+
Response.json({ web: { results: "PRIVATE_BAD_ROWS" } }),
603+
);
604+
const out = await webSearchTool.run({ query: "cats" }, ctxWith({ search }));
605+
expect(out).toBe("web_search failed: Brave search HTTP 200 response was invalid");
606+
});
531607
});
532608

533609
describe("toolset + agent wiring", () => {

‎src/tools/web.ts‎

Lines changed: 116 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { lookup as dnsLookup } from "node:dns/promises";
22
import { Agent, fetch as undiciFetch } from "undici";
33
import { observeHttpRequest } from "../core/trace/httpTransport.js";
4+
import { oneLine, redactAndCap } from "../core/redact.js";
45
import type { Secret, Secrets } from "../secrets.js";
56
import type { RunnableTool } from "./runnableTool.js";
67

@@ -43,6 +44,97 @@ export class NullWebSearch implements WebSearch {
4344
}
4445
}
4546

47+
type BraveFailure = {
48+
kind: "http" | "transport" | "timeout" | "decode";
49+
status?: number;
50+
code?: string;
51+
requestId?: string;
52+
detail?: string;
53+
remaining?: readonly number[];
54+
resetSeconds?: readonly number[];
55+
};
56+
57+
class BraveSearchError extends Error {
58+
constructor(
59+
readonly facts: Readonly<BraveFailure>,
60+
cause?: unknown,
61+
) {
62+
const message =
63+
facts.kind === "http"
64+
? [
65+
`Brave search HTTP ${facts.status}`,
66+
...(facts.code ? [`code ${facts.code}`] : []),
67+
...(facts.requestId ? [`request ${facts.requestId}`] : []),
68+
...(facts.remaining ? [`remaining ${facts.remaining.join(",")}`] : []),
69+
...(facts.resetSeconds ? [`reset ${facts.resetSeconds.join(",")}s`] : []),
70+
...(facts.detail ? [`detail ${facts.detail}`] : []),
71+
].join("; ")
72+
: facts.kind === "decode"
73+
? `Brave search HTTP ${facts.status} response was invalid`
74+
: facts.kind === "timeout"
75+
? "Brave search timed out before a response"
76+
: `Brave search ${facts.kind} failed before a response`;
77+
super(message, { cause });
78+
this.name = "BraveSearchError";
79+
}
80+
}
81+
82+
function rateWindowFacts(value: string | null): readonly number[] | undefined {
83+
if (!value || value.length > 64 || !/^(?:0|[1-9][0-9]{0,9})(?:,\s*(?:0|[1-9][0-9]{0,9})){0,3}$/.test(value))
84+
return undefined;
85+
const numbers = value.split(",").map((part) => Number(part.trim()));
86+
return numbers.every((number) => number <= 1_000_000_000) ? numbers : undefined;
87+
}
88+
89+
function braveDetail(value: unknown, query: string, apiKey: string): string | undefined {
90+
if (typeof value !== "string") return undefined;
91+
const first = oneLine(value);
92+
if (!first) return undefined;
93+
const text = first
94+
.replaceAll(apiKey, "«redacted»")
95+
.replace(/\b[a-z][a-z0-9+.-]*:\/\/\S+/gi, "«redacted-url»")
96+
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, "«redacted-email»");
97+
return redactAndCap(query.length >= 4 ? text.replaceAll(query, "«redacted-query»") : text, 200);
98+
}
99+
100+
async function braveFailure(res: Response, query: string, apiKey: string): Promise<BraveSearchError> {
101+
let body: Awaited<ReturnType<typeof readCapped>>;
102+
try {
103+
body = await readCapped(res, 8_192);
104+
} catch (cause) {
105+
return new BraveSearchError({ kind: "decode", status: res.status }, cause);
106+
}
107+
let remote: Record<string, unknown> | undefined;
108+
if (!body.truncated) {
109+
try {
110+
const parsed: unknown = JSON.parse(body.bytes.toString("utf8"));
111+
if (
112+
parsed &&
113+
typeof parsed === "object" &&
114+
!Array.isArray(parsed) &&
115+
"type" in parsed &&
116+
parsed.type === "ErrorResponse"
117+
) {
118+
const error = (parsed as { error?: unknown }).error;
119+
if (error && typeof error === "object" && !Array.isArray(error)) remote = error as Record<string, unknown>;
120+
}
121+
} catch {
122+
// The HTTP failure still has a status; unparseable body text is not a provider message.
123+
}
124+
}
125+
const code = typeof remote?.code === "string" && /^[A-Z][A-Z_]{0,39}$/.test(remote.code) ? remote.code : undefined;
126+
const requestId = typeof remote?.id === "string" && /^[A-Za-z0-9-]{1,64}$/.test(remote.id) ? remote.id : undefined;
127+
return new BraveSearchError({
128+
kind: "http",
129+
status: res.status,
130+
...(code ? { code } : {}),
131+
...(requestId ? { requestId } : {}),
132+
...(remote ? { detail: braveDetail(remote.detail, query, apiKey) } : {}),
133+
remaining: rateWindowFacts(res.headers.get("x-ratelimit-remaining")),
134+
resetSeconds: rateWindowFacts(res.headers.get("x-ratelimit-reset")),
135+
});
136+
}
137+
46138
/** Real adapter: Brave Search API. Coded now; stays keyless until the operator
47139
* provisions BRAVE_SEARCH_API_KEY (a separate, deliberate decision). */
48140
export class BraveWebSearch implements WebSearch {
@@ -54,13 +146,30 @@ export class BraveWebSearch implements WebSearch {
54146
async search(query: string, opts?: { count?: number }): Promise<WebSearchResult[]> {
55147
const count = Math.min(Math.max(Math.trunc(opts?.count ?? 5), 1), 10);
56148
const url = `https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${count}`;
57-
const res = await this.fetchImpl(url, {
58-
headers: { Accept: "application/json", "X-Subscription-Token": this.apiKey.reveal() },
59-
signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
60-
});
61-
if (!res.ok) throw new Error(`brave search returned HTTP ${res.status}`);
62-
const data = (await res.json()) as { web?: { results?: Array<Record<string, unknown>> } };
63-
const results = data?.web?.results ?? [];
149+
let res: Response;
150+
try {
151+
res = await this.fetchImpl(url, {
152+
headers: { Accept: "application/json", "X-Subscription-Token": this.apiKey.reveal() },
153+
signal: AbortSignal.timeout(SEARCH_TIMEOUT_MS),
154+
});
155+
} catch (cause) {
156+
const timedOut = cause instanceof Error && (cause.name === "TimeoutError" || cause.name === "AbortError");
157+
throw new BraveSearchError({ kind: timedOut ? "timeout" : "transport" }, cause);
158+
}
159+
if (!res.ok) throw await braveFailure(res, query, this.apiKey.reveal());
160+
let results: Array<Record<string, unknown>>;
161+
try {
162+
const data: unknown = await res.json();
163+
if (!data || typeof data !== "object" || Array.isArray(data)) throw new TypeError("invalid Brave response");
164+
const web = (data as Record<string, unknown>).web;
165+
if (web != null && (typeof web !== "object" || Array.isArray(web))) throw new TypeError("invalid Brave response");
166+
const rows = web == null ? [] : ((web as Record<string, unknown>).results ?? []);
167+
if (!Array.isArray(rows) || rows.some((row) => !row || typeof row !== "object" || Array.isArray(row)))
168+
throw new TypeError("invalid Brave response");
169+
results = rows as Array<Record<string, unknown>>;
170+
} catch (cause) {
171+
throw new BraveSearchError({ kind: "decode", status: res.status }, cause);
172+
}
64173
return results.slice(0, count).map((r) => ({
65174
title: String(r.title ?? ""),
66175
url: String(r.url ?? ""),

0 commit comments

Comments
 (0)