fix(web): reject credentialed search redirects

This commit is contained in:
Tianyi Cui
2026-07-19 21:32:23 +08:00
parent f84092045c
commit 5e0e4b2401
10 changed files with 135 additions and 6 deletions

View File

@@ -23,7 +23,7 @@ This is an **implementation** package: it registers a provider into `ctx.web`, i
## Mapping
Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. A request's `maxResults` wins over the configured `numResults` default and is sent as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`.
Exa returns a flat `results[]` and no generated answer, so `content` is omitted. Each result maps to a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first non-empty `highlights[]` entry (a result with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. A request's `maxResults` wins over the configured `numResults` default and is sent as Exa's `numResults` for a cost/latency optimization; the final bound is enforced by the seam. Provider failures (HTTP errors, network failure, unparseable or wrong-shape bodies) surface as `WebError` `WEB_PROVIDER_ERROR`; an aborted request surfaces as `WEB_ABORTED`. HTTP redirects are rejected before the `Location` target is contacted and surface as `WEB_PROVIDER_ERROR`.
## Model Experience

View File

@@ -80,7 +80,7 @@ export function mapExaResponse(response: ExaSearchResponse): WebSearchResult {
return { sources, truncated: false }
}
/** The Exa-backed search provider. */
/** The Exa-backed search provider; HTTP redirects fail as `WEB_PROVIDER_ERROR`. */
export class ExaSearchProvider implements WebSearchProvider {
readonly id = EXA_PROVIDER_ID
@@ -100,6 +100,7 @@ export class ExaSearchProvider implements WebSearchProvider {
try {
response = await fetch(`${this.options.baseURL}/search`, {
method: 'POST',
redirect: 'error',
headers: {
'authorization': `Bearer ${this.options.apiKey}`,
'content-type': 'application/json',

View File

@@ -96,6 +96,7 @@ describe('ExaSearchProvider request mapping', () => {
expect(fetchMock).toHaveBeenCalledOnce()
const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]
expect(url).toBe('https://api.exa.test/search')
expect(init).toMatchObject({ method: 'POST', redirect: 'error' })
expect((init.headers as Record<string, string>)['authorization']).toBe('Bearer exa-key')
expect(JSON.parse(init.body as string)).toEqual({
query: 'hello',