mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
fix(web): preserve preview fetch composition
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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) },
|
||||
|
||||
Reference in New Issue
Block a user