diff --git a/examples/acp-agent/tests/fixtures/web-fetch-network.ts b/examples/acp-agent/tests/fixtures/web-fetch-network.ts index b68f2f5df0..e52419bb91 100644 --- a/examples/acp-agent/tests/fixtures/web-fetch-network.ts +++ b/examples/acp-agent/tests/fixtures/web-fetch-network.ts @@ -5,7 +5,8 @@ import { createServer } from 'node:http' import type { Context } from '@deepseek-ai/cordis' -import { publicHttpNetwork } from '@deepseek-ai/dsh-web-fetch-http/src/network.ts' +import { HttpFetchProvider } from '@deepseek-ai/dsh-web-fetch-http' +import type { HttpFetchLimits, HttpFetchResolver } from '@deepseek-ai/dsh-web-fetch-http' const FIXTURE_HOST = 'public.test' const FIXTURE_PORT = 43_117 @@ -13,8 +14,19 @@ const FIXTURE_PORT = 43_117 /** Cordis plugin name used by Loader diagnostics. */ export const name = 'web-fetch-snapshot-network' -/** Start the fixture endpoint and map its public test hostname after approval. */ -export async function apply(ctx: Context): Promise { +/** The web registry receiving the deterministic provider. */ +export const inject = ['web'] + +const LIMITS: HttpFetchLimits = { + maxResponseBytes: 5_000_000, + maxBodyChars: 100_000, + timeoutMs: 30_000, + maxRedirects: 5, + userAgent: 'deepseek-harness-snapshot/1.0', +} + +/** Start the fixture endpoint and register a deterministic pinned provider. */ +export function apply(ctx: Context): void { const server = createServer((request, response) => { if (request.url !== '/menu.html') { response.writeHead(404, { 'content-type': 'text/plain' }) @@ -24,18 +36,20 @@ export async function apply(ctx: Context): Promise { response.writeHead(200, { 'content-type': 'text/html; charset=utf-8' }) response.end('

Lunch menu

Tomato soup

') }) - await new Promise((resolve, reject) => { + const listening = new Promise((resolve, reject) => { server.once('error', reject) server.listen(FIXTURE_PORT, '127.0.0.1', resolve) }) + void listening.catch(() => undefined) - const resolve = publicHttpNetwork.resolve - publicHttpNetwork.resolve = (hostname, signal) => hostname === FIXTURE_HOST - ? Promise.resolve([{ address: '127.0.0.1', family: 4 }]) - : resolve(hostname, signal) + const resolveAddresses: HttpFetchResolver = async (hostname) => { + await listening + if (hostname !== FIXTURE_HOST) throw new Error(`unexpected snapshot hostname: ${hostname}`) + return [{ address: '127.0.0.1', family: 4 }] + } ctx.effect(() => async () => { - publicHttpNetwork.resolve = resolve + server.closeAllConnections() await new Promise((closed, reject) => { server.close((error) => { if (error === undefined) closed() @@ -43,4 +57,5 @@ export async function apply(ctx: Context): Promise { }) }) }, 'web fetch snapshot network') + ctx.web.registerFetchProvider(new HttpFetchProvider(LIMITS, resolveAddresses)) } diff --git a/examples/acp-agent/web.cordis.snapshot.yml b/examples/acp-agent/web.cordis.snapshot.yml index f6d7eca28b..913eb26f23 100644 --- a/examples/acp-agent/web.cordis.snapshot.yml +++ b/examples/acp-agent/web.cordis.snapshot.yml @@ -18,6 +18,10 @@ - id: web-fetch-snapshot-network name: './tests/fixtures/web-fetch-network.ts' +- id: web-fetch-http + name: '@deepseek-ai/dsh-web-fetch-http' + disabled: true + - id: tool-web name: '@deepseek-ai/dsh-tool-web' config: diff --git a/examples/acp-agent/web.cordis.yml b/examples/acp-agent/web.cordis.yml index d74ee6ef5e..ee32c63d47 100644 --- a/examples/acp-agent/web.cordis.yml +++ b/examples/acp-agent/web.cordis.yml @@ -1,11 +1,14 @@ # Web-fetch composition for the web-fetch snapshot scenario. The base bundle # supplies the web seam, public HTTP provider, and fetch permission policy; this -# overlay narrows the model-facing tools to fetch only. A snapshot-only network -# plugin serves one deterministic endpoint after one-shot approval. +# overlay disables that provider, inserts a deterministic one, and exposes only fetch. - insert: - id: web-fetch-snapshot-network name: './tests/fixtures/web-fetch-network.ts' +- id: web-fetch-http + name: '@deepseek-ai/dsh-web-fetch-http' + disabled: true + - id: tool-web name: '@deepseek-ai/dsh-tool-web' config: diff --git a/packages/web/web-fetch-http/README.i18n.yaml b/packages/web/web-fetch-http/README.i18n.yaml index f86fecaccb..20deaab886 100644 --- a/packages/web/web-fetch-http/README.i18n.yaml +++ b/packages/web/web-fetch-http/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/web/web-fetch-http/README.md -README.md: 7bf124575a6682db00fa9a2818c69f6f51f7aa6d -README.zh.md: 1bae48a0a5b00600f83ce05c2f7d310300e6339a +README.md: 7c39ecdb9a49490da64e9e9ed64c61b5a5b42bc2 +README.zh.md: 66b4b7be85f54f38e4e93012dd6c9365f5b9b2ce diff --git a/packages/web/web-fetch-http/README.md b/packages/web/web-fetch-http/README.md index 7bf124575a..7c39ecdb9a 100644 --- a/packages/web/web-fetch-http/README.md +++ b/packages/web/web-fetch-http/README.md @@ -26,6 +26,8 @@ A shipping web-tool deployment sets the provider backstop above the tool budget, `validateFetchApprovalUrl()` exposes network-free URL syntax, length, credentials, and literal-IP checks to permission consumers. Hostname resolution remains exclusively in the provider after consent, where the result is enforced and pinned rather than reused as an authorization token. +Direct `HttpFetchProvider` construction may inject an `HttpFetchResolver` for alternate trusted assemblies and deterministic tests. That resolver must reject every non-public destination before returning addresses; the shipped plugin always uses the built-in public-address resolver. + ## Config | Key | Default | Meaning | diff --git a/packages/web/web-fetch-http/README.zh.md b/packages/web/web-fetch-http/README.zh.md index 1bae48a0a5..66b4b7be85 100644 --- a/packages/web/web-fetch-http/README.zh.md +++ b/packages/web/web-fetch-http/README.zh.md @@ -26,6 +26,8 @@ `validateFetchApprovalUrl()` 向权限消费方暴露不产生网络活动的 URL 语法、长度、凭据与 IP 字面量校验。hostname 解析只会在用户同意后由提供方执行;提供方会强制校验并固定解析结果,而不会把它当作可复用的授权令牌。 +直接构造 `HttpFetchProvider` 时,可以为受信任的替代装配和确定性测试注入 `HttpFetchResolver`。该 resolver 必须先拒绝所有非公开目的地址,再返回地址;随产品交付的插件始终使用内置的公开地址 resolver。 + ## 配置 | 配置键 | 默认值 | 含义 | diff --git a/packages/web/web-fetch-http/src/index.ts b/packages/web/web-fetch-http/src/index.ts index d1f05151d6..fd856a5cec 100644 --- a/packages/web/web-fetch-http/src/index.ts +++ b/packages/web/web-fetch-http/src/index.ts @@ -17,7 +17,7 @@ export { LOCAL_FETCH_PROVIDER_ID, HttpFetchProvider, } from './provider.ts' -export type { HttpFetchLimits } from './provider.ts' +export type { HttpFetchLimits, HttpFetchResolver } from './provider.ts' export { validateFetchApprovalUrl } from './preflight.ts' export { WEB_FETCH_MAX_URL_LENGTH } from './policy.ts' diff --git a/packages/web/web-fetch-http/src/provider.ts b/packages/web/web-fetch-http/src/provider.ts index 2092818f0c..8f783d4ed7 100644 --- a/packages/web/web-fetch-http/src/provider.ts +++ b/packages/web/web-fetch-http/src/provider.ts @@ -11,6 +11,7 @@ import type { WebFetchBody, WebFetchProvider, WebFetchRequest, WebFetchResult } import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { Response } from 'undici' import { publicHttpNetwork } from './network.ts' +import type { PublicAddress } from './network.ts' import { classifyContentType, decoderForCharset, isSameOrigin, parseCharset, validateFetchUrl } from './policy.ts' /** Resolved provider limits (the plugin's schemastery Config supplies defaults). */ @@ -27,6 +28,9 @@ export interface HttpFetchLimits { userAgent: string } +/** Resolve one hostname to an already policy-validated address set. */ +export type HttpFetchResolver = (hostname: string, signal: AbortSignal) => Promise + /** Stable id this provider registers under. */ export const LOCAL_FETCH_PROVIDER_ID = 'http' @@ -34,7 +38,14 @@ export const LOCAL_FETCH_PROVIDER_ID = 'http' export class HttpFetchProvider implements WebFetchProvider { readonly id = LOCAL_FETCH_PROVIDER_ID - constructor(private readonly limits: HttpFetchLimits) {} + /** + * @param limits - resolved transport and response limits. + * @param resolveAddresses - resolver that rejects non-public destinations before returning. + */ + constructor( + private readonly limits: HttpFetchLimits, + private readonly resolveAddresses: HttpFetchResolver = publicHttpNetwork.resolve, + ) {} /** No credentials to check — an anonymous public fetcher is always usable. */ available(): boolean { @@ -104,7 +115,7 @@ export class HttpFetchProvider implements WebFetchProvider { private async requestOnce(url: URL, signal: AbortSignal) { try { - const addresses = await publicHttpNetwork.resolve(url.hostname, signal) + const addresses = await this.resolveAddresses(url.hostname, signal) return await publicHttpNetwork.request(url, addresses, { 'user-agent': this.limits.userAgent, 'accept': 'text/html,application/xhtml+xml,text/*;q=0.9,application/json;q=0.8', diff --git a/packages/web/web-fetch-http/tests/fetch-http.spec.ts b/packages/web/web-fetch-http/tests/fetch-http.spec.ts index 34beaf36b6..1478476bbc 100644 --- a/packages/web/web-fetch-http/tests/fetch-http.spec.ts +++ b/packages/web/web-fetch-http/tests/fetch-http.spec.ts @@ -4,7 +4,7 @@ import { AddressInfo } from 'node:net' import { Context } from '@deepseek-ai/cordis' import WebRuntime from '@deepseek-ai/dsh-web' import { HttpFetchProvider, LOCAL_FETCH_PROVIDER_ID } from '@deepseek-ai/dsh-web-fetch-http' -import type { HttpFetchLimits } from '@deepseek-ai/dsh-web-fetch-http' +import type { HttpFetchLimits, HttpFetchResolver } from '@deepseek-ai/dsh-web-fetch-http' import * as fetchPlugin from '@deepseek-ai/dsh-web-fetch-http' import { createPinnedLookup, isPublicIpAddress, publicHttpNetwork, requestPinned, resolvePublicAddresses } from '../src/network.ts' import { @@ -282,6 +282,14 @@ describe('HttpFetchProvider success', () => { expect(result.body).toEqual({ kind: 'html', content: '

hi

' }) }) + it('uses an explicitly injected validated-address resolver', async () => { + const resolveAddresses = vi.fn(async () => [{ address: '127.0.0.1', family: 4 }]) + const result = await new HttpFetchProvider(limits, resolveAddresses).fetch({ url: base }) + expect(result.statusCode).toBe(200) + expect(resolveAddresses).toHaveBeenCalledWith('127.0.0.1', expect.any(AbortSignal)) + expect(publicHttpNetwork.resolve).not.toHaveBeenCalled() + }) + 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') }