Introduce web access as a first-class capability seam so the model-facing web tools stay stable while backends change. dsh-web owns ctx.web as a provider registry with registration-order-independent selection and the WebError taxonomy; dsh-web-search-exa, dsh-web-search-perplexity, and dsh-web-fetch-local register capabilities into it; dsh-tool-web is the sole owner of the model-facing web_search/web_fetch schemas, prompt sections, and HTML-to-markdown presentation. Search and fetch are deliberately one seam. Providers ship as namespace plugins that register into ctx.web (like an LlmAdapter into ctx.llm), not key-owning services, since multiple search providers cannot each own the key. Tool registration follows product enablement, not backend availability, so load order/credentials never enter the model contract; the seam resolves the provider at execution time and surfaces a structured WebError otherwise. Moves the RFC to implemented/ amended to match what shipped. Example/app configs are intentionally not wired yet (RFC migration step 6).
240 lines
11 KiB
TypeScript
240 lines
11 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
|
import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http'
|
|
import { AddressInfo } from 'node:net'
|
|
import { Context } from 'cordis'
|
|
import WebService from '@deepseek-ai/dsh-web'
|
|
import { LocalFetchProvider, LOCAL_FETCH_PROVIDER_ID, classifyContentType, isSameOrigin, validateFetchUrl } from '@deepseek-ai/dsh-web-fetch-local'
|
|
import type { LocalFetchLimits } from '@deepseek-ai/dsh-web-fetch-local'
|
|
import * as fetchPlugin from '@deepseek-ai/dsh-web-fetch-local'
|
|
|
|
const limits: LocalFetchLimits = {
|
|
maxUrlLength: 2048,
|
|
maxResponseBytes: 5_000_000,
|
|
maxBodyChars: 100_000,
|
|
timeoutMs: 5_000,
|
|
maxTimeoutMs: 10_000,
|
|
maxRedirects: 5,
|
|
userAgent: 'test-agent/1.0',
|
|
}
|
|
|
|
type Handler = (req: IncomingMessage, res: ServerResponse) => void
|
|
|
|
let server: Server
|
|
let base: string
|
|
let handler: Handler
|
|
|
|
beforeEach(async () => {
|
|
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('default') }
|
|
server = createServer((req, res) => { handler(req, res) })
|
|
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
|
const { port } = server.address() as AddressInfo
|
|
base = `http://127.0.0.1:${port}`
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await new Promise<void>(resolve => server.close(() => { resolve() }))
|
|
})
|
|
|
|
function provider(overrides: Partial<LocalFetchLimits> = {}): LocalFetchProvider {
|
|
return new LocalFetchProvider({ ...limits, ...overrides })
|
|
}
|
|
|
|
describe('policy helpers', () => {
|
|
it('validates scheme, credentials, and length', () => {
|
|
expect(validateFetchUrl('https://example.com/x', 2048).hostname).toBe('example.com')
|
|
expect(() => validateFetchUrl('ftp://example.com', 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
|
|
expect(() => validateFetchUrl('not a url', 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
|
|
expect(() => validateFetchUrl('https://user:pass@example.com', 2048)).toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
|
|
expect(() => validateFetchUrl(`https://example.com/${'a'.repeat(3000)}`, 2048)).toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
|
|
})
|
|
|
|
it('classifies content types', () => {
|
|
expect(classifyContentType('text/html; charset=utf-8')).toBe('html')
|
|
expect(classifyContentType('application/xhtml+xml')).toBe('html')
|
|
expect(classifyContentType('text/plain')).toBe('text')
|
|
expect(classifyContentType('application/json')).toBe('text')
|
|
expect(classifyContentType('image/png')).toBeUndefined()
|
|
expect(classifyContentType(null)).toBeUndefined()
|
|
})
|
|
|
|
it('compares origins', () => {
|
|
expect(isSameOrigin(new URL('https://a.com/x'), new URL('https://a.com/y'))).toBe(true)
|
|
expect(isSameOrigin(new URL('https://a.com'), new URL('https://b.com'))).toBe(false)
|
|
expect(isSameOrigin(new URL('http://a.com'), new URL('https://a.com'))).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('LocalFetchProvider success', () => {
|
|
it('fetches a text body', async () => {
|
|
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('hello world') }
|
|
const result = await provider().fetch({ url: base })
|
|
expect(result.providerId).toBe(LOCAL_FETCH_PROVIDER_ID)
|
|
expect(result.statusCode).toBe(200)
|
|
expect(result.body).toEqual({ kind: 'text', content: 'hello world' })
|
|
expect(result.truncated).toBe(false)
|
|
})
|
|
|
|
it('fetches an html body and classifies it as html', async () => {
|
|
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/html' }); res.end('<h1>hi</h1>') }
|
|
const result = await provider().fetch({ url: base })
|
|
expect(result.body).toEqual({ kind: 'html', content: '<h1>hi</h1>' })
|
|
})
|
|
|
|
it('sends the configured user agent', async () => {
|
|
let seen: string | undefined
|
|
handler = (req, res) => { seen = req.headers['user-agent']; res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') }
|
|
await provider().fetch({ url: base })
|
|
expect(seen).toBe('test-agent/1.0')
|
|
})
|
|
|
|
it('returns a non-2xx response as a result, not an error', async () => {
|
|
handler = (_req, res) => { res.writeHead(404, { 'content-type': 'text/plain' }); res.end('nope') }
|
|
const result = await provider().fetch({ url: base })
|
|
expect(result.statusCode).toBe(404)
|
|
expect(result.body).toEqual({ kind: 'text', content: 'nope' })
|
|
})
|
|
})
|
|
|
|
describe('LocalFetchProvider caps', () => {
|
|
it('rejects an over-cap Content-Length with WEB_FETCH_TOO_LARGE', async () => {
|
|
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '999999' }); res.end('x'.repeat(999999)) }
|
|
await expect(provider({ maxResponseBytes: 10 }).fetch({ url: base }))
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TOO_LARGE' }))
|
|
})
|
|
|
|
it('truncates a stream that grows past the byte cap', async () => {
|
|
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcdefghij') }
|
|
const result = await provider({ maxResponseBytes: 4 }).fetch({ url: base })
|
|
expect(result.body.content).toBe('abcd')
|
|
expect(result.truncated).toBe(true)
|
|
})
|
|
|
|
it('truncates a decoded body past the character cap', async () => {
|
|
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('abcdefghij') }
|
|
const result = await provider({ maxBodyChars: 3 }).fetch({ url: base })
|
|
expect(result.body.content).toBe('abc')
|
|
expect(result.truncated).toBe(true)
|
|
})
|
|
|
|
it('rejects an unsupported content type', async () => {
|
|
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'image/png' }); res.end('binary') }
|
|
await expect(provider().fetch({ url: base }))
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
|
|
})
|
|
|
|
it('rejects a response with no content type at all', async () => {
|
|
handler = (_req, res) => { res.writeHead(200); res.end('no type') }
|
|
await expect(provider().fetch({ url: base }))
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_UNSUPPORTED_CONTENT_TYPE' }))
|
|
})
|
|
|
|
it('accepts a declared content-length within the cap', async () => {
|
|
handler = (_req, res) => { const body = 'sized'; res.writeHead(200, { 'content-type': 'text/plain', 'content-length': String(body.length) }); res.end(body) }
|
|
const result = await provider().fetch({ url: base })
|
|
expect(result.body.content).toBe('sized')
|
|
})
|
|
})
|
|
|
|
describe('LocalFetchProvider redirects', () => {
|
|
it('follows a same-origin redirect and reports the final URL', async () => {
|
|
handler = (req, res) => {
|
|
if (req.url === '/start') { res.writeHead(302, { location: '/end' }); res.end() }
|
|
else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('arrived') }
|
|
}
|
|
const result = await provider().fetch({ url: `${base}/start` })
|
|
expect(result.body.content).toBe('arrived')
|
|
expect(result.url).toBe(`${base}/end`)
|
|
})
|
|
|
|
it('blocks a cross-origin redirect with WEB_REDIRECT_BLOCKED', async () => {
|
|
handler = (_req, res) => { res.writeHead(302, { location: 'https://example.com/' }); res.end() }
|
|
await expect(provider().fetch({ url: base }))
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
|
|
})
|
|
|
|
it('rejects exceeding the redirect hop cap', async () => {
|
|
handler = (req, res) => {
|
|
const n = Number(new URL(req.url ?? '/', base).searchParams.get('n') ?? '0')
|
|
res.writeHead(302, { location: `/?n=${n + 1}` })
|
|
res.end()
|
|
}
|
|
await expect(provider({ maxRedirects: 2 }).fetch({ url: `${base}/?n=0` }))
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_REDIRECT_BLOCKED' }))
|
|
})
|
|
|
|
it('treats a redirect without a Location header as a provider error', async () => {
|
|
handler = (_req, res) => { res.writeHead(302); res.end() }
|
|
await expect(provider().fetch({ url: base }))
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
|
|
})
|
|
|
|
it('follows a relative same-origin redirect', async () => {
|
|
handler = (req, res) => {
|
|
if (req.url === '/a') { res.writeHead(301, { location: 'b' }); res.end() }
|
|
else { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('landed') }
|
|
}
|
|
const result = await provider().fetch({ url: `${base}/a` })
|
|
expect(result.body.content).toBe('landed')
|
|
})
|
|
})
|
|
|
|
describe('LocalFetchProvider invalid URLs and abort', () => {
|
|
it('rejects a non-http scheme before any network access', async () => {
|
|
await expect(provider().fetch({ url: 'ftp://example.com' }))
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_INVALID_URL' }))
|
|
})
|
|
|
|
it('rejects credentials in the URL', async () => {
|
|
await expect(provider().fetch({ url: 'http://user:pass@127.0.0.1/' }))
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_BLOCKED_URL' }))
|
|
})
|
|
|
|
it('honors a pre-aborted signal', async () => {
|
|
const controller = new AbortController()
|
|
controller.abort()
|
|
await expect(provider().fetch({ url: base }, { signal: controller.signal }))
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
|
})
|
|
|
|
it('aborts an in-flight fetch via the signal', async () => {
|
|
handler = (_req, _res) => { /* never responds */ }
|
|
const controller = new AbortController()
|
|
const promise = provider().fetch({ url: base }, { signal: controller.signal })
|
|
controller.abort()
|
|
await expect(promise).rejects.toThrow(expect.objectContaining({ code: 'WEB_ABORTED' }))
|
|
})
|
|
|
|
it('times out a slow response with WEB_FETCH_TIMEOUT', async () => {
|
|
handler = (_req, _res) => { /* never responds */ }
|
|
await expect(provider({ timeoutMs: 50 }).fetch({ url: base }))
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_FETCH_TIMEOUT' }))
|
|
})
|
|
|
|
it('maps a connection failure to WEB_PROVIDER_ERROR', async () => {
|
|
// Port 1 on loopback is not listening: a real connection failure (not abort).
|
|
await expect(provider().fetch({ url: 'http://127.0.0.1:1/' }))
|
|
.rejects.toThrow(expect.objectContaining({ code: 'WEB_PROVIDER_ERROR' }))
|
|
})
|
|
|
|
it('caps the per-request timeout at maxTimeoutMs', async () => {
|
|
handler = (_req, res) => { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok') }
|
|
const result = await provider({ maxTimeoutMs: 10_000 }).fetch({ url: base, timeoutMs: 999_999 })
|
|
expect(result.statusCode).toBe(200)
|
|
})
|
|
})
|
|
|
|
describe('web-fetch-local plugin registration', () => {
|
|
it('registers the provider into ctx.web (HMR-safe)', async () => {
|
|
const ctx = new Context()
|
|
await ctx.plugin(WebService, { fetchProvider: LOCAL_FETCH_PROVIDER_ID })
|
|
const fiber = await ctx.plugin(fetchPlugin, {})
|
|
expect(ctx.web.fetchStatus()).toEqual({ available: true, providerId: LOCAL_FETCH_PROVIDER_ID })
|
|
await fiber.dispose()
|
|
expect(ctx.web.fetchStatus()).toEqual({ available: false, reason: 'configured-missing' })
|
|
})
|
|
|
|
it('has no default export (namespace plugin export shape)', () => {
|
|
expect('default' in fetchPlugin).toBe(false)
|
|
})
|
|
})
|