fix(web): preserve preview fetch composition

This commit is contained in:
Dudu-0223
2026-08-24 14:18:15 +08:00
parent 14e4d3f078
commit 470af0a404
13 changed files with 51 additions and 15 deletions
+1 -1
View File
@@ -231,7 +231,7 @@ describe('the shipped Web composition', () => {
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
'get_goal', 'interrupt_agent', 'job_kill', 'job_list', 'job_output', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_search',
'subagent', 'subagent_fork', 'todo_write', 'update_goal', 'web_fetch', 'web_search',
'workflow', 'write',
])
expect(ctx.commands.find(handle.agent, 'goal')).toBeDefined()
+1 -1
View File
@@ -305,7 +305,7 @@ async function bootPreview(origin: string, browser: Browser): Promise<void> {
// The hero's workspace picker is the client tree's first interactive
// surface, so it appears only once the startup chain completed over the
// tunnel.
await page.getByRole('textbox', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS })
await page.getByRole('button', { name: 'Choose workspace' }).waitFor({ timeout: HERO_TIMEOUT_MS })
const continueButton = page.getByRole('button', { name: 'Continue' })
await continueButton.waitFor({ timeout: 30_000 })
await continueButton.click()
+5 -3
View File
@@ -29,9 +29,10 @@ const FILE_REFERENCE_PROMPT = fileURLToPath(new URL(
* The catalog the shipped Web composition puts in front of the model, minus the
* ripgrep-dependent pair below. The absences are deliberate, not incidental
* gaps: the `cordis_*` toolset executes model-written JavaScript that no
* sandbox row confines, `web_fetch` chooses its own request target, and
* `mcp_*` servers spawn outside `ctx.shell`. The composition Agent Note owns the
* rationale and its sources.
* sandbox row confines, and `mcp_*` servers spawn outside `ctx.shell`.
* `web_fetch` is present because public-address enforcement and one-shot
* approval now confine its model-selected request target. The composition
* Agent Note owns the rationale and its sources.
*/
const EXPECTED_TOOLS = [
'ask_user_question',
@@ -54,6 +55,7 @@ const EXPECTED_TOOLS = [
'subagent_fork',
'todo_write',
'update_goal',
'web_fetch',
'web_search',
'workflow',
'write',
+1
View File
@@ -365,6 +365,7 @@ describe('dsh web keyless CLI smoke', () => {
.filter(name => name === 'web_search' || name === 'web_fetch'))
.toMatchInlineSnapshot(`
[
"web_fetch",
"web_search",
]
`)
@@ -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/experimental/webworker-runtime/README.md
README.md: 3e9b4fffe0b97a97adf218aa12fd1f4342d3bc6c
README.zh.md: 2552c659d1b735b0cf28b9b0d0808276d31d0a2a
README.md: bd671683bd872450b046362c1e7a0cc39da0863e
README.zh.md: 0ae6fbe8f993de7675dea526b5531a8f822807dd
@@ -24,7 +24,7 @@ None; this package neither assembles nor sends a provider request.
## Known Limitations and Deferred Work
- **The worker composition writes plaintext session logs** (`compression: 'none'` boot patch): it carries no Zstandard codec, so exported logs are `.jsonl`, never `.jsonl.zstd`.
- **`node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing a real process or realm isolation cannot run here.
- **`node:dns/promises`, `node:vm`, `node:net`, `node:sqlite`, `node:worker_threads` are structural stubs**: every call reports its refusal on the console and throws. Rows needing native DNS, a real process, or realm isolation cannot run here.
- **Filesystem watchers observe only the mounted VFS**: image seeding is silent and the VFS has no symlinks or external writers. `persistent`, `ref()`, and `unref()` preserve the Node API but cannot control a dedicated Worker's lifetime because browsers expose no ref-counted event loop.
- **Worker confinement is a VFS boundary, not kernel Landlock**: `read-only` and `workspace-write` run the unchanged `@deepseek-ai/node-addon-landlock-run` JavaScript and launcher argv, but the process layer implements the logical `landlock-run` executable and enforces its grants on every shell filesystem request. `full` therefore covers the Worker command table and mounted VFS only; it does not claim arbitrary native-process execution or Linux kernel isolation.
- **The worker bundle pins a path inside `@yarnpkg/parsers`** — the build resolves the package's own `lib/shell.js` instead of its root, whose barrel also re-exports the Syml parser and so drags js-yaml into a bundle that never parses that format (around 175 kB, plus its module body at worker start). The path is derived from the package manifest, so a layout change fails the build rather than reinstating the barrel; upgrading the dependency means re-checking that the shell parser still lives there.
@@ -24,7 +24,7 @@
## Known Limitations and Deferred Work
- **worker 组合写明文会话日志**(`compression: 'none'` boot patch):不带 Zstandard 编解码器,导出日志是 `.jsonl`,不会是 `.jsonl.zstd`
- **`node:vm``node:net``node:sqlite``node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要真进程或真 realm 隔离的行在此无法运行。
- **`node:dns/promises``node:vm``node:net``node:sqlite``node:worker_threads` 是结构化 stub**:每次调用在 console 报告拒绝并抛出。需要原生 DNS、真进程或真 realm 隔离的行在此无法运行。
- **文件 watcher 只能观察已挂载的 VFS**:镜像 seed 不产生事件,VFS 也没有符号链接或外部写入方。`persistent``ref()``unref()` 保留 Node API,但浏览器没有引用计数事件循环,因此这些接口不能控制 dedicated Worker 的生存期。
- **Worker confinement 是 VFS 边界,不是内核 Landlock**`read-only``workspace-write` 运行未经修改的 `@deepseek-ai/node-addon-landlock-run` JavaScript 与 launcher argv,进程层则实现逻辑 `landlock-run` 可执行文件,并在 shell 的每次文件系统请求上执行其授权。`full` 仅覆盖 Worker 命令表和已挂载 VFS,不表示能够执行任意 native 进程,也不表示 Linux 内核隔离。
- **worker 束钉住了 `@yarnpkg/parsers` 的包内路径**——构建解析到该包自己的 `lib/shell.js` 而非包根,因为包根 barrel 还 re-export 了 Syml 解析器,会把 js-yaml 拖进一个从不解析该格式的束(约 175 kB,外加 worker 启动时的模块体求值)。该路径由包 manifest 派生,包内布局一变即构建期失败、不会静默退回 barrel;升级这个依赖时须复核 shell 解析器是否仍在那里。
@@ -55,6 +55,8 @@ export const MODULE_PROXIES: Record<string, string> = {
// the VFS, because a browser worker has no processes to fork.
'node:child_process': './node/builtin_modules/implemented/child_process.ts',
// Structural mocks: every symbol exists, every call throws.
'node:dns/promises': './node/builtin_modules/mock/dns/promises.ts',
'dns/promises': './node/builtin_modules/mock/dns/promises.ts',
'node:net': './node/builtin_modules/mock/net.ts',
'node:stream': './node/builtin_modules/implemented/stream.ts',
'node:vm': './node/builtin_modules/mock/vm.ts',
@@ -0,0 +1,20 @@
/**
* `node:dns/promises` stub. The static WebWorker preview has no DNS resolver;
* reaching public-address preflight must fail loud instead of inventing an
* address or bypassing the native HTTP provider's SSRF policy.
*/
import { notImplementedFail } from '../../../notImplementedFail.ts'
const MODULE = 'node:dns/promises'
/** DNS lookup (unavailable in the worker host). */
export const lookup: typeof import('node:dns/promises').lookup = notImplementedFail(MODULE, 'lookup')
/** CommonJS interop marker: the worker loader hands `default` to default imports. */
export const __esModule = true
/** The `node:dns/promises` declarations this module stands in for. */
type NodeFace = Partial<typeof import('node:dns/promises')>
/** CommonJS default export: the members `require()` hands a caller of this module. */
export default { lookup } satisfies NodeFace
@@ -24,6 +24,7 @@
import * as nodeAsyncHooks from './builtin_modules/implemented/async_hooks.ts'
import * as nodeBuffer from './builtin_modules/implemented/buffer.ts'
import * as nodeCrypto from './builtin_modules/implemented/crypto.ts'
import * as nodeDnsPromises from './builtin_modules/mock/dns/promises.ts'
import * as nodeEvents from './builtin_modules/implemented/events.ts'
import * as nodeFs from './builtin_modules/implemented/fs.ts'
import * as nodeFsPromises from './builtin_modules/implemented/fs/promises.ts'
@@ -58,6 +59,7 @@ const BUILTINS: Record<string, StaticModuleFactory> = {
buffer: () => nodeBuffer,
child_process: () => nodeChildProcess,
crypto: () => nodeCrypto,
'dns/promises': () => nodeDnsPromises,
events: () => nodeEvents,
fs: () => nodeFs,
'fs/promises': () => nodeFsPromises,
@@ -14,6 +14,7 @@
import { describe, expect, it, vi } from 'vitest'
import { notAvailableError, notImplementedFail } from '../../src/node/notImplementedFail.ts'
import * as childProcess from '../../src/node/builtin_modules/implemented/child_process.ts'
import * as dnsPromises from '../../src/node/builtin_modules/mock/dns/promises.ts'
import * as net from '../../src/node/builtin_modules/mock/net.ts'
import * as sqlite from '../../src/node/builtin_modules/mock/sqlite.ts'
import * as stream from '../../src/node/builtin_modules/implemented/stream.ts'
@@ -33,6 +34,7 @@ const quiet = (): void => { vi.spyOn(console, 'error').mockImplementation(() =>
/** Symbols that refuse when called. */
const CALLED: [string, Record<string, unknown>, readonly string[]][] = [
['node:dns/promises', dnsPromises, ['lookup']],
['node:net', net, ['createServer', 'connect']],
['node:sqlite', sqlite, ['backup']],
['node:vm', vm, ['createContext', 'runInContext', 'runInNewContext', 'runInThisContext', 'isContext']],
@@ -90,7 +92,7 @@ describe('not-implemented stubs', () => {
}
it('keeps the CommonJS interop marker and a default export on every replaced module', () => {
for (const namespace of [net, sqlite, vm, workerThreads, childProcess, stream, ws, nodePty, piAi, os, perfHooks]) {
for (const namespace of [dnsPromises, net, sqlite, vm, workerThreads, childProcess, stream, ws, nodePty, piAi, os, perfHooks]) {
const holder = namespace as { __esModule?: unknown; default?: unknown }
expect(holder.__esModule).toBe(true)
expect(holder.default).toBeDefined()
@@ -92,11 +92,15 @@ describe('the shipped preset root', () => {
it('enables web_fetch in each tool-bearing Web app preset', async () => {
for (const id of ['cordis', 'code', 'standard']) {
const source = await readFile(join(SHIPPED_PRESET_ROOT, id, 'agent.cordis.yml'), 'utf8')
const entries = yaml.load(source, { schema: entryListSchema })
const entries: unknown = yaml.load(source, { schema: entryListSchema })
if (!Array.isArray(entries)) throw new TypeError(`${id} preset must contain a Cordis entry list`)
const toolWeb = entries.find((entry): entry is { id: string; config: { fetch?: boolean } } =>
typeof entry === 'object' && entry !== null && entry.id === 'tool-web')
expect(toolWeb?.config.fetch, id).toBe(true)
const toolWeb: unknown = entries.find((entry: unknown) =>
typeof entry === 'object' && entry !== null && 'id' in entry && entry.id === 'tool-web')
if (typeof toolWeb !== 'object' || toolWeb === null || !('config' in toolWeb)
|| typeof toolWeb.config !== 'object' || toolWeb.config === null || !('fetch' in toolWeb.config)) {
throw new TypeError(`${id} preset must configure tool-web.fetch`)
}
expect(toolWeb.config.fetch, id).toBe(true)
}
})
})
+4 -1
View File
@@ -9,7 +9,6 @@
import { lookup as systemLookup } from 'node:dns/promises'
import type { LookupAddress, LookupOptions } from 'node:dns'
import { isIP } from 'node:net'
import { Agent, fetch } from 'undici'
import type { Response } from 'undici'
import ipaddr from 'ipaddr.js'
import { WebError } from '@deepseek-ai/dsh-web'
@@ -106,6 +105,10 @@ export async function requestPinned(
headers: Record<string, string>,
signal: AbortSignal,
): Promise<PinnedResponse> {
// Keep the Node-only transport out of browser-worker startup. The preview
// can load the provider and fail loud at its DNS stub without evaluating
// Undici; a real request on Node resolves this maintained dependency here.
const { Agent, fetch } = await import('undici')
const dispatcher = new Agent({
autoSelectFamily: true,
connect: { lookup: createPinnedLookup(addresses) },