Ver Fonte

fix(web): preserve preview fetch composition

Dudu-0223 há 1 mês atrás
pai
commit
470af0a404

+ 1 - 1
apps/cli/tests/web-agent-presets.e2e.ts

@@ -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
apps/web/tests/preview-boot.e2e.ts

@@ -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
apps/web/tests/shipped-composition.e2e.ts

@@ -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 - 0
apps/web/tests/smoke-real.e2e.ts

@@ -365,6 +365,7 @@ describe('dsh web keyless CLI smoke', () => {
         .filter(name => name === 'web_search' || name === 'web_fetch'))
         .toMatchInlineSnapshot(`
           [
+            "web_fetch",
             "web_search",
           ]
         `)

+ 2 - 2
packages/experimental/webworker-runtime/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/experimental/webworker-runtime/README.md
-README.md: 3e9b4fffe0b97a97adf218aa12fd1f4342d3bc6c
-README.zh.md: 2552c659d1b735b0cf28b9b0d0808276d31d0a2a
+README.md: bd671683bd872450b046362c1e7a0cc39da0863e
+README.zh.md: 0ae6fbe8f993de7675dea526b5531a8f822807dd

+ 1 - 1
packages/experimental/webworker-runtime/README.md

@@ -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.

+ 1 - 1
packages/experimental/webworker-runtime/README.zh.md

@@ -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 解析器是否仍在那里。

+ 2 - 0
packages/experimental/webworker-runtime/src/module-proxies.ts

@@ -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',

+ 20 - 0
packages/experimental/webworker-runtime/src/node/builtin_modules/mock/dns/promises.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

+ 2 - 0
packages/experimental/webworker-runtime/src/node/builtins.ts

@@ -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,

+ 3 - 1
packages/experimental/webworker-runtime/tests/node/node-stubs.spec.ts

@@ -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()

+ 8 - 4
packages/preset/agent-presets/tests/shipped-root.spec.ts

@@ -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
packages/web/web-fetch-http/src/network.ts

@@ -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) },