Compare commits
3 Commits
4e67a265ac
...
v1.11.10-s
| Author | SHA1 | Date | |
|---|---|---|---|
| cc73ed1957 | |||
| 3e1e17ecf6 | |||
| ab01e04d77 |
12
CLAUDE.md
12
CLAUDE.md
@@ -33,7 +33,7 @@ npx tsc -p apps/web/tsconfig.app.json --noEmit # web app specifically
|
||||
docker compose build --no-cache boocode && docker compose up -d
|
||||
```
|
||||
|
||||
Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps/web` (adding it requires installing vitest as a new devDep). Vitest pinned to `^3` because Vite 5 / vitest 4 are incompatible. No linters configured.
|
||||
Tests: `pnpm -C apps/server test` runs the vitest suite. No test harness on `apps/web` (adding it requires installing vitest as a new devDep). Vitest pinned to `^3` because Vite 5 / vitest 4 are incompatible. No linters configured. Vitest include glob is `src/**/__tests__/**/*.test.ts` (see `apps/server/vitest.config.ts`) — tests outside `src/**/__tests__/` silently won't run; match the per-domain convention (`apps/server/src/services/__tests__/foo.test.ts`).
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -46,9 +46,10 @@ Tests: `pnpm -C apps/server test` runs 23 vitest tests. No test harness on `apps
|
||||
- **Zod** for request validation and config parsing.
|
||||
|
||||
Key services:
|
||||
- **`services/inference.ts`** — Streams LLM responses, executes tool loops (max depth 15, see `MAX_TOOL_LOOP_DEPTH`), flushes to DB every 500ms. Publishes `InferenceFrame` events through the broker.
|
||||
- **`services/inference.ts`** — Streams LLM responses, executes tool loops (max depth 15, see `MAX_TOOL_LOOP_DEPTH`), flushes to DB every 500ms. Publishes `InferenceFrame` events through the broker. **`TurnArgs`** is the per-turn state envelope threaded through the `executeToolPhase → runAssistantTurn` recursion (`toolsUsed`, `recentToolCalls`, `assistantMessageId`, `signal`); reset to defaults in `runInference` at the user-message boundary. Cap-hit (`toolsUsed >= budget`) and doom-loop (`detectDoomLoop(recentToolCalls)`) checks both read from this envelope. Add new per-turn state here, not in module-level closures.
|
||||
- **`services/broker.ts`** — In-memory pub/sub with two channel types: per-session (message streaming) and per-user (sidebar updates). No persistence; clients reconnect on restart.
|
||||
- **`services/tools.ts`** — Four read-only file tools exposed as OpenAI function-calling schemas. All file access goes through `path_guard.ts` which resolves against project root.
|
||||
- **`services/tools.ts`** — Tool registry (`ALL_TOOLS`, `READ_ONLY_TOOL_NAMES`, `TOOLS_BY_NAME`). Filesystem tools (view_file/list_dir/grep/find_files) go through three guard layers: `path_guard.ts` (workspace scope), `secret_guard.ts` (filename deny list), `url_guard.ts` (SSRF/private-IP block for web_fetch). v1.11.8+ web tools (`web_search`, `web_fetch`) are opt-in per chat via `session.web_search_enabled` (resolved with `project.default_web_search_enabled` fallback) and filtered out of the LLM's tool schema when false.
|
||||
- **`services/compaction.ts`** + **`services/model-context.ts`** — v1.11.0 anchored rolling summary (single `summary=true` assistant row per chat, supersedes itself on each compaction). Triggered when `chats.needs_compaction` is set after an inference turn exceeds `usable(ctx_max) = ctx_max - 20k`. **`ctx_max` comes from `model-context.getModelContext()` which fetches `${LLAMA_SWAP_URL}/upstream/<model>/props`** — NOT from `parsed.timings.n_ctx` (the stream completion's `timings` doesn't carry n_ctx; that read was dead code until v1.11.3 ripped it out).
|
||||
- **`services/file_ops.ts`** — Shared file operation implementations used by both inference tools and HTTP routes.
|
||||
- **`services/auto_name.ts`** — Non-streaming LLM call to generate 4-word session titles after first assistant reply.
|
||||
|
||||
@@ -98,7 +99,7 @@ Position-shift pattern for panes (legacy `session_panes` table): negate-and-rest
|
||||
|
||||
## Environment
|
||||
|
||||
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`.
|
||||
Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0.0.0), `PROJECT_ROOT_WHITELIST` (/opt, read-only scope for add-existing path resolution), `BOOTSTRAP_ROOT` (/opt/projects, writable scope for create-new-project bootstrap mkdir target — host must `mkdir -p /opt/projects` before container start), `DEFAULT_MODEL`, `LOG_LEVEL`, `SEARXNG_URL` (default `http://100.114.205.53:8888` — internal Tailscale Fathom; the public `search.indifferentketchup.com` is behind Authelia and unusable from server context).
|
||||
|
||||
## Workflow
|
||||
|
||||
@@ -128,3 +129,6 @@ Required: `DATABASE_URL`, `LLAMA_SWAP_URL`. Optional: `PORT` (3000), `HOST` (0.0
|
||||
- `vite.config.ts` proxy entries are order-sensitive: more-specific prefixes (`/api/term`, `/ws/term`) must come BEFORE `/api`.
|
||||
- Mobile pane URL sync (`Session.tsx`): the `?pane=<id>` effect resets `activePaneIdx` whenever `panes` changes. New-pane creation on mobile must push `?pane=` atomically — `addPaneAndSwitch` is the wrapper that does this. `addSplitPane` returns the new pane id for callers.
|
||||
- xterm.js v5 uses canvas rendering — browser doesn't see xterm's selection; the native right-click menu has no working Copy for terminal text. App keybindings (`Cmd/Ctrl-C`, `Cmd/Ctrl-Shift-C`) are the path.
|
||||
- **New tools** live in their own `services/<name>.ts` file (see `web_search.ts`, `web_fetch.ts`) — exports a pure `executeFoo(input, ...deps)` for direct test access plus a `ToolDef` wrapper that `loadConfig()`s its real dependencies. Register the ToolDef in `tools.ts` `ALL_TOOLS` (and `READ_ONLY_TOOL_NAMES` if applicable). Inject `fetcher: typeof fetch = fetch` rather than `vi.spyOn(globalThis, 'fetch')` — cleanup is simpler and the production call site stays unchanged.
|
||||
- **Sentinels** are `role='system'` rows with structured `metadata.kind` (`cap_hit`, `doom_loop`). UI-only — `buildMessagesPayload` strips them via `isAnySentinel` so the LLM never sees them. A new kind requires arms in `MessageMetadata` in BOTH `apps/server/src/types/api.ts` AND `apps/web/src/api/types.ts`, plus a render branch in `apps/web/src/components/MessageBubble.tsx`.
|
||||
- **ReadableStream test stubs** use `pull()` (not `start()`) so chunks are produced lazily — `start()` enqueues everything and calls `controller.close()` before the consumer reads, so a subsequent `reader.cancel()` finds the stream already closed and the `cancel()` callback never fires. Also provide MORE chunks than the test will consume so the source stays in 'readable' state when cancel runs (e.g. cap test reads ~6 chunks, stub provides 10).
|
||||
|
||||
@@ -295,9 +295,10 @@ describe('executeWebFetch — size + truncation', () => {
|
||||
// 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.
|
||||
// v1.11.10: streaming reader catches this as body_too_large (was
|
||||
// response_too_large in the post-consumption check). No
|
||||
// Content-Length header so the pre-flight pass and the streaming
|
||||
// path is the one that rejects.
|
||||
const heavy = '😀'.repeat(1_500_000);
|
||||
const fakeFetch = vi.fn().mockResolvedValue(
|
||||
new Response(heavy, { status: 200, headers: { 'content-type': 'text/plain' } }),
|
||||
@@ -308,9 +309,8 @@ describe('executeWebFetch — size + truncation', () => {
|
||||
);
|
||||
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/);
|
||||
expect(result.error).toBe('body_too_large');
|
||||
expect(result.reason).toMatch(/exceeded/);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -343,3 +343,248 @@ describe('executeWebFetch — size + truncation', () => {
|
||||
expect('content' in result && result.truncated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// v1.11.9: manual redirect handling — re-run URL guard on each hop
|
||||
// ============================================================================
|
||||
|
||||
// Helper: build a 30x redirect Response. status 302 by default; tests
|
||||
// pass other codes (or omit the Location header) when they need to.
|
||||
function redirect(loc: string | null, status = 302): Response {
|
||||
const headers: Record<string, string> = {};
|
||||
if (loc !== null) headers['location'] = loc;
|
||||
return new Response('', { status, headers });
|
||||
}
|
||||
|
||||
describe('executeWebFetch — redirect handling', () => {
|
||||
it('blocks a redirect target that resolves to a private IP (AWS IMDS)', async () => {
|
||||
// Public-IP origin 302s into 169.254.169.254 (link-local). Pre-v1.11.9
|
||||
// `redirect: 'follow'` would silently follow this; the new manual
|
||||
// loop re-runs isPublicUrl on the resolved target and blocks.
|
||||
const fakeFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(redirect('http://169.254.169.254/latest/meta-data/'));
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://example.com/redirect' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
expect('error' in result).toBe(true);
|
||||
if ('error' in result) {
|
||||
expect(result.error).toBe('blocked_by_url_guard');
|
||||
// Reason should make it clear this was a REDIRECT hop, not the
|
||||
// initial URL — so logs can distinguish the two failure modes.
|
||||
expect(result.reason).toMatch(/redirect target/);
|
||||
}
|
||||
// Critical: the second fetch (the private target) must NOT happen.
|
||||
expect(fakeFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('follows a public-to-public redirect and returns the final body', async () => {
|
||||
const fakeFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(redirect('https://example.org/final'))
|
||||
.mockResolvedValueOnce(mockResponse('ok body', { contentType: 'text/plain' }));
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://example.com/start' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
expect('content' in result).toBe(true);
|
||||
if ('content' in result) {
|
||||
expect(result.content).toBe('ok body');
|
||||
// Final URL is reported back so the model knows where the body came from.
|
||||
expect(result.url).toBe('https://example.org/final');
|
||||
}
|
||||
expect(fakeFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('bails after MAX_REDIRECTS hops with a Too many redirects error', async () => {
|
||||
// Chain 6 redirects — one more than the loop allows. Each Location
|
||||
// points at a distinct public host so the URL guard stays happy and
|
||||
// we exercise the redirectCount > MAX_REDIRECTS branch specifically.
|
||||
const fakeFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(redirect('https://a.example/'))
|
||||
.mockResolvedValueOnce(redirect('https://b.example/'))
|
||||
.mockResolvedValueOnce(redirect('https://c.example/'))
|
||||
.mockResolvedValueOnce(redirect('https://d.example/'))
|
||||
.mockResolvedValueOnce(redirect('https://e.example/'))
|
||||
.mockResolvedValueOnce(redirect('https://f.example/'));
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://start.example/' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
expect('error' in result).toBe(true);
|
||||
if ('error' in result) {
|
||||
expect(result.error).toBe('too_many_redirects');
|
||||
expect(result.reason).toMatch(/Too many redirects/);
|
||||
}
|
||||
});
|
||||
|
||||
it('errors when a 30x response omits the Location header', async () => {
|
||||
const fakeFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(redirect(null, 302));
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://example.com/' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
expect('error' in result).toBe(true);
|
||||
if ('error' in result) {
|
||||
expect(result.error).toBe('redirect_missing_location');
|
||||
expect(result.reason).toMatch(/no Location/);
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves a relative Location against the current URL', async () => {
|
||||
// Server sends `Location: /foo` (relative) on a request to
|
||||
// https://example.com/path. RFC 9110 says resolve against the
|
||||
// request URL, so the next hop is https://example.com/foo. Assert
|
||||
// the second fetch was called with the absolute resolved URL.
|
||||
const fakeFetch = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockResolvedValueOnce(redirect('/foo'))
|
||||
.mockResolvedValueOnce(mockResponse('final', { contentType: 'text/plain' }));
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://example.com/path' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
expect('content' in result && result.content).toBe('final');
|
||||
expect(fakeFetch).toHaveBeenCalledTimes(2);
|
||||
expect(fakeFetch.mock.calls[1]![0]).toBe('https://example.com/foo');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// v1.11.10: streaming body cap — abort the response stream at MAX_BYTES
|
||||
// ============================================================================
|
||||
|
||||
// MAX_BYTES is 5 * 1024 * 1024 = 5_242_880. Repeating this here (rather
|
||||
// than importing) so a change to the cap surfaces as a test failure —
|
||||
// the limit is part of the public contract.
|
||||
const MAX_BYTES_TEST = 5 * 1024 * 1024;
|
||||
|
||||
// Build a Response whose body is a real ReadableStream. Uses pull() (not
|
||||
// start()) so chunks are produced lazily — without backpressure, an
|
||||
// unbounded start() enqueues everything and calls controller.close()
|
||||
// before the consumer reads, which means a subsequent reader.cancel()
|
||||
// finds the stream already closed and the cancel callback never fires.
|
||||
// `cancelFlag` lets the test observe whether reader.cancel() reached the
|
||||
// underlying source mid-stream.
|
||||
function streamedResponse(
|
||||
chunks: Uint8Array[],
|
||||
init: { contentType?: string; contentLength?: number | null; cancelFlag?: { cancelled: boolean } } = {},
|
||||
): Response {
|
||||
let idx = 0;
|
||||
const stream = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (idx >= chunks.length) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
controller.enqueue(chunks[idx]!);
|
||||
idx += 1;
|
||||
},
|
||||
cancel() {
|
||||
if (init.cancelFlag) init.cancelFlag.cancelled = true;
|
||||
},
|
||||
});
|
||||
const headers: Record<string, string> = {};
|
||||
if (init.contentType) headers['content-type'] = init.contentType;
|
||||
if (init.contentLength !== undefined && init.contentLength !== null) {
|
||||
headers['content-length'] = String(init.contentLength);
|
||||
}
|
||||
return new Response(stream, { status: 200, headers });
|
||||
}
|
||||
|
||||
describe('executeWebFetch — streaming body cap (v1.11.10)', () => {
|
||||
it('aborts the stream when a server lies about Content-Length and emits over the cap', async () => {
|
||||
// Honest header would have failed the pre-flight check. The lie is
|
||||
// the point: pre-flight passes (100 < 5MB) and the streaming reader
|
||||
// has to be the thing that catches the oversized body.
|
||||
//
|
||||
// Chunk count is deliberately higher than what the reader will
|
||||
// consume (10 × 1MB available, but the reader will cancel after ~6
|
||||
// chunks land it over 5MB). That headroom keeps the stream in
|
||||
// 'readable' state at the moment reader.cancel() runs — otherwise
|
||||
// a pull-then-close race could make the source close the stream
|
||||
// before cancel reaches it, and the cancel() callback wouldn't fire.
|
||||
const oneMB = new Uint8Array(1024 * 1024).fill(65); // 'A'
|
||||
const tenMBInChunks = Array.from({ length: 10 }, () => oneMB);
|
||||
const cancelFlag = { cancelled: false };
|
||||
const fakeFetch = vi.fn().mockResolvedValue(
|
||||
streamedResponse(tenMBInChunks, {
|
||||
contentType: 'text/plain',
|
||||
contentLength: 100,
|
||||
cancelFlag,
|
||||
}),
|
||||
);
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://example.com/lying-server' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
expect('error' in result).toBe(true);
|
||||
if ('error' in result) {
|
||||
expect(result.error).toBe('body_too_large');
|
||||
expect(result.reason).toMatch(/exceeded/);
|
||||
}
|
||||
// Critical: reader.cancel() actually fired so the underlying
|
||||
// connection / stream got released. Otherwise the abort would be
|
||||
// notional and the server could keep streaming.
|
||||
expect(cancelFlag.cancelled).toBe(true);
|
||||
});
|
||||
|
||||
it('catches an oversized stream when Content-Length is omitted entirely', async () => {
|
||||
// Many real servers (chunked transfer-encoding, dynamic responses)
|
||||
// never send Content-Length. The pre-flight check has nothing to
|
||||
// gate on; the streaming reader is the only line of defense.
|
||||
// 10 chunks vs the ~6 the reader will consume — same headroom
|
||||
// rationale as the lying-Content-Length test above.
|
||||
const oneMB = new Uint8Array(1024 * 1024).fill(66); // 'B'
|
||||
const tenMBInChunks = Array.from({ length: 10 }, () => oneMB);
|
||||
const fakeFetch = vi.fn().mockResolvedValue(
|
||||
streamedResponse(tenMBInChunks, { contentType: 'text/plain' }),
|
||||
);
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://example.com/no-length' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
expect('error' in result && result.error).toBe('body_too_large');
|
||||
});
|
||||
|
||||
it('passes a multi-chunk body that totals just under the cap', async () => {
|
||||
// Boundary case: MAX_BYTES - 1 bytes split across N chunks. The
|
||||
// streaming reader's `total > maxBytes` check is strict-greater so
|
||||
// exactly MAX_BYTES would still succeed; MAX_BYTES + 1 would fail.
|
||||
// - 1 leaves clear headroom without coinciding with the boundary.
|
||||
const targetTotal = MAX_BYTES_TEST - 1;
|
||||
const chunkSize = 256 * 1024; // 256 KiB chunks
|
||||
const chunks: Uint8Array[] = [];
|
||||
let remaining = targetTotal;
|
||||
while (remaining > 0) {
|
||||
const size = Math.min(chunkSize, remaining);
|
||||
chunks.push(new Uint8Array(size).fill(67)); // 'C'
|
||||
remaining -= size;
|
||||
}
|
||||
const fakeFetch = vi.fn().mockResolvedValue(
|
||||
streamedResponse(chunks, { contentType: 'text/plain' }),
|
||||
);
|
||||
const result = await executeWebFetch(
|
||||
{ url: 'https://example.com/right-at-cap' },
|
||||
fakeFetch as unknown as typeof fetch,
|
||||
);
|
||||
// The streaming reader succeeded — we got a content shape, not an
|
||||
// error. (Downstream truncate() will clamp the final string to
|
||||
// MAX_CHARS_CAP=32000 and set truncated:true; that's the existing
|
||||
// truncation logic and is exercised by its own test. The point of
|
||||
// THIS test is that readBodyCapped didn't trip on a body that
|
||||
// sits just under its byte limit.)
|
||||
expect('content' in result).toBe(true);
|
||||
if ('content' in result) {
|
||||
expect(result.content.length).toBeGreaterThan(0);
|
||||
// All ASCII 'C's, so the leading 200 chars before any truncation
|
||||
// marker should be all C — proves we read real bytes through the
|
||||
// streaming reader rather than getting an empty buffer.
|
||||
expect(result.content.slice(0, 200)).toBe('C'.repeat(200));
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,9 @@ const DEFAULT_MAX_CHARS = 8_000;
|
||||
const MAX_CHARS_CAP = 32_000;
|
||||
const FETCH_TIMEOUT_MS = 15_000;
|
||||
const MAX_BYTES = 5 * 1024 * 1024;
|
||||
// v1.11.9: cap redirect chains. Each hop re-runs isPublicUrl on the
|
||||
// resolved target so a public-IP origin can't 302 us into a private IP.
|
||||
const MAX_REDIRECTS = 5;
|
||||
|
||||
// Output shape. Each variant uses a discriminator the LLM can branch on.
|
||||
export type WebFetchOutput =
|
||||
@@ -59,6 +62,39 @@ function stripHtml(html: string): { text: string; title: string | undefined } {
|
||||
return { text, title };
|
||||
}
|
||||
|
||||
// v1.11.10: streaming body reader. Aborts the response stream the instant
|
||||
// cumulative bytes cross maxBytes, so a server that lies about
|
||||
// Content-Length (or omits it entirely) can't make us buffer gigabytes
|
||||
// before the post-read check fires. reader.cancel() releases the
|
||||
// underlying connection on the spot.
|
||||
async function readBodyCapped(
|
||||
res: Response,
|
||||
maxBytes: number,
|
||||
): Promise<{ ok: true; body: string } | { ok: false; bytesRead: number }> {
|
||||
if (!res.body) return { ok: true, body: '' };
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let total = 0;
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.byteLength;
|
||||
if (total > maxBytes) {
|
||||
// Best-effort cancel — surfaces on the server side as a closed
|
||||
// connection and (in our tests) fires the ReadableStream's
|
||||
// cancel() callback so we can assert the abort happened.
|
||||
await reader.cancel();
|
||||
return { ok: false, bytesRead: total };
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
} finally {
|
||||
try { reader.releaseLock(); } catch { /* already released by cancel() */ }
|
||||
}
|
||||
return { ok: true, body: Buffer.concat(chunks).toString('utf8') };
|
||||
}
|
||||
|
||||
function truncate(text: string, max: number): { content: string; truncated: boolean } {
|
||||
if (text.length <= max) return { content: text, truncated: false };
|
||||
const omitted = text.length - max;
|
||||
@@ -74,89 +110,136 @@ export async function executeWebFetch(
|
||||
input: WebFetchInputT,
|
||||
fetcher: typeof fetch = fetch,
|
||||
): Promise<WebFetchOutput> {
|
||||
const guard = isPublicUrl(input.url);
|
||||
if (!guard.ok) {
|
||||
return { error: 'blocked_by_url_guard', reason: guard.reason ?? 'unknown' };
|
||||
}
|
||||
|
||||
const maxChars = Math.min(input.max_chars ?? DEFAULT_MAX_CHARS, MAX_CHARS_CAP);
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await fetcher(input.url, {
|
||||
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',
|
||||
headers: { 'User-Agent': 'BooCode/1.11.8', Accept: 'text/html,text/plain,application/json,*/*' },
|
||||
});
|
||||
if (!res.ok) {
|
||||
return { error: 'upstream_status', reason: `HTTP ${res.status}` };
|
||||
}
|
||||
// Pre-flight size check via Content-Length when the server provides it.
|
||||
const lenHeader = res.headers.get('content-length');
|
||||
if (lenHeader) {
|
||||
const len = Number(lenHeader);
|
||||
if (Number.isFinite(len) && len > MAX_BYTES) {
|
||||
return { error: 'response_too_large', reason: `Content-Length ${len} > ${MAX_BYTES}` };
|
||||
}
|
||||
}
|
||||
const contentType = (res.headers.get('content-type') ?? '').toLowerCase();
|
||||
// Read body. We rely on the 5MB cap by checking length after consumption
|
||||
// — most malicious or accidental large responses also exceed it via the
|
||||
// Content-Length pre-flight above. A truly hostile server that lies
|
||||
// about length AND streams gigabytes would defeat that; for v1.11.8
|
||||
// the 15s timeout is the secondary fence.
|
||||
const body = await res.text();
|
||||
// v1.11.8 review: byte-count, not char-count. A 5MB cap on
|
||||
// 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}` };
|
||||
}
|
||||
// v1.11.9: manual redirect handling. `redirect: 'follow'` in fetch
|
||||
// doesn't expose intermediate hops — a public-IP origin that 302s us
|
||||
// to 169.254.169.254 would silently bypass isPublicUrl. We follow each
|
||||
// hop ourselves, re-running the URL guard on the resolved target so a
|
||||
// mid-chain hostile redirect gets blocked.
|
||||
//
|
||||
// Timeout semantics changed from v1.11.8: AbortSignal.timeout fires
|
||||
// per fetch hop (vs. one 15s budget shared across the whole call). In
|
||||
// the worst case a 5-hop chain can take ~5×15s before erroring — still
|
||||
// bounded; trades a longer cap for simpler code.
|
||||
let currentUrl = input.url;
|
||||
let res: Response | undefined;
|
||||
let redirectCount = 0;
|
||||
|
||||
let textRaw: string;
|
||||
let title: string | undefined;
|
||||
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
|
||||
const stripped = stripHtml(body);
|
||||
textRaw = stripped.text;
|
||||
title = stripped.title;
|
||||
} else if (
|
||||
contentType.includes('text/plain') ||
|
||||
contentType.includes('text/markdown') ||
|
||||
contentType.includes('application/json') ||
|
||||
contentType.includes('text/xml') ||
|
||||
contentType.includes('application/xml')
|
||||
) {
|
||||
textRaw = body;
|
||||
} else {
|
||||
while (true) {
|
||||
const guard = isPublicUrl(currentUrl);
|
||||
if (!guard.ok) {
|
||||
return {
|
||||
error: 'unsupported_content_type',
|
||||
reason: `content-type ${contentType || '(none)'} not supported`,
|
||||
content_type: contentType,
|
||||
error: 'blocked_by_url_guard',
|
||||
reason: redirectCount === 0
|
||||
? (guard.reason ?? 'unknown')
|
||||
: `redirect target ${currentUrl} blocked: ${guard.reason ?? 'unknown'}`,
|
||||
};
|
||||
}
|
||||
|
||||
const truncated = truncate(textRaw, maxChars);
|
||||
return {
|
||||
url: input.url,
|
||||
title,
|
||||
content: truncated.content,
|
||||
content_type: contentType,
|
||||
truncated: truncated.truncated,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (err instanceof Error && err.name === 'AbortError') {
|
||||
return { error: 'timeout', reason: `aborted after ${FETCH_TIMEOUT_MS}ms` };
|
||||
try {
|
||||
res = await fetcher(currentUrl, {
|
||||
method: 'GET',
|
||||
redirect: 'manual',
|
||||
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
||||
headers: {
|
||||
'User-Agent': 'BooCode/1.11.9',
|
||||
Accept: 'text/html,text/plain,application/json,*/*',
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
// AbortSignal.timeout fires a DOMException with name 'TimeoutError';
|
||||
// older runtimes / polyfills may surface 'AbortError'. Treat both.
|
||||
if (err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError')) {
|
||||
return { error: 'timeout', reason: `aborted after ${FETCH_TIMEOUT_MS}ms` };
|
||||
}
|
||||
return { error: 'fetch_failed', reason: msg };
|
||||
}
|
||||
return { error: 'fetch_failed', reason: msg };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
|
||||
if (res.status >= 300 && res.status < 400) {
|
||||
const loc = res.headers.get('location');
|
||||
if (!loc) {
|
||||
return {
|
||||
error: 'redirect_missing_location',
|
||||
reason: `${res.status} redirect with no Location header`,
|
||||
};
|
||||
}
|
||||
redirectCount += 1;
|
||||
if (redirectCount > MAX_REDIRECTS) {
|
||||
return {
|
||||
error: 'too_many_redirects',
|
||||
reason: `Too many redirects (exceeded ${MAX_REDIRECTS} hops)`,
|
||||
};
|
||||
}
|
||||
// Resolve relative Location against the URL we just hit (RFC 9110).
|
||||
// The next loop iteration re-runs isPublicUrl on the new currentUrl.
|
||||
currentUrl = new URL(loc, currentUrl).toString();
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
return { error: 'upstream_status', reason: `HTTP ${res.status}` };
|
||||
}
|
||||
// Pre-flight size check via Content-Length when the server provides it.
|
||||
const lenHeader = res.headers.get('content-length');
|
||||
if (lenHeader) {
|
||||
const len = Number(lenHeader);
|
||||
if (Number.isFinite(len) && len > MAX_BYTES) {
|
||||
return { error: 'response_too_large', reason: `Content-Length ${len} > ${MAX_BYTES}` };
|
||||
}
|
||||
}
|
||||
const contentType = (res.headers.get('content-type') ?? '').toLowerCase();
|
||||
// v1.11.10: stream the body with a hard byte cap. Previously we read
|
||||
// res.text() in one shot and then byte-length-checked — a server that
|
||||
// lies about Content-Length (or omits it) could make us buffer
|
||||
// gigabytes before the post-check fired. readBodyCapped aborts the
|
||||
// stream the instant total bytes cross MAX_BYTES. The Content-Length
|
||||
// pre-flight above stays as a cheap early reject for honest servers.
|
||||
const read = await readBodyCapped(res, MAX_BYTES);
|
||||
if (!read.ok) {
|
||||
return {
|
||||
error: 'body_too_large',
|
||||
reason: `Response body exceeded ${MAX_BYTES} bytes (read ${read.bytesRead} before abort)`,
|
||||
};
|
||||
}
|
||||
const body = read.body;
|
||||
|
||||
let textRaw: string;
|
||||
let title: string | undefined;
|
||||
if (contentType.includes('text/html') || contentType.includes('application/xhtml')) {
|
||||
const stripped = stripHtml(body);
|
||||
textRaw = stripped.text;
|
||||
title = stripped.title;
|
||||
} else if (
|
||||
contentType.includes('text/plain') ||
|
||||
contentType.includes('text/markdown') ||
|
||||
contentType.includes('application/json') ||
|
||||
contentType.includes('text/xml') ||
|
||||
contentType.includes('application/xml')
|
||||
) {
|
||||
textRaw = body;
|
||||
} else {
|
||||
return {
|
||||
error: 'unsupported_content_type',
|
||||
reason: `content-type ${contentType || '(none)'} not supported`,
|
||||
content_type: contentType,
|
||||
};
|
||||
}
|
||||
|
||||
const truncated = truncate(textRaw, maxChars);
|
||||
// Report the FINAL URL (post-redirects) so the LLM knows where the body
|
||||
// came from — useful for citations and for the model to reason about
|
||||
// domain trust.
|
||||
return {
|
||||
url: currentUrl,
|
||||
title,
|
||||
content: truncated.content,
|
||||
content_type: contentType,
|
||||
truncated: truncated.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
export const webFetch: ToolDef<WebFetchInputT> = {
|
||||
|
||||
Reference in New Issue
Block a user