v1.11.8: address review — inject fetcher, byte-count limit, redirect TODO
This commit is contained in:
@@ -170,6 +170,28 @@ describe('executeWebSearch', () => {
|
|||||||
expect(out.results).toHaveLength(1);
|
expect(out.results).toHaveLength(1);
|
||||||
expect(out.results[0]!.url).toBe('https://ok/');
|
expect(out.results[0]!.url).toBe('https://ok/');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('uses the injected fetcher when one is passed (v1.11.8 review)', async () => {
|
||||||
|
// Direct injection vs vi.spyOn(globalThis, 'fetch'): the injected
|
||||||
|
// path lets tests run without monkey-patching globals, and the
|
||||||
|
// production code path defaults to global fetch when no fetcher is
|
||||||
|
// supplied. Asserts the stub is the thing actually called.
|
||||||
|
const globalSpy = vi.spyOn(globalThis, 'fetch');
|
||||||
|
const stub = vi.fn().mockResolvedValue(
|
||||||
|
mockResponse(
|
||||||
|
{ results: [{ title: 'injected', url: 'https://inj/', content: 's' }] },
|
||||||
|
{ contentType: 'application/json' },
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const out = await executeWebSearch(
|
||||||
|
{ query: 'q' },
|
||||||
|
TEST_SEARXNG,
|
||||||
|
stub as unknown as typeof fetch,
|
||||||
|
);
|
||||||
|
expect(stub).toHaveBeenCalledOnce();
|
||||||
|
expect(globalSpy).not.toHaveBeenCalled();
|
||||||
|
expect(out.results[0]!.url).toBe('https://inj/');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -269,6 +291,29 @@ describe('executeWebFetch — size + truncation', () => {
|
|||||||
expect('error' in result && result.error).toBe('response_too_large');
|
expect('error' in result && result.error).toBe('response_too_large');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects multi-byte content that exceeds 5MB in bytes but fits in chars (v1.11.8 review)', async () => {
|
||||||
|
// 1.5M U+1F600 emojis: each is length 2 in UTF-16 (surrogate pair) and
|
||||||
|
// 4 bytes in UTF-8. body.length = 3,000,000 chars (~2.86 MiB by
|
||||||
|
// UTF-16 count) but Buffer.byteLength = 6,000,000 bytes (>5 MiB).
|
||||||
|
// Pre-fix the char-count comparison let this through; the byte-count
|
||||||
|
// check now rejects. No Content-Length header so the pre-flight
|
||||||
|
// guard doesn't fire — we're testing the POST-consumption check.
|
||||||
|
const heavy = '😀'.repeat(1_500_000);
|
||||||
|
const fakeFetch = vi.fn().mockResolvedValue(
|
||||||
|
new Response(heavy, { status: 200, headers: { 'content-type': 'text/plain' } }),
|
||||||
|
);
|
||||||
|
const result = await executeWebFetch(
|
||||||
|
{ url: 'https://example.com/multibyte' },
|
||||||
|
fakeFetch as unknown as typeof fetch,
|
||||||
|
);
|
||||||
|
expect('error' in result).toBe(true);
|
||||||
|
if ('error' in result) {
|
||||||
|
expect(result.error).toBe('response_too_large');
|
||||||
|
// Error reason should reference bytes, not character count.
|
||||||
|
expect(result.reason).toMatch(/bytes/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it('truncates output to max_chars and appends a marker', async () => {
|
it('truncates output to max_chars and appends a marker', async () => {
|
||||||
const big = 'A'.repeat(50_000);
|
const big = 'A'.repeat(50_000);
|
||||||
const fakeFetch = vi.fn().mockResolvedValue(
|
const fakeFetch = vi.fn().mockResolvedValue(
|
||||||
|
|||||||
@@ -86,6 +86,8 @@ export async function executeWebFetch(
|
|||||||
try {
|
try {
|
||||||
const res = await fetcher(input.url, {
|
const res = await fetcher(input.url, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
|
// TODO(v1.11.9): redirect: 'manual' + re-run isPublicUrl on Location header.
|
||||||
|
// Current 'follow' allows redirect-to-private-IP bypass of URL guard.
|
||||||
redirect: 'follow',
|
redirect: 'follow',
|
||||||
headers: { 'User-Agent': 'BooCode/1.11.8', Accept: 'text/html,text/plain,application/json,*/*' },
|
headers: { 'User-Agent': 'BooCode/1.11.8', Accept: 'text/html,text/plain,application/json,*/*' },
|
||||||
});
|
});
|
||||||
@@ -107,8 +109,13 @@ export async function executeWebFetch(
|
|||||||
// about length AND streams gigabytes would defeat that; for v1.11.8
|
// about length AND streams gigabytes would defeat that; for v1.11.8
|
||||||
// the 15s timeout is the secondary fence.
|
// the 15s timeout is the secondary fence.
|
||||||
const body = await res.text();
|
const body = await res.text();
|
||||||
if (body.length > MAX_BYTES) {
|
// v1.11.8 review: byte-count, not char-count. A 5MB cap on
|
||||||
return { error: 'response_too_large', reason: `body ${body.length} > ${MAX_BYTES}` };
|
// body.length (UTF-16 code units) lets a multi-byte payload (emoji,
|
||||||
|
// CJK) pass when its wire size already exceeded MAX_BYTES. Compute
|
||||||
|
// once and reuse for the error message.
|
||||||
|
const bodyBytes = Buffer.byteLength(body, 'utf8');
|
||||||
|
if (bodyBytes > MAX_BYTES) {
|
||||||
|
return { error: 'response_too_large', reason: `body ${bodyBytes} bytes > ${MAX_BYTES}` };
|
||||||
}
|
}
|
||||||
|
|
||||||
let textRaw: string;
|
let textRaw: string;
|
||||||
|
|||||||
@@ -35,16 +35,19 @@ export interface WebSearchOutput {
|
|||||||
// with a mocked fetch. Throws on network / non-200 — the executeToolCall
|
// with a mocked fetch. Throws on network / non-200 — the executeToolCall
|
||||||
// wrapper in inference.ts turns the thrown message into the LLM-visible
|
// wrapper in inference.ts turns the thrown message into the LLM-visible
|
||||||
// error string.
|
// error string.
|
||||||
|
// v1.11.8 review: fetcher injection. Mirrors executeWebFetch's signature
|
||||||
|
// so tests can pass a vi.fn() stub without monkey-patching globalThis.
|
||||||
export async function executeWebSearch(
|
export async function executeWebSearch(
|
||||||
input: WebSearchInputT,
|
input: WebSearchInputT,
|
||||||
searxngUrl: string,
|
searxngUrl: string,
|
||||||
|
fetcher: typeof fetch = fetch,
|
||||||
): Promise<WebSearchOutput> {
|
): Promise<WebSearchOutput> {
|
||||||
const cap = Math.min(Math.max(1, input.max_results ?? DEFAULT_RESULTS), MAX_RESULTS_CAP);
|
const cap = Math.min(Math.max(1, input.max_results ?? DEFAULT_RESULTS), MAX_RESULTS_CAP);
|
||||||
const url = `${searxngUrl}/search?q=${encodeURIComponent(input.query)}&format=json`;
|
const url = `${searxngUrl}/search?q=${encodeURIComponent(input.query)}&format=json`;
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetcher(url, {
|
||||||
signal: controller.signal,
|
signal: controller.signal,
|
||||||
headers: { 'User-Agent': 'BooCode/1.11.8' },
|
headers: { 'User-Agent': 'BooCode/1.11.8' },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user