diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index 1d7c7a29bb..3f8444bcfa 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -24,8 +24,8 @@ en: ec3703b7e7c6b23d zh: 325755e6e90092da /agent-note-web-capability-seam-stable-tools-over-multiple-providers/fetch-request-and-result-schema: - en: f734fdc57a6546c9 - zh: 027c28cbc584a8c2 + en: 6dba3b9a51c28b6d + zh: 58a93f6ba2d269ea /agent-note-web-capability-seam-stable-tools-over-multiple-providers/tool-consumer-behavior: en: 62127883453dd79c zh: 01503a8177a0ddab @@ -55,19 +55,16 @@ zh: 64032ea6ab61b836 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/mirror-claude-code-s-url-prompt-webfetch-shape: en: 56a080de39e96056 - zh: ae4d070aa7657503 -/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/validate-dns-and-then-call-an-ordinary-fetch: - en: aa6c1e7e6374a875 - zh: 045c8af6cd44c3e1 -/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/block-private-looking-hostname-strings-without-pinning-resolved-addresses: - en: 3d906f3056733479 - zh: d652723eb5372007 + zh: 937336a73de70518 +/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/restrict-fetch-to-public-addresses: + en: af3014c0de7ff66f + zh: 509040c9183327fe /agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/require-per-call-approval-before-public-fetches: - en: f8d1fc8e9c9659cf - zh: da5792520e35cba0 + en: 3ea94ffb142d144a + zh: 89645f68412f6769 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/consequences: - en: 12fd20ba54818215 - zh: c1ca000b13666701 + en: 9774448fd6b21c41 + zh: 8411c590ad70d5c4 /agent-note-web-capability-seam-stable-tools-over-multiple-providers/deferred-work: en: 48b7b8e7310cd56b zh: d406cd6a8a649e58 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md index 135447cb15..91e212ad9f 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md @@ -202,7 +202,7 @@ Full page retrieval remains the job of `web_fetch(url)`. Search snippets are dis ## Fetch request and result schema -The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, resolves and pins public destinations, applies the transport hygiene below, decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services. +The `web_fetch` implementation is an anonymous HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, applies the transport hygiene below, decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, or internal auth tokens. It does not restrict destination addresses. The seam request stays smaller than OpenCode's model-facing tool: @@ -238,14 +238,12 @@ The provider owns safe resource retrieval: URL validation, HTTP transport, redir The fetch provider's resource controls: - Only `http:` and `https:` URLs are accepted; credentials in URLs are rejected. -- A literal address or the complete result of one hostname lookup must contain only globally reachable unicast IPv4 or IPv6 destinations. IPv6 resolution also discovers the active DNS64 prefix and rejects NAT64 addresses that translate to non-public IPv4. Loopback, private, link-local, carrier-grade NAT, multicast, reserved, transition, translation, and private IPv4-mapped IPv6 addresses are rejected. -- The request retains that validated address set in an Undici lookup callback instead of resolving the hostname again. The original hostname remains the HTTP Host and TLS SNI value, while DNS rebinding cannot replace the connection destination after validation. - Maximum URL length, response byte cap, decoded body character cap, timeout, and redirect hop cap are enforced. - Abort signals propagate through network fetches and expensive decoding. -- Only same-origin redirects are followed automatically; each followed hop performs a fresh public-address lookup and pins its own connection. A cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and fresh public-address validation. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.) +- Only same-origin redirects are followed automatically; each followed hop repeats URL validation. A cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.) - Requests carry an explicit product user agent rather than silently impersonating a browser. -The provider rejects an entire DNS answer set when any address is not public instead of silently filtering the unsafe members. This fail-closed rule prevents connection-family selection or fallback from reaching an address that did not satisfy the public-network policy. +Requests use the process-wide dispatcher, so the installed outbound proxy policy and the system resolver decide the connection. Loopback, private-network, and cloud metadata destinations are reachable. ## Tool consumer behavior @@ -313,17 +311,13 @@ Rejected for the first version. Those providers often return extracted or summar Rejected for the seam. `prompt` turns fetch into LLM summarization and couples public-web retrieval to a model provider. The harness seam should fetch and decode deterministically; `dsh-tool-web` can later offer summaries as a presentation mode without making `ctx.web` depend on `ctx.llm`. -### Validate DNS and then call an ordinary fetch +### Restrict fetch to public addresses -Rejected because an ordinary fetch resolves the hostname again when it opens the connection. An attacker can return a public address during validation and a private address during the second lookup. Passing the validated answer set through the connection's lookup callback closes that rebinding interval while preserving hostname-based HTTP and TLS behavior. - -### Block private-looking hostname strings without pinning resolved addresses - -Rejected because hostname syntax does not establish the connection destination: an arbitrary public-looking name can resolve to loopback, a private range, or a cloud metadata address. Address classification belongs after resolution, and every address available to connection fallback must pass it. +Rejected. Resolving each hostname, rejecting any non-public answer, and pinning the connection to the validated set does not confine the model, which reaches the same networks through mounted shell tools, and it refuses every page on hosts whose DNS answers with non-public addresses, such as TUN-mode proxies with fake-IP DNS. Deployments that must keep the model away from private destinations need network confinement, a `tools/pre-execute` policy, or no fetch tool. ### Require per-call approval before public fetches -Rejected for the shipped presets. Public-address validation blocks SSRF destinations, while per-call confirmation would interrupt ordinary browsing without controlling public data egress reliably: a model can reach the same public network through mounted shell tools. Deployments that require a dedicated confirmation step can add a `tools/pre-execute` policy or disable `web_fetch`. +Rejected for the shipped presets. Per-call confirmation would interrupt ordinary browsing without controlling data egress reliably: a model can reach the same networks through mounted shell tools. Deployments that require a dedicated confirmation step can add a `tools/pre-execute` policy or disable `web_fetch`. ## Consequences @@ -335,7 +329,7 @@ Rejected for the shipped presets. Public-address validation blocks SSRF destinat **Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path resolves again and fails with a structured error. -**Fetch is a network boundary, not just a read-only tool.** Public-address validation and connection pinning prevent `web_fetch` from reaching non-public destinations, but a model can still disclose data through a public URL and fetched text remains untrusted model input. The shipped `cordis`, `code`, and `standard` presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation. +**Fetch is a network boundary, not just a read-only tool.** `web_fetch` reaches any destination the host can, including loopback and private-network services, a model can disclose data through a URL, and fetched text remains untrusted model input. The shipped `cordis`, `code`, and `standard` presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation. **Large web content can damage context quality.** Providers enforce byte/character caps and report `truncated`; `tool-web` formats bounded model output with clear continuation or follow-up guidance. diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index 736b50ebb3..27b7777f23 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -202,7 +202,7 @@ Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource` ## Fetch 请求与结果 schema -`web_fetch` 的实现是一个匿名公开 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,解析并固定公开目的地址,应用下述传输卫生措施,解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证、内部认证令牌,也不隐式访问私有服务。 +`web_fetch` 的实现是一个匿名 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,应用下述传输卫生措施,解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证或内部认证令牌。它不限制目的地址。 seam 请求比 OpenCode 的面向模型工具更小: @@ -238,14 +238,12 @@ export type WebFetchBody = fetch 提供方的资源控制: - 仅接受 `http:` 和 `https:` URL;拒绝 URL 中的凭证。 -- 字面 IP 地址或 hostname 一次解析得到的完整结果只能包含全球可达的单播 IPv4 或 IPv6 目的地址。IPv6 解析还会发现当前 DNS64 前缀,并拒绝转换到非公开 IPv4 的 NAT64 地址。loopback、私有、link-local、运营商级 NAT、多播、保留、过渡、转换和映射到私有 IPv4 的 IPv6 地址都会被拒绝。 -- 请求通过 Undici lookup 回调保留这一组已验证地址,不会再次解析 hostname。原 hostname 仍作为 HTTP Host 与 TLS SNI 值,而 DNS rebinding 无法在验证后替换连接目的地址。 - 强制执行最大 URL 长度、响应字节上限、解码正文字符上限、超时和重定向跳数上限。 - Abort 信号传播到网络获取和高开销解码。 -- 仅自动跟随同源重定向;每个跟随的跳转都会重新解析公开地址,并把自己的连接固定到解析结果。跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用和新的公开地址校验。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。) +- 仅自动跟随同源重定向;每个跟随的跳转都会重复 URL 校验。跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。) - 请求携带显式的产品 User-Agent,而非静默伪装浏览器。 -只要 DNS 完整解析结果中存在任一非公开地址,提供方就会拒绝整个结果,而不是静默过滤不安全成员。该 fail-closed 规则可防止连接的地址族选择或回退触及未满足公开网络策略的地址。 +请求使用进程级 dispatcher,因此由已安装的出站代理策略与系统解析器决定连接。loopback、内网与云 metadata 目的地址都可访问。 ## 工具消费方行为 @@ -313,17 +311,14 @@ fetch 提供方的资源控制: 在 seam 层面否决。`prompt` 将 fetch 变成 LLM 摘要,并将公开 web 获取耦合到模型提供方。harness seam 应当确定性地获取和解码;`dsh-tool-web` 日后可以将摘要作为展示模式提供,而无需让 `ctx.web` 依赖 `ctx.llm`。 -### 验证 DNS 后调用普通 fetch + +### 只允许抓取公开地址 -否决,因为普通 fetch 在打开连接时会再次解析 hostname。攻击者可以在验证时返回公开地址,在第二次解析时返回私有地址。把已验证解析结果通过连接的 lookup 回调传入,可以在保留基于 hostname 的 HTTP 与 TLS 行为的同时关闭这一 rebinding 时间窗口。 - -### 只阻断看起来像私网的 hostname 字符串,不固定解析地址 - -否决,因为 hostname 语法无法确定连接目的地址:任意看似公开的名称都可能解析到 loopback、私有网段或云 metadata 地址。地址分类必须在解析后执行,连接回退可使用的每个地址都必须通过校验。 +否决。解析每个 hostname、拒绝任何非公开解析结果并把连接固定到已验证地址,并不能约束模型,因为模型可以通过已挂载的 shell 工具访问相同网络;同时在 DNS 返回非公开地址的主机上(例如使用 fake-IP DNS 的 TUN 模式代理),这会拒绝所有页面。需要阻止模型访问私有目的地址的部署应使用网络隔离、`tools/pre-execute` 策略,或不挂载抓取工具。 ### 在公开抓取前要求逐次审批 -已交付的 preset 不采用这一方案。公开地址校验会阻断 SSRF 目的地址,而逐次确认会打断普通浏览,却不能可靠控制公开数据出站:模型可以通过已挂载的 shell 工具访问同一公开网络。要求专门确认步骤的部署可以添加 `tools/pre-execute` 策略或禁用 `web_fetch`。 +已交付的 preset 不采用这一方案。逐次确认会打断普通浏览,却不能可靠控制数据出站:模型可以通过已挂载的 shell 工具访问相同网络。要求专门确认步骤的部署可以添加 `tools/pre-execute` 策略或禁用 `web_fetch`。 ## 后果 @@ -335,7 +330,7 @@ fetch 提供方的资源控制: **提供方状态可能在启动后变化。** 一个工具可能在步骤开始时组装的请求中可见,但在执行前失去其提供方。执行路径重新解析并以结构化错误失败。 -**Fetch 是网络边界,不仅仅是只读工具。** 公开地址校验与连接固定可防止 `web_fetch` 触达非公开目的地址,但模型仍可通过公开 URL 泄露数据,抓取文本也仍是不受信任的模型输入。已交付的 `cordis`、`code` 与 `standard` preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。 +**Fetch 是网络边界,不仅仅是只读工具。** `web_fetch` 可以访问主机能访问的任何目的地址,包括 loopback 与内网服务;模型可以通过 URL 泄露数据,抓取文本也仍是不受信任的模型输入。已交付的 `cordis`、`code` 与 `standard` preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。 **大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated`;`tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。 diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml index 18f65a56f9..1169daf37a 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.i18n.yaml @@ -9,14 +9,14 @@ en: 6379f0a2522f4c46 zh: c45ffeb5089be274 /agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/decision: - en: 92526818ff4870c2 - zh: 8c54ff3f4941d5ad + en: 6435c336ab53ca0f + zh: 57070799a7061f85 /agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/alternatives-considered: en: df6c078f239a11f5 zh: 914da26836aa373b /agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/consequences: - en: 6a4acbab915f1bd1 - zh: 2c8ffd62ba74ff96 + en: ff1898a64e5db79c + zh: 0a77d02385fb3506 /agent-note-one-outbound-proxy-policy-installed-before-anything-can-request/testing: - en: 0d7eaa666c94a5cd - zh: 210fa098f154d137 + en: fddb0be6abbd809e + zh: 88843f0f3ea60555 diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md index 7d9b909678..fb2d4ae3a6 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.md @@ -36,7 +36,7 @@ This keeps `proxyForUrl()` and the dispatcher answering from one set of values. **Rejection is quiet, and never reroutes the refused scheme.** A slot the user filled and this package refused keeps that scheme direct rather than falling through to `ALL_PROXY` or the HTTP proxy, so the diagnostic and the route agree. A SOCKS URL, an unparseable string, or an unsupported scheme is reported on stderr and skipped — the variable may have been exported for other tools, and a typo in it must not stop the agent from starting. The environment is the only source, so no configuration surface exists where `AGENTS.md`'s fail-loud rule would apply instead. -**Through a proxy, `web_fetch` stops resolving and pinning.** The provider validates a public address set and pins the connection to it. Through a proxy there is nothing to pin — the proxy performs the origin's DNS — and a pinned direct connection would bypass the proxy entirely. So a proxied hop skips resolution, and configuring a proxy is a statement that the proxy is trusted with destination selection. A hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, takes the resolved-and-pinned path unchanged. Kimi Code and Claude Code reached this same conclusion independently. +**`web_fetch` uses the installed dispatcher.** The provider sends every request through the process-wide dispatcher, so a proxied hop goes through the proxy, which performs the origin's DNS, and a hop the policy bypasses, which includes every loopback and every `NO_PROXY` entry, connects directly. The provider does not restrict destination addresses ([web capability seam](2026-06-24-web-capability-seam.md#restrict-fetch-to-public-addresses)). The URL-level policy is untouched: `http(s)` only, no embedded credentials, the length cap, and the cross-origin redirect refusal all still apply on every hop. @@ -54,7 +54,7 @@ Weighed against that, telemetry is the one outbound channel whose loss costs the **Every call site carries an egress test.** Each owning package's `egress.spec.ts` drives its actual transport through a fake proxy and checks the observed route. These tests cover search backends, pi-ai discovery, MCP over HTTP, child Node processes, worker threads and telemetry's direct-route exception. They detect dependency transport changes that a static call-site check cannot observe. -**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `proxyRouteFor(url)` is the sanctioned replacement, and the one call site that genuinely owns its transport — `web-fetch-http`, pinning a request to addresses it validated — says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it. +**A gate keeps the defect from returning.** `verify-no-bare-dispatcher` parses the TypeScript AST — `scripts/AGENTS.md` requires syntax-aware discovery, and a line-wise regex missed both the `{ dispatcher }` shorthand this repository already uses and a `new Alias(...)` behind a renamed import. It rejects an undici agent construction and an explicit `dispatcher` option outside the owning package. `proxyRouteFor(url)` is the sanctioned replacement, and a call site that genuinely owns its transport says so with a `proxy-exempt:` comment. The rule exists because `web-fetch-http`'s original `new Agent` was entirely reasonable when it was written — proxying simply did not exist yet, and nothing would have caught it. ## Alternatives considered @@ -78,8 +78,6 @@ A user who exports `HTTPS_PROXY`, or writes it into `$DSH_HOME/.env`, is proxied Because the operating system's settings are not read, the user-facing documentation is now load-bearing rather than supplementary: a user who only toggled "system proxy" in a proxy application gets nothing and no diagnostic. `docs/user/guide/network-proxy.md` therefore states which variables to export and why a browser is proxied when a terminal is not — the three-mechanism confusion is the single most common report, and it is not specific to this harness. -`web_fetch`'s safety story now has two shapes, and its README says so: direct hops keep address validation and pinning, proxied hops delegate destination selection to a proxy the operator configured. This is the one outward-facing security promise the change alters. - Reaching Node's built-in `fetch` from a userland undici depends on both writing the legacy `Symbol.for('undici.globalDispatcher.1')` slot. That is an implicit cross-version coupling rather than a contract — corepack#834 records it breaking — so `tests/install.spec.ts` drives a real request through a loopback proxy. A version bump that breaks the coupling fails there instead of in the field. The suite is hermetic against the developer's own environment: every Vitest configuration runs `scripts/test-proxy-environment.ts`, which clears all eight proxy names in both casings before any test, and `install.spec.ts` restores the machine's values around each case that sets its own. It has to. An exported lowercase `all_proxy` decided a test's outcome during development, because resolution reads lowercase first. @@ -88,7 +86,7 @@ The suite is hermetic against the developer's own environment: every Vitest conf `packages/util/http-proxy` holds 84 tests at 100% per-file coverage. Resolution covers precedence, the `ALL_PROXY` fallback, blank-shadowing, the SOCKS and malformed diagnostics, and the HTTPS-only environment that leaves `http:` direct; routing covers the whole loopback range structurally, and bypass matching covers suffixes, ports, both IPv6 spellings, and the CIDR entry that deliberately does not match. Installation drives a real loopback proxy and asserts the absolute-form request arrives, that a bypassed target does not, and that disposal restores the dispatcher, the policy, and the environment. Every case installs through `installProxyFromEnvironment`, so no test can assert a policy object a real launch could not produce. -`packages/web/web-fetch-http/tests/proxy.spec.ts` asserts the decision that matters most: under a proxy the public-address resolver is never called, while a bypassed hop still calls it exactly once, and the cross-origin redirect refusal survives on the proxied path. +`packages/web/web-fetch-http/tests/proxy.spec.ts` asserts that a proxied hop reaches the proxy, a bypassed hop connects directly, and the cross-origin redirect refusal survives on the proxied path. `verify-no-bare-dispatcher.spec.ts` proves the gate rejects the exact shape this package was introduced to fix, accepts `proxyRouteFor`, accepts an annotated exemption, and passes on the current tree. diff --git a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md index f24e811af7..bd48a6e6b5 100644 --- a/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-27-outbound-proxy-policy.zh.md @@ -36,7 +36,7 @@ Node 内置的 `fetch` 会忽略 `HTTP_PROXY` 与 `HTTPS_PROXY`。开发者运 **拒绝是静默的,且绝不为被拒协议改道。** 用户填写而被本包拒绝的槽位,会让该协议保持直连,而不是继续回退到 `ALL_PROXY` 或 HTTP 代理,从而让诊断与实际路由一致。SOCKS URL、无法解析的字符串或不受支持的协议,会在 stderr 上报告并跳过——该变量可能是为其他工具导出的,它的笔误不应阻止 agent 启动。环境是唯一来源,因此不存在一个本应适用 `AGENTS.md` 「配置错误必须响」规则的配置面。 -**经由代理时,`web_fetch` 不再解析与固定地址。** 该提供方会校验一组公网地址并把连接固定到其上。经由代理时没有可固定的对象——origin 的 DNS 由代理执行——而固定后的直连会彻底绕开代理。因此代理转发的一跳跳过解析,配置代理即表示信任该代理进行目的地选择。被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,仍走原有的解析并固定路径。Kimi Code 与 Claude Code 各自独立得出了同一结论。 +**`web_fetch` 使用已安装的 dispatcher。** 该提供方的每个请求都经过进程级 dispatcher,因此代理转发的一跳经过代理,由代理执行 origin 的 DNS;被策略绕过的一跳,包括每一个 loopback 与每一条 `NO_PROXY` 条目,则直接连接。该提供方不限制目的地址([web capability seam](2026-06-24-web-capability-seam.zh.md#restrict-fetch-to-public-addresses))。 URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与跨域重定向拒绝在每一跳上依然生效。 @@ -54,7 +54,7 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 **每个出网点都配有出网测试。** 各所属包中的 `egress.spec.ts` 通过假代理驱动实际传输,并检查观察到的路由。这些测试覆盖搜索后端、pi-ai 发现、走 HTTP 的 MCP、子 Node 进程、worker 线程和遥测的直连例外。它们可发现静态调用点检查无法观察到的依赖传输变化。 -**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;唯一一处确实自有传输的调用点——`web-fetch-http`,它把请求钉在已校验的地址上——用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。 +**用门禁防止该缺陷复现。** `verify-no-bare-dispatcher` 解析 TypeScript AST——`scripts/AGENTS.md` 要求 source-ownership 门禁使用语法感知发现,而逐行正则漏掉了本仓库已在使用的 `{ dispatcher }` 简写,以及重命名导入后的 `new Alias(...)`。它在所属包之外拒绝 undici agent 构造与显式 `dispatcher` 选项。`proxyRouteFor(url)` 是受支持的替代;确实自有传输的调用点用 `proxy-exempt:` 注释说明。这条规则之所以存在,是因为 `web-fetch-http` 里原本那行 `new Agent` 在写下时完全合理——那时根本还没有代理这回事,也没有任何机制会拦下它。 ## Alternatives considered @@ -78,8 +78,6 @@ URL 层策略未受影响:仅 `http(s)`、禁止内嵌凭据、长度上限与 由于不读取操作系统设置,面向用户的文档从补充材料变成了承重件:仅在代理软件里拨了「系统代理」开关的用户什么也得不到,且没有诊断。因此 `docs/user/guide/network-proxy.md` 说明了要导出哪些变量,以及为什么浏览器走代理而终端不走——这个「三套机制」的困惑是最常见的报障,且并非本 Harness 特有。 -`web_fetch` 的安全叙述现在有两种形态,其 README 已如实说明:直连的一跳保留地址校验与固定,代理转发的一跳把目的地选择交给运维方配置的代理。这是本次变更唯一改动的对外安全承诺。 - userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 legacy 的 `Symbol.for('undici.globalDispatcher.1')` 槽位。那是跨版本的隐式耦合而非约定——corepack#834 记录了它失效的实例——因此 `tests/install.spec.ts` 会驱动一次真实请求穿过 loopback 代理。破坏该耦合的版本升级会在那里失败,而不是流到线上。 测试套件对开发者自身的环境免疫:每份 Vitest 配置都会先运行 `scripts/test-proxy-environment.ts`,在任何测试之前清除全部八个代理变量名的两种大小写形式;`install.spec.ts` 则在每个自行设值的用例前后还原本机的值。这是必需的。开发过程中,一个已导出的小写 `all_proxy` 曾决定了某个测试的结果,因为解析优先读取小写。 @@ -88,7 +86,7 @@ userland undici 能触及 Node 内置的 `fetch`,依赖于两者都会写入 l `packages/util/http-proxy` 有 84 个测试,per-file 覆盖率 100%。解析覆盖优先级、`ALL_PROXY` 兜底、空值遮蔽、SOCKS 与畸形值诊断,以及只设 https 变量时 `http:` 保持直连;路由以结构化方式覆盖整个 loopback 网段,绕过匹配覆盖后缀、端口、两种 IPv6 写法,以及刻意不匹配的 CIDR 条目。安装驱动一个真实的 loopback 代理,断言绝对形式的请求确实抵达、被绕过的目标不抵达,且 dispose 会还原 dispatcher、策略与环境。所有用例一律经 `installProxyFromEnvironment` 安装,因此没有测试能断言一次真实启动无法产生的策略对象。 -`packages/web/web-fetch-http/tests/proxy.spec.ts` 断言了最关键的那个决定:经由代理时公网地址解析器完全不被调用,而被绕过的一跳仍恰好调用一次,且跨域重定向拒绝在代理路径上依然成立。 +`packages/web/web-fetch-http/tests/proxy.spec.ts` 断言代理转发的一跳抵达代理、被绕过的一跳直接连接,且跨域重定向拒绝在代理路径上依然成立。 `verify-no-bare-dispatcher.spec.ts` 证明该门禁能拒掉本包所要修复的那种写法、接受 `proxyRouteFor`、接受带注释的豁免,并在当前代码树上通过。 diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml index d3002b42b6..dea1cb8a9f 100644 --- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.i18n.yaml @@ -15,11 +15,11 @@ en: ec653b59d62ed95f zh: 0d675616059930ba /agent-note-direct-deepseek-search-and-fetch-apis/decision/search-results-and-model-input: - en: a95618f172ef63ee - zh: 306556be09c56bdf + en: ad7b3e4fbe528cf2 + zh: 8ab8779bfdaaa4fa /agent-note-direct-deepseek-search-and-fetch-apis/decision/fetch-metadata-and-ownership: - en: 8051417b08739ed0 - zh: c11d35a346014436 + en: 7566e400f9ab3a51 + zh: 118b2e84d0030480 /agent-note-direct-deepseek-search-and-fetch-apis/decision/default-migration: en: d5a8e36b545f1c04 zh: d741fd0789401c62 @@ -33,5 +33,5 @@ en: a55da02f4d3c06c0 zh: 3c1a624f03f34f28 /agent-note-direct-deepseek-search-and-fetch-apis/verification-and-remaining-api-adoption: - en: 41f9a331c7aca397 - zh: 17a0fd53e133c2f5 + en: 4c84e7bd035b52a7 + zh: 4e5687bda0a47706 diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md index 1df773558d..4369faee79 100644 --- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.md @@ -24,7 +24,7 @@ The guide specifies API-key Bearer authentication, and requests send only the do Direct Search sends `query`, `count`, `need_content: false`, and `ori_question`. `count` is the configured value from 1 to 50, defaulting to the service's 10, unless the request's result bound is smaller; the tool always sends its limit, 8 by default. `ori_question` is the text of the initiating Session's latest human prompt with whitespace collapsed and cut to the API's 128-character limit. The API rejects blank or longer values, so a search without such a prompt omits the field, and `sendOriginalQuestion: false` disables it. In 2026-10-16 probes, different values left result order, scores, and summaries unchanged. Queries pass through unchanged, including the documented site, language, date, exclusion, and exact-phrase operators. The service enforces the 1–128 Unicode-character query constraint. Empty `results` is successful. -The provider preserves service order and maps URL, title, `snippet`, and nullable `date` into shared sources. The current tool gives the same short excerpt to the model and search card. It does not consume `summary`, full `content`, `site_name`, `site_icon`, `rerank_score`, or `authority`, and it does not send `localization`. Those omissions are integration limits, not absent API capabilities. +The provider preserves service order and maps URL, title, `snippet`, `summary`, and nullable `date` into shared sources. Following the guide, the tool gives the model each source's `summary` and keeps the short `snippet` for the search card and its replay metadata. The guide's typical summary length is not a maximum: returned summaries measured 1,000–2,300 characters on 2026-10-16. `dsh-tool-web` therefore collapses summary whitespace and caps each summary at `searchSummaryMaxChars`, 800 characters by default, the upper end of the guide's typical range; one call's summaries stay within the source cap times that cap. The summary rides in the tool output value and rendered text, which the Session already records. The provider does not consume full `content`, `site_name`, `site_icon`, `rerank_score`, or `authority`, and it does not send `localization`. Those omissions are integration limits, not absent API capabilities. Direct retrieval emits no auxiliary model-request event because Harness makes no such request. The normal tool-call and tool-result events retain the query and model-visible result. This says nothing about the search service's internal use of models. Returned text remains external, untrusted data. @@ -32,7 +32,7 @@ Direct retrieval emits no auxiliary model-request event because Harness makes no Fetch sends a URL and returns Markdown without an extra Harness summarization request. The API supplies neither a final redirected URL nor a target HTTP status, and the shared result has no title field. The provider keeps the requested URL, returns the Markdown body, and reports HTTP 200 for a successful response: the Fetch API answers 404 when the URL is unavailable, blocked by site policy, or returns an error, or when no Markdown can be extracted. On 2026-10-16 an unresolvable host returned HTTP 500 instead, so every non-success status is a retrieval failure rather than a specific target diagnosis. The shared fetch result and its consumers stay unchanged. -The HTTP provider retains its own public-address validation, connection pinning, and resource limits. Selecting DeepSeek delegates page retrieval and target access policy to that service; Harness rejects redirects of the API request and retains cancellation and bounded tool output. The reported HTTP 200 therefore means the service retrieved the page, not that Harness observed the target response. The guide's Fetch 403 means crawler-policy rejection, while 404 means no usable page content. +The HTTP provider retains its own URL validation and resource limits. Selecting DeepSeek delegates page retrieval and target access policy to that service; Harness rejects redirects of the API request and retains cancellation and bounded tool output. The reported HTTP 200 therefore means the service retrieved the page, not that Harness observed the target response. The guide's Fetch 403 means crawler-policy rejection, while 404 means no usable page content. ### Default migration @@ -78,7 +78,6 @@ Provider tests cover request mapping, empty results, malformed responses, indepe The following capabilities remain outside the implemented integration: -- Separate model-facing `summary` from display `snippet`, with per-source and total output budgets and matching Session records. The guide's typical summary length is not a maximum: returned summaries measured 1,000–2,300 characters on 2026-10-16. - Expose search syntax in provider-appropriate tool guidance and define a bounded localization input before forwarding it. API `localization` is an IP address, not an inferred city or UI language. - Retain quality metadata before defining selection policies. `rerank_score` and `authority` describe service assessments, not guarantees of truth; a hard cutoff can discard useful niche sources. - Add cancellation-aware scheduling and bounded retry ownership. The guide recommends waiting before retrying 408, 429, and 5xx; 402 requires funding first, and other 4xx require a changed request. Local pacing cannot enforce an account-wide quota across unrelated processes. diff --git a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md index 997a7d5509..0b736cb445 100644 --- a/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md +++ b/.agents/notes/implemented/feature/2026-10-15-deepseek-search-fetch-api.zh.md @@ -24,7 +24,7 @@ Status: implemented 直接 Search 发送 `query`、`count`、`need_content: false` 和 `ori_question`。`count` 为 1 至 50 的配置值,默认采用服务的 10;请求的结果上限更小时以后者为准,工具总会发送其上限,默认为 8。`ori_question` 是发起会话中最近一条用户提示的文本,空白折叠后截取至 API 的 128 个字符上限。API 拒绝空白或更长的值,因此没有此类提示的搜索不发送该字段,`sendOriginalQuestion: false` 可将其关闭。2026-10-16 的探测中,不同取值未改变结果顺序、评分或摘要。查询原样传递,包括文档中的站点、语言、日期、排除和精确短语操作符。服务端执行查询长度为 1–128 个 Unicode 字符的约束。空 `results` 表示成功。 -提供方保留服务端顺序,将 URL、标题、`snippet` 和可为 null 的 `date` 映射为共享来源。当前工具将同一段短摘要提供给模型和搜索卡片。它不使用 `summary`、完整 `content`、`site_name`、`site_icon`、`rerank_score` 或 `authority`,也不发送 `localization`。这些是当前接入的限制,并非 API 缺少这些能力。 +提供方保留服务端顺序,将 URL、标题、`snippet`、`summary` 和可为 null 的 `date` 映射为共享来源。按照指南,工具把每个来源的 `summary` 提供给模型,短的 `snippet` 留给搜索卡片及其回放元数据。指南中的典型摘要长度不是最大值:2026-10-16 实测返回的摘要为 1,000–2,300 个字符。因此 `dsh-tool-web` 合并摘要中的空白,并将每条摘要限制在 `searchSummaryMaxChars` 以内,默认 800 个字符,即指南典型范围的上限;一次调用的摘要总长不超过来源上限乘以该上限。摘要随工具输出值和渲染文本一起进入 Session 记录。提供方不使用完整 `content`、`site_name`、`site_icon`、`rerank_score` 或 `authority`,也不发送 `localization`。这些是当前接入的限制,并非 API 缺少这些能力。 直接检索不产生辅助模型请求事件,因为 Harness 没有发起该请求。普通工具调用与工具结果事件继续保留查询及模型可见的结果。这不代表搜索服务内部不使用模型。返回文本仍是外部不受信任的数据。 @@ -32,7 +32,7 @@ Status: implemented Fetch 发送 URL,返回 Markdown,无需 Harness 额外发起摘要请求。API 既不提供重定向后的最终 URL,也不提供目标 HTTP 状态,而共享结果没有标题字段。提供方保留请求 URL,返回 Markdown 正文,并对成功响应报告 HTTP 200:URL 不可用、被站点策略拦截、目标站点返回错误或无法提取 Markdown 时,Fetch API 会返回 404。2026-10-16 无法解析的主机实际返回 HTTP 500,因此任何非成功状态都视为抓取失败,而不是对目标的具体诊断。共享抓取结果及其消费方保持不变。 -HTTP 提供方继续负责自身的公开地址校验、连接固定和资源限制。选择 DeepSeek 会将页面抓取与目标访问策略交给该服务;Harness 拒绝 API 请求重定向,并保留取消与工具输出限额。因此报告的 HTTP 200 表示服务已获取页面,而不是 Harness 观察到了目标响应。指南中的 Fetch 403 表示抓取器策略拒绝,404 则表示没有可用的页面内容。 +HTTP 提供方继续负责自身的 URL 校验和资源限制。选择 DeepSeek 会将页面抓取与目标访问策略交给该服务;Harness 拒绝 API 请求重定向,并保留取消与工具输出限额。因此报告的 HTTP 200 表示服务已获取页面,而不是 Harness 观察到了目标响应。指南中的 Fetch 403 表示抓取器策略拒绝,404 则表示没有可用的页面内容。 ### 默认值迁移 @@ -78,7 +78,6 @@ Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一 以下能力尚未包含在已实现的接入中: -- 区分面向模型的 `summary` 与用于展示的 `snippet`,设置每个来源和总输出限额,并记录对应的 Session 内容。指南中的典型摘要长度不是最大值:2026-10-16 实测返回的摘要为 1,000–2,300 个字符。 - 在适用于提供方的工具说明中介绍搜索语法,先定义有界的本地化输入,再进行转发。API 的 `localization` 是 IP 地址,不能用推测的城市或界面语言代替。 - 先保留质量元数据,再定义筛选策略。`rerank_score` 与 `authority` 表示服务端评估,不保证内容真实;固定阈值可能丢弃有用的冷门来源。 - 增加支持取消的调度并明确有限重试的职责。指南建议对 408、429 和 5xx 等待后重试;402 需要先充值,其他 4xx 需要修改请求。本地节流无法跨无关进程执行账号级配额。 diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 3144f47b3a..d7cbbd9ac6 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/README.i18n.yaml @@ -24,8 +24,8 @@ en: 850434e8ca162221 zh: 4893613f97a21eb8 /dsh-cli-behavior-reference/shared-deployment-behavior: - en: 6a7abc9339e8f37f - zh: 133adb834c8e195d + en: f40edcf903a51f3f + zh: 8a9a915451047c8b /dsh-cli-behavior-reference/source-execution: en: 05c2b23790f7b296 zh: 51f809020fb230a1 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 58ffe96eb8..98b7197a92 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -134,7 +134,7 @@ New sessions in base-backed profiles default to the `workspace-write` permission ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the public-only HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation; the provider rejects non-public destinations before connecting. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search` and `web_fetch`, the HTTP fetch provider, default-on DeepSeek session-log upload, and feedback-gated OTel upload for all users. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`. Enabled fetch calls run in every sandbox and approval mode without per-call confirmation and can reach loopback and private-network hosts. The Web app disables the base tool row and exposes the same tools through its `cordis`, `ptc`, and `standard` agent presets. Feedback is recorded in the Session log without starting model work. The [DeepSeek session-log contributor](../../../packages/session/session-log-deepseek/README.md) sends complete unaccepted log suffixes with subsequent DeepSeek requests by default, including requests sent through configured gateways; turn off **Settings → General → Upload Session Log when using the official model API** in Web, or set its `enabled` configuration to `false` to opt out. [OTel session upload](../../../packages/session/session-telemetry-otel/README.md) applies to all users and providers, including `deepseek-official`, without requiring a request header. The base defaults to `FEEDBACK_ONLY`: new own text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including stored context; later records wait for the next explicit feedback. Inherited parent feedback does not authorize a fork. Requests, restoration, mount, and HMR do not trigger capture. SDK batching may finish an authorized upload without further interaction or model work. `DSH_TELEMETRY_MODE=DISABLED` disables OTel delivery; `FULL` is rejected, and any non-empty `DSH_TELEMETRY_DISABLED` disables its row. `DSH_TELEMETRY_OTLP_URL` selects the collector. Handoff is best-effort, not collector acceptance; no durable outbox or retry guarantee is provided. These OTel settings do not enable or disable the DeepSeek contribution. Neither path changes model input, but exports can include message text, tool arguments and results, and workspace paths. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index 85312166bc..6441309f1c 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -136,7 +136,7 @@ dsh web --help ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、仅限公网的 HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认;提供方会在连接前拒绝非公开目的地址。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`。已启用的抓取调用会在所有 sandbox 与审批模式下执行,无需逐次确认,并且可以访问 loopback 与内网主机。Web app 会禁用 base 工具配置项,再通过 `cordis`、`ptc` 与 `standard` agent preset 暴露相同工具。 反馈记录在会话日志中,不会启动模型工作。[DeepSeek 会话日志贡献器](../../../packages/session/session-log-deepseek/README.zh.md)默认随后续 DeepSeek 请求发送尚未确认接收的完整日志后缀,包括经已配置网关发送的请求;可在 Web 的**设置 → 通用 → 在使用官方模型 API 时上传 Session Log**中关闭上传,或将其 `enabled` 配置设为 `false`。[OTel 会话上传](../../../packages/session/session-telemetry-otel/README.zh.md)适用于所有用户和提供方,包括 `deepseek-official`,无需请求头。基础配置默认使用 `FEEDBACK_ONLY`:新的自身文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范日志前缀,包含存储的上下文;后续记录等待下一次显式反馈。继承的父级反馈不构成 fork 的授权。请求、恢复、挂载和 HMR 不触发捕获。SDK 批处理可完成已授权上传,无需进一步交互或模型工作。`DSH_TELEMETRY_MODE=DISABLED` 禁止 OTel 投递;`FULL` 被拒绝,任何非空的 `DSH_TELEMETRY_DISABLED` 都会禁用其配置行。`DSH_TELEMETRY_OTLP_URL` 选择采集端。交接尽力而为,不代表采集端接受;不提供持久化 outbox 或重试保证。这些 OTel 设置不会开启或关闭 DeepSeek 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。 diff --git a/apps/web/tests/cot-translation-paid.e2e.ts b/apps/web/tests/cot-translation-paid.e2e.ts index 0954aa180a..f8afb08a67 100644 --- a/apps/web/tests/cot-translation-paid.e2e.ts +++ b/apps/web/tests/cot-translation-paid.e2e.ts @@ -195,8 +195,8 @@ it.skipIf(MODE === 'record')('reuses saved native Account Flash translations acr expect(calls).toEqual([]) await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor() await panel.getByText(bundleVersion, { exact: true }).waitFor() - // The bundle's README is its detail document, shown between its components and its source. - await panel.locator('[data-detail-document] h1').waitFor() + // The bundle's detail document is shown between its components and its source. + await panel.locator('[data-detail-document]').getByRole('heading', { name: '使用方法', exact: true }).waitFor() await compareOrRefreshGolden(join(EXPECTED, 'paid-choices.expected.md'), await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']], diff --git a/apps/web/tests/cot-translation.e2e.ts b/apps/web/tests/cot-translation.e2e.ts index 8419b55b79..cf10562c90 100644 --- a/apps/web/tests/cot-translation.e2e.ts +++ b/apps/web/tests/cot-translation.e2e.ts @@ -212,8 +212,8 @@ it.skipIf(MODE === 'record')('persists Bing reasoning translation across reopen await panel.getByText('展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。', { exact: true }).waitFor() await panel.getByText(`v${bundleVersion}`, { exact: true }).waitFor() await panel.getByText(bundleVersion, { exact: true }).waitFor() - // The bundle's README is its detail document, shown between its components and its source. - await panel.locator('[data-detail-document] h1').waitFor() + // The bundle's detail document is shown between its components and its source. + await panel.locator('[data-detail-document]').getByRole('heading', { name: '使用方法', exact: true }).waitFor() await compareOrRefreshGolden(join(EXPECTED, 'preferences.expected.md'), await captureStableAria(settings, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[BUNDLE, '{{bundle}}'], [bundleVersion, '{{version}}']], diff --git a/apps/web/tests/cua-optin.e2e.ts b/apps/web/tests/cua-optin.e2e.ts index 7a35661fe4..9cc9cf1f16 100644 --- a/apps/web/tests/cua-optin.e2e.ts +++ b/apps/web/tests/cua-optin.e2e.ts @@ -45,7 +45,12 @@ it.each([undefined, 'minimal'] as const)('adds CUA to PTC sessions while preserv const before = await readFile(settingsPath, 'utf8') const selected = () => readProfileManifest('scaffold', join(scaffold.harnessHome, 'profiles/scaffold')).dsh?.profile?.bundles await page.getByRole('navigation', { name: '全局面板' }).getByRole('button', { name: '插件', exact: true }).click() + const plugins = page.locator('[data-plugin-panel]') + await plugins.getByRole('button', { name: '更多', exact: true }).waitFor() + expect(await page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]').count()).toBe(0) + await plugins.getByRole('button', { name: '更多', exact: true }).click() const card = page.locator('[data-plugin-package="@deepseek-ai/dsh-cua-bundle"]') + expect(await card.getByText('实验性', { exact: true }).count()).toBe(1) const toggle = card.getByRole('switch', { name: '启用 Computer Use', exact: true }) expect(await toggle.getAttribute('aria-checked')).toBe('false') expect(await card.textContent()).toContain('请切换到 PTC 模式') diff --git a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md index 3e4ab96099..f50238cc81 100644 --- a/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md +++ b/apps/web/tests/expected/cot-translation-paid/paid-choices.expected.md @@ -7,64 +7,55 @@ - code: "@deepseek-ai/dsh-experimental-cot-translation-bundle" - paragraph: 机器翻译展开的思考内容;原文会发送给所选服务 - region "详细介绍": - - heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1] - - paragraph: English | 中文 - - heading "Summary" [level=2] - - paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input. - - heading "Table of Contents" [level=2] + - paragraph: + - img "展开的思考行从英文翻译成中文,并显示“查看原文”按钮" + - paragraph: + - text: 展开 + - strong: 思考 + - text: 行,即可用界面语言阅读其中的思考内容。智能体输出时会逐段翻译;最新的内容在能够翻译之前保持原文。 + - heading "使用方法" [level=2] - list: - - listitem: Use this package - - listitem: Understand the implementation - - listitem: Further Exploration - - listitem: Model Experience - - listitem: Known Limitations and Deferred Work - - listitem: Dev Note - - separator - - paragraph - - heading "Use this package" [level=2] - - paragraph: - - text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language. - - code: auto - - text: follows the interface language; an explicit language code such as - - code: zh - - text: "," - - code: en - - text: ", or" - - code: ja - - text: overrides it. Save applies the preferences. - - paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts. - - paragraph: - - text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native - - code: deepseek-flash - - text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically. - - separator - - paragraph - - heading "Understand the implementation" [level=2] - - paragraph: - - text: The bundle patch inserts - - code: translator - - text: and - - code: cot-translation - - text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory." - - separator - - paragraph - - heading "Further Exploration" [level=2] - - paragraph: Text translation subsystem · Translator service · GUI consumer - - separator - - paragraph - - heading "Model Experience" [level=2] - - paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context. - - heading "KV Cache effect" [level=4] - - paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer. - - heading "Known Limitations and Deferred Work" [level=2] - - paragraph + - listitem: + - text: 展开 + - strong: 思考 + - text: 行。点击 + - strong: 查看原文 + - text: 显示原文,点击 + - strong: 查看译文 + - text: 切换回译文。 + - listitem: + - text: 在此插件的页面中选择 + - strong: 翻译服务 + - text: 和 + - strong: 目标语言 + - text: ,然后点击 + - strong: 保存 + - text: 。 + - code: auto + - text: 表示跟随界面语言;也可以填写 + - code: zh + - text: 、 + - code: en + - text: 、 + - code: ja + - text: 等语言代码。 + - listitem: + - text: 翻译失败时,原文保持显示,并提供 + - strong: 重试 + - text: 。 + - heading "注意事项" [level=2] - list: - - listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible. - - listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations. - - separator - - paragraph - - heading "Dev Note" [level=3] - - paragraph: None. + - listitem: 你展开的思考内容可能包含你的代码和对话细节。 + - listitem: 默认使用 Bing,也可以选择 Google。两者都无需账号或 API key,但都不保证可用,也不保证免费额度。 + - listitem: + - text: 如果已配置 DeepSeek 账号或官方 API,还会出现 + - strong: DeepSeek Flash · 账号(付费) + - text: 和 + - strong: DeepSeek Flash · 官方 API(付费) + - text: 。此时每段没有已保存译文的内容都是一次付费请求。 + - listitem: 译文保存在会话中,重新打开时直接复用。原始思考内容和模型的输入都不会改变。 + - listitem: 折叠时显示的摘要保持原文。翻译可能改变 Markdown 格式或代码。 + - listitem: 关闭此插件会移除它的按钮并取消未完成的翻译;已保存的译文仍保留在会话中。 - paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。 - paragraph: 每个未缓存片段都会通过所选账号或官方 API 凭据单独发送一次付费的 DeepSeek Flash 请求;重新展开会复用已保存的译文,更改服务、语言、原文或请求设置可能发送新的付费请求 - text: 翻译服务 已覆盖 diff --git a/apps/web/tests/expected/cot-translation/preferences.expected.md b/apps/web/tests/expected/cot-translation/preferences.expected.md index b2c7e3c3d7..223bcb3571 100644 --- a/apps/web/tests/expected/cot-translation/preferences.expected.md +++ b/apps/web/tests/expected/cot-translation/preferences.expected.md @@ -7,64 +7,55 @@ - code: "@deepseek-ai/dsh-experimental-cot-translation-bundle" - paragraph: 机器翻译展开的思考内容;原文会发送给所选服务 - region "详细介绍": - - heading "@deepseek-ai/dsh-experimental-cot-translation-bundle" [level=1] - - paragraph: English | 中文 - - heading "Summary" [level=2] - - paragraph: Translate expanded reasoning into the interface language or another chosen language. Enable Reasoning translation on the Web or Desktop Plugins page; shipped profiles leave it disabled. Bing is the default provider, and Google is available in the bundle settings. Original reasoning remains available and stays unchanged in the Session and model input. - - heading "Table of Contents" [level=2] + - paragraph: + - img "展开的思考行从英文翻译成中文,并显示“查看原文”按钮" + - paragraph: + - text: 展开 + - strong: 思考 + - text: 行,即可用界面语言阅读其中的思考内容。智能体输出时会逐段翻译;最新的内容在能够翻译之前保持原文。 + - heading "使用方法" [level=2] - list: - - listitem: Use this package - - listitem: Understand the implementation - - listitem: Further Exploration - - listitem: Model Experience - - listitem: Known Limitations and Deferred Work - - listitem: Dev Note - - separator - - paragraph - - heading "Use this package" [level=2] - - paragraph: - - text: Open Plugins and enable Reasoning translation in the Official group. Open its details to select a translation service and target language. - - code: auto - - text: follows the interface language; an explicit language code such as - - code: zh - - text: "," - - code: en - - text: ", or" - - code: ja - - text: overrides it. Save applies the preferences. - - paragraph: Expand a Think row to start translation. The row translates completed paragraphs and request-sized prefixes of long streaming paragraphs while retaining the unfinished tail. View original in the translation toolbar switches the expanded body to the source; View translation switches it back. A failed translation leaves the original visible and offers Retry. Disabling the bundle removes its controls and cancels pending calls. Successful translations remain saved in the Session and are reused when the same request is reopened, the page reloads, or the Host restarts. - - paragraph: - - text: The selected service receives the expanded reasoning, which can contain private code and conversation details. Google and Bing send source text in POST request bodies. Bing and Google require no additional login or API key, but neither anonymous endpoint guarantees availability or free quotas. Eligible native - - code: deepseek-flash - - text: routes appear as explicit paid choices using the configured DeepSeek Account or Official API credentials. Each uncached fragment issues one independent query with thinking disabled. Reopening reuses saved results; changing provider, language, source or recipe may require another billed query. The model ID is matched without a version number, and pi-ai routes are excluded. Provider failure never sends the text to another service automatically. - - separator - - paragraph - - heading "Understand the implementation" [level=2] - - paragraph: - - text: The bundle patch inserts - - code: translator - - text: and - - code: cot-translation - - text: ", selecting Bing and the interface language. The translator owns provider protocols, network limits, and shared persisted requests and results; the GUI consumer owns settings, paragraph scheduling, and a local cache for the current display. The GUI reuses saved results directly and joins normal Session activation when a new translation needs its existing writer. Its generated Remote contribution is mounted only while the bundle is enabled. The translation wrapper and Chat's default Body entry both call the official reasoning Content Factory." - - separator - - paragraph - - heading "Further Exploration" [level=2] - - paragraph: Text translation subsystem · Translator service · GUI consumer - - separator - - paragraph - - heading "Model Experience" [level=2] - - paragraph: Indirectly, through optional independent paid Flash requests and normal Session activation on cache misses; the translator owns translation prompts, and the Session's preset owns startup context. - - heading "KV Cache effect" [level=4] - - paragraph: Translation records leave original reasoning intact; normal Session activation and optional paid queries have the cache effects described by the GUI consumer. - - heading "Known Limitations and Deferred Work" [level=2] - - paragraph + - listitem: + - text: 展开 + - strong: 思考 + - text: 行。点击 + - strong: 查看原文 + - text: 显示原文,点击 + - strong: 查看译文 + - text: 切换回译文。 + - listitem: + - text: 在此插件的页面中选择 + - strong: 翻译服务 + - text: 和 + - strong: 目标语言 + - text: ,然后点击 + - strong: 保存 + - text: 。 + - code: auto + - text: 表示跟随界面语言;也可以填写 + - code: zh + - text: 、 + - code: en + - text: 、 + - code: ja + - text: 等语言代码。 + - listitem: + - text: 翻译失败时,原文保持显示,并提供 + - strong: 重试 + - text: 。 + - heading "注意事项" [level=2] - list: - - listitem: Collapsed summaries remain in the original language. Translation starts when the reader expands the reasoning body; it may change Markdown or code formatting. Original always remains accessible. - - listitem: Anonymous Google and Bing browser endpoints may fail, rate-limit requests, or change their response formats. Mainland China connectivity depends on the deployment's network and proxy configuration. Successful translations remain in the Session after collapse, bundle removal, or restart. Experimental records have best-effort retention across future Session-format migrations. - - separator - - paragraph - - heading "Dev Note" [level=3] - - paragraph: None. + - listitem: 你展开的思考内容可能包含你的代码和对话细节。 + - listitem: 默认使用 Bing,也可以选择 Google。两者都无需账号或 API key,但都不保证可用,也不保证免费额度。 + - listitem: + - text: 如果已配置 DeepSeek 账号或官方 API,还会出现 + - strong: DeepSeek Flash · 账号(付费) + - text: 和 + - strong: DeepSeek Flash · 官方 API(付费) + - text: 。此时每段没有已保存译文的内容都是一次付费请求。 + - listitem: 译文保存在会话中,重新打开时直接复用。原始思考内容和模型的输入都不会改变。 + - listitem: 折叠时显示的摘要保持原文。翻译可能改变 Markdown 格式或代码。 + - listitem: 关闭此插件会移除它的按钮并取消未完成的翻译;已保存的译文仍保留在会话中。 - paragraph: 展开的思考内容会发送给所选翻译服务,可能包含私有代码或对话细节。 - paragraph: Bing 和 Google 翻译无需额外登录或 API key,但匿名接口可能拒绝或限制请求 - text: 翻译服务 diff --git a/apps/web/tests/expected/models-settings/model-picker.expected.md b/apps/web/tests/expected/models-settings/model-picker.expected.md index 01fd8e7166..2d5d1cf617 100644 --- a/apps/web/tests/expected/models-settings/model-picker.expected.md +++ b/apps/web/tests/expected/models-settings/model-picker.expected.md @@ -1,7 +1,7 @@ -- dialog "选择要添加的模型": - - heading "选择要添加的模型" [level=2] +- dialog "选择模型": + - heading "选择模型" [level=2] - button "关闭" - - paragraph: 以下是模型提供商的可用模型,勾选要添加的模型。 + - paragraph: 以下是模型提供商的可用模型,勾选的模型即为模型列表。 - searchbox "搜索模型" - button "取消全选" - list: @@ -15,4 +15,4 @@ - checkbox "MiniMax-M3" [checked] - text: MiniMax-M3 - button "取消" - - button "添加所选" + - button "更新列表" diff --git a/apps/web/tests/expected/plugin-config/official.expected.md b/apps/web/tests/expected/plugin-config/official.expected.md index 27c2e72887..37d3a232b4 100644 --- a/apps/web/tests/expected/plugin-config/official.expected.md +++ b/apps/web/tests/expected/plugin-config/official.expected.md @@ -38,10 +38,6 @@ - button "查看 开发者工具": 开发者工具 - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 - switch "启用 开发者工具" - - listitem: - - button "查看 Computer Use": Computer Use - - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 - - switch "启用 Computer Use" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/exports-en.expected.md b/apps/web/tests/expected/plugin-manager/exports-en.expected.md index 9602e04da9..d00afb3ed1 100644 --- a/apps/web/tests/expected/plugin-manager/exports-en.expected.md +++ b/apps/web/tests/expected/plugin-manager/exports-en.expected.md @@ -6,6 +6,13 @@ - paragraph: - code: "@fixture/bundle" - paragraph: Registry description for the fixture bundle. +- region "Details": + - heading "Fixture bundle" [level=1] + - paragraph: English | 中文 + - paragraph: + - text: Registry fixture for the Web plugin-manager tests. It declares no + - code: meta.detail + - text: ", so its page shows this README." - heading "Components" [level=4] - text: 3 total · 3 off - list: diff --git a/apps/web/tests/expected/plugin-manager/exports.expected.md b/apps/web/tests/expected/plugin-manager/exports.expected.md index 702150c359..067989b093 100644 --- a/apps/web/tests/expected/plugin-manager/exports.expected.md +++ b/apps/web/tests/expected/plugin-manager/exports.expected.md @@ -6,6 +6,13 @@ - paragraph: - code: "@fixture/bundle" - paragraph: Registry description for the fixture bundle. +- region "详细介绍": + - heading "Fixture bundle" [level=1] + - paragraph: English | 中文 + - paragraph: + - text: Registry fixture for the Web plugin-manager tests. It declares no + - code: meta.detail + - text: ", so its page shows this README." - heading "包含的组件" [level=4] - text: 共 3 个 · 3 已停用 - list: diff --git a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md index cfe553e2bb..eab9dd48e1 100644 --- a/apps/web/tests/expected/plugin-manager/live-enabled.expected.md +++ b/apps/web/tests/expected/plugin-manager/live-enabled.expected.md @@ -38,10 +38,6 @@ - button "查看 开发者工具": 开发者工具 - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 - switch "启用 开发者工具" - - listitem: - - button "查看 Computer Use": Computer Use - - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 - - switch "启用 Computer Use" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/manager.expected.md b/apps/web/tests/expected/plugin-manager/manager.expected.md index d58ad62b03..f3dd4a7bc6 100644 --- a/apps/web/tests/expected/plugin-manager/manager.expected.md +++ b/apps/web/tests/expected/plugin-manager/manager.expected.md @@ -38,10 +38,6 @@ - button "查看 开发者工具": 开发者工具 - text: 实验性 查看调试会话原始数据、聊天消息分组数据,以及调试 NodeJS 后端 - switch "启用 开发者工具" - - listitem: - - button "查看 Computer Use": Computer Use - - text: 目前仅支持 macOS,Windows 支持正在开发中。为已有及新建 PTC 会话加载 Computer Use。请切换到 PTC 模式来使用,并授予辅助功能和屏幕录制权限。 - - switch "启用 Computer Use" - heading "已安装插件" [level=3] - list: - listitem: diff --git a/apps/web/tests/expected/plugin-manager/refresh.expected.md b/apps/web/tests/expected/plugin-manager/refresh.expected.md index 85d666fd3e..75dda78845 100644 --- a/apps/web/tests/expected/plugin-manager/refresh.expected.md +++ b/apps/web/tests/expected/plugin-manager/refresh.expected.md @@ -6,7 +6,7 @@ Focus at 500ms: - tooltip "刷新" failure: pending - button "刷新" [disabled] -{"busy":"true","spinners":1,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} +{"busy":"true","spinners":1,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":[]} failure: settled - button "刷新" -{"busy":"false","spinners":0,"cards":12,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} +{"busy":"false","spinners":0,"cards":11,"skeletons":0,"alerts":[],"retry":0,"toasts":["刷新失败,请重试"]} diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/README.i18n.yaml b/apps/web/tests/fixtures/plugins/fixture-bundle/README.i18n.yaml new file mode 100644 index 0000000000..e775a30a21 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/README.i18n.yaml @@ -0,0 +1,7 @@ +# Bilingual-pair consistency record for README.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write apps/web/tests/fixtures/plugins/fixture-bundle/README.md +/fixture-bundle: + en: 987d3ae4a4d79382 + zh: 4b7ce36da8833efe diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/README.md b/apps/web/tests/fixtures/plugins/fixture-bundle/README.md new file mode 100644 index 0000000000..2fb5f33e51 --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/README.md @@ -0,0 +1,5 @@ +# Fixture bundle + +English | [中文](README.zh.md) + +Registry fixture for the Web plugin-manager tests. It declares no `meta.detail`, so its page shows this README. diff --git a/apps/web/tests/fixtures/plugins/fixture-bundle/README.zh.md b/apps/web/tests/fixtures/plugins/fixture-bundle/README.zh.md new file mode 100644 index 0000000000..486e2b7eaa --- /dev/null +++ b/apps/web/tests/fixtures/plugins/fixture-bundle/README.zh.md @@ -0,0 +1,5 @@ +# 测试组合包 + +[English](README.md) | 中文 + +Web 插件管理测试用的 registry 测试组合包。它没有声明 `meta.detail`,所以它的页面显示这份 README。 diff --git a/apps/web/tests/markdown-file-links.e2e.ts b/apps/web/tests/markdown-file-links.e2e.ts index 3b9cb9879b..62b83dfafb 100644 --- a/apps/web/tests/markdown-file-links.e2e.ts +++ b/apps/web/tests/markdown-file-links.e2e.ts @@ -61,6 +61,35 @@ function parentPathFixture(path: string): string { ].join('\n') } +/** Build a settled turn whose relative link follows a directory change into a nested checkout. */ +function changedDirectoryFixture(path: string): string { + const session = Session.create(SessionId('changed-directory-links-source')) + session.append('turn/start', { turn: 1 }) + const user = session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'Enter the worktree and link its notes.' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('session/title', { title: 'Changed directory links', messageSeqs: [user.seq], source: { kind: 'fallback' } }) + session.append('working-directory/change', { cwd: '{{cwd}}/.agents/worktrees/w' }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('assistant/message', { + turn: 1, step: 1, stream: [], + message: createAssistantMessage({ + content: [{ type: 'text', text: `[Worktree notes](${path})` }], + source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return [ + JSON.stringify({ + type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0, + cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0, + }), + ...session.snapshotEvents().map(event => JSON.stringify(event)), + '', + ].join('\n') +} + describe('web e2e: Markdown file links', () => { let scaffold: WebScaffold let browser: Browser @@ -177,4 +206,27 @@ describe('web e2e: Markdown file links', () => { expect(tripwire.pageErrors).toEqual([]) expect(tripwire.warnings).toEqual([]) }) + it('resolves relative links from the Session\'s current working directory', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-changed-directory-file-links')) + const worktree = join(scaffold.workspaceCwd, '.agents/worktrees/w') + await mkdir(worktree, { recursive: true }) + await writeFile(join(scaffold.workspaceCwd, 'notes.txt'), 'original checkout\n') + await writeFile(join(worktree, 'notes.txt'), 'worktree checkout\n') + const id = await seedSession(scaffold, changedDirectoryFixture('notes.txt'), 'changed-directory-file-links') + await page.reload({ waitUntil: 'load' }) + const workspace = page.getByRole('treeitem').first() + await workspace.waitFor() + if (await workspace.getAttribute('aria-expanded') !== 'true') await workspace.click() + await page.locator(`[data-row-key="session:${id}"]`).click() + + await page.getByRole('button', { name: 'Worktree notes', exact: true }).click() + const column = page.locator('[data-rightbar-col]') + await expect.poll(() => column.locator('[data-textpreview-path]').textContent()).toBe(join(worktree, 'notes.txt')) + await expect.poll(() => column.locator('[data-textpreview-line="1"]').textContent()).toBe('worktree checkout\n') + const artifacts = fileURLToPath(new URL('../../../.artifacts', import.meta.url)) + await mkdir(artifacts, { recursive: true }) + await page.screenshot({ path: join(artifacts, 'changed-directory-file-links.png'), animations: 'disabled' }) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }) }) diff --git a/apps/web/tests/markdown-images.e2e.ts b/apps/web/tests/markdown-images.e2e.ts index db3cad441a..fd1a329253 100644 --- a/apps/web/tests/markdown-images.e2e.ts +++ b/apps/web/tests/markdown-images.e2e.ts @@ -275,7 +275,7 @@ describe('web e2e: Markdown image rendering', () => { await page.getByText(`Image preview unavailable · ${join(scaffold.workspaceCwd, 'corrupt.png')}`, { exact: true }).waitFor() expect(mediaResponses).toEqual(new Map([ [join(scaffold.workspaceCwd, 'valid.png'), 200], - [`${scaffold.workspaceCwd}/./local-image.png`, 200], + [join(scaffold.workspaceCwd, 'local-image.png'), 200], [join(scaffold.workspaceCwd, 'test workspace/测试图.png'), 200], [join(scaffold.workspaceCwd, 'test workspace/测试#100%.png'), 200], [join(scaffold.workspaceCwd, 'oversized.png'), 413], diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 9b5da0d115..7363d59e97 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -241,14 +241,14 @@ describe('web e2e: Models settings page configures a dormant provider', () => { expect(tripwire.pageErrors).toEqual([]) }, 60_000) - it('filters the discovered model catalog and clears hidden selections', async () => { + it('filters the discovered model catalog and deselects only visible candidates', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-models-picker')) const settingsDialog = page.getByRole('dialog', { name: '设置' }) await settingsDialog.getByRole('button', { name: '编辑 minimax-cn' }).click() await settingsDialog.getByText('自定义设置').click() await settingsDialog.getByRole('button', { name: '获取可用模型' }).click() - const picker = page.getByRole('dialog', { name: '选择要添加的模型' }) + const picker = page.getByRole('dialog', { name: '选择模型' }) await picker.waitFor({ timeout: 10_000 }) const boxes = picker.getByRole('checkbox') const count = await boxes.count() @@ -268,12 +268,13 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await search.fill('') await expect.poll(async () => boxes.count()).toBe(count) const restored = await boxes.evaluateAll(nodes => nodes.map(node => (node as HTMLInputElement).checked)) - expect(restored).toEqual(Array.from({ length: count }, () => false)) + expect(restored.filter(checked => !checked)).toHaveLength(1) + expect(await picker.getByRole('checkbox', { name: 'MiniMax-M2.7-highspeed', exact: true }).isChecked()).toBe(false) await picker.getByRole('button', { name: '全选' }).waitFor() await picker.getByRole('button', { name: '全选' }).click() const snapshot = await captureStableAria( page, - '[role="dialog"][aria-label="选择要添加的模型"]', + '[role="dialog"][aria-label="选择模型"]', scaffold.workspaceCwd, ) await compareOrRefreshGolden(MODEL_PICKER_EXPECTED, snapshot, MODE) @@ -517,11 +518,11 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await dialog.getByRole('button', { name: '删除模型 1' }).click() await dialog.getByRole('button', { name: '获取可用模型' }).click() - const picker = page.getByRole('dialog', { name: '选择要添加的模型' }) + const picker = page.getByRole('dialog', { name: '选择模型' }) await picker.getByRole('button', { name: '取消全选' }).click() await picker.getByRole('searchbox', { name: '搜索模型' }).fill('gpt-6-astra') await picker.getByRole('checkbox', { name: 'gpt-6-astra', exact: true }).check() - await picker.getByRole('button', { name: '添加所选' }).click() + await picker.getByRole('button', { name: '更新列表' }).click() await dialog.getByRole('button', { name: '模型选项 1' }).click() expect(await image.isChecked()).toBe(true) await dialog.getByRole('button', { name: '保存', exact: true }).click() @@ -530,7 +531,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { } finally { await scaffold.ctx.settings.mutate('llm-pi-ai', [{ op: 'unset', path: ['providers', 'openai'] }]) await edit.waitFor({ state: 'detached' }) - await page.getByRole('dialog', { name: '选择要添加的模型' }).waitFor({ state: 'detached' }) + await page.getByRole('dialog', { name: '选择模型' }).waitFor({ state: 'detached' }) } expect(tripwire.pageErrors).toEqual([]) }, 60_000) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index 75202aea47..effd31c913 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -27,6 +27,7 @@ const SEED = join(SNAPSHOT_DIR, 'session.v3.jsonl') const TRAJECTORY_EXPECTED = join(SNAPSHOT_DIR, 'trajectory.expected.md') const TIMING_EXPECTED = join(SNAPSHOT_DIR, 'timing.expected.md') const SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-results.expected.md') +const ID_SEARCH_EXPECTED = join(SNAPSHOT_DIR, 'search-id-results.expected.md') const TERMINAL_EXPECTED = join(SNAPSHOT_DIR, 'terminal-card.expected.md') const CODE_CARD_DIR = fileURLToPath(new URL('./expected/navigation-panes', import.meta.url)) const COMMAND_WRAP_EXPECTED = join(CODE_CARD_DIR, 'command-wrap.expected.md') @@ -202,9 +203,20 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { { timeout: 10_000 }, ).toBe(0) - await search.fill('WATERFALL') const resultTree = page.getByRole('tree', { name: 'Search results' }) const result = resultTree.getByRole('treeitem') + // The seed log carries its id only in the header, so this mid-id, + // differently cased fragment can match only through local id matching. + await search.fill('PANES-WEB') + await expect.poll(() => result.count(), { timeout: 10_000 }).toBe(1) + await page.getByRole('status', { name: 'Searching session history…' }).waitFor({ state: 'detached', timeout: 30_000 }) + await compareOrRefreshGolden( + ID_SEARCH_EXPECTED, + await captureStableAria(page, '[class*="listArea"]', scaffold.workspaceCwd), + MODE, + ) + + await search.fill('WATERFALL') await expect.poll(() => result.count(), { timeout: 30_000 }).toBe(1) await expect.poll(() => result.getByText('WATERFALL', { exact: false }).count(), { timeout: 10_000, @@ -551,7 +563,7 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { it.skipIf(MODE === 'record')('keeps the recorded fixture inventory exact', async () => { await assertFixtureInventory(CODE_CARD_DIR, ['code-card.expected.md', 'command-wrap.expected.md']) await assertFixtureInventory(SNAPSHOT_DIR, [ - 'session.v3.jsonl', 'search-results.expected.md', 'trajectory.expected.md', + 'session.v3.jsonl', 'search-id-results.expected.md', 'search-results.expected.md', 'trajectory.expected.md', 'terminal-card.expected.md', 'timing.expected.md', ]) }) diff --git a/apps/web/tests/new-session-refused.e2e.ts b/apps/web/tests/new-session-refused.e2e.ts index 9f3a50a41a..b7d6c31e9a 100644 --- a/apps/web/tests/new-session-refused.e2e.ts +++ b/apps/web/tests/new-session-refused.e2e.ts @@ -38,7 +38,8 @@ describe.skipIf(MODE === 'record')('web e2e: refused New Session', () => { expect(scaffold.ctx.sessions.list()).toEqual([]) expect(await page.getByRole('alert').count()).toBe(0) - await page.getByRole('button', { name: '新建会话', exact: true }).first().click() + // The capsule: on a local build the wordmark's fallback copies the version. + await page.getByRole('button', { name: '新建会话', exact: true }).last().click() const notice = page.getByRole('alert').filter({ hasText: '新建会话失败' }) await notice.waitFor() await compareOrRefreshGolden(NOTICE_EXPECTED, await notice.ariaSnapshot(), MODE) diff --git a/apps/web/tests/plugin-manager.e2e.ts b/apps/web/tests/plugin-manager.e2e.ts index e4ac5cb89a..4edd06e075 100644 --- a/apps/web/tests/plugin-manager.e2e.ts +++ b/apps/web/tests/plugin-manager.e2e.ts @@ -584,8 +584,27 @@ describe('web e2e: plugin manager', () => { await panel.getByRole('heading', { name: '实验性插件', exact: true, level: 1 }).waitFor() expect(await panel.locator('[data-plugin-group="more"] [data-plugin-package]').count()) .toBe(OPTIONAL_BUNDLES.length + ON_DEMAND_BUNDLES.length) - expect(await panel.getByText('实验性', { exact: true }).count()) - .toBe(OPTIONAL_BUNDLES.filter(name => name.startsWith('@deepseek-ai/dsh-experimental-')).length) + const cards = await panel.locator('[data-plugin-group="more"] [data-plugin-package]').all() + expect(await Promise.all(cards.map(async card => [ + await card.getAttribute('data-plugin-package'), + await card.getByText('实验性', { exact: true }).count(), + ]))).toEqual([ + ['@deepseek-ai/dsh-experimental-agent-team-profile', 1], + ['@deepseek-ai/dsh-experimental-voice-input-bundle', 1], + ['@deepseek-ai/dsh-experimental-auto-review', 1], + ['@deepseek-ai/dsh-experimental-inspector-profile', 1], + ['@deepseek-ai/dsh-cua-bundle', 1], + ['@deepseek-ai/dsh-experimental-tool-worktree', 1], + ['@deepseek-ai/dsh-experimental-cot-translation-bundle', 1], + ['@deepseek-ai/dsh-experimental-terminal-bundle', 1], + ['@deepseek-ai/dsh-experimental-session-search', 1], + ['@deepseek-ai/dsh-experimental-session-titles-bundle', 1], + ['@deepseek-ai/dsh-experimental-badge-skill-bundle', 1], + ['@deepseek-ai/dsh-experimental-ralph-bundle', 1], + ['@deepseek-ai/dsh-experimental-plugin-market-bundle', 1], + ['@deepseek-ai/dsh-subagent-codex', 0], + ['@deepseek-ai/dsh-subagent-claude-code', 0], + ]) expect(await panel.locator('[data-plugin-group="bundles"]').count()).toBe(0) await panel.getByRole('button', { name: '返回插件', exact: true }).click() expect(await panel.locator('[data-plugin-group="bundles"] [data-plugin-package]').count()).toBe(2) @@ -727,11 +746,12 @@ describe('web e2e: plugin manager', () => { } expect(await preview.getAttribute('src')).toBe(chineseSource) await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click() - await panel.getByRole('button', { name: '查看 智能体团队', exact: true }).click() - // Detail icons have alt=""; this bundle's README detail document has no images. + await panel.getByRole('button', { name: '返回插件', exact: true }).click() + await panel.getByRole('button', { name: '查看 @fixture/bundle', exact: true }).click() + // A bundle without meta.detail still shows its README; this one has no images. await panel.locator('[data-detail-document] h1').waitFor() expect(await panel.getByRole('img').count()).toBe(0) - await panel.getByRole('button', { name: '返回 实验性插件', exact: true }).click() + await panel.getByRole('button', { name: '返回插件', exact: true }).click() expect(tripwire.pageErrors).toEqual([]) }) @@ -747,6 +767,9 @@ describe('web e2e: plugin manager', () => { expect(await search.getByText('搜索工作区中的文件。', { exact: true }).count()).toBe(1) expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(1) await panel.getByText('Registry description for the fixture bundle.', { exact: true }).first().waitFor() + // The README detail document loads asynchronously after the description. + const readmeHeading = panel.locator('[data-detail-document]').getByRole('heading', { name: 'Fixture bundle', exact: true }) + await readmeHeading.waitFor() expect([...scaffold.ctx.loader.entries()].some(entry => entry.options.name.startsWith('@fixture/bundle'))).toBe(false) await compareOrRefreshGolden(EXPORTS_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']], @@ -757,6 +780,7 @@ describe('web e2e: plugin manager', () => { expect(await review.getByText('@fixture/bundle/review', { exact: true }).count()).toBeGreaterThan(0) expect(await search.getByText('Search package introduction.', { exact: true }).count()).toBe(0) expect(await review.getByText('审查工作区中的改动。', { exact: true }).count()).toBe(0) + await readmeHeading.waitFor() await compareOrRefreshGolden(EXPORTS_EN_EXPECTED, await captureStableAria(page, '[data-plugin-panel]', scaffold.workspaceCwd, { replacements: [[FIXTURE_PLUGINS, '{{fixtures}}']], }), MODE) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index 19c7a26aa2..be06ec07f0 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -468,8 +468,8 @@ function assertLeanChildRecord(agent: Agent): void { * gaps: the `cordis_*` toolset executes model-written JavaScript that no * sandbox row confines, `mcp_*` servers spawn outside `ctx.shell`, and `ralph` * runs unsupervised rounds whose completion is a worker self-report. - * `web_fetch` is present because public-address enforcement and one-shot - * approval now confine its model-selected request target. The composition + * `web_fetch` is present because it adds no network reach beyond the + * mounted shell tools. The composition * Agent Note owns the rationale and its sources. */ const EXPECTED_TOOLS = [ diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 33cfdeb374..450911da2b 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -208,7 +208,7 @@ export function probeFreePort(): Promise { */ export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('textbox', { name: 'Choose workspace' }).click() + await page.getByRole('button', { name: 'Add workspace', exact: true }).click() const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Edit path' }).click() @@ -234,7 +234,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo */ export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace', modelAvailable = true): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('textbox', { name: '选择工作区' }).click() + await page.getByRole('button', { name: '添加工作区', exact: true }).click() const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: '编辑路径' }).click() diff --git a/apps/web/tests/web-search-round.e2e.ts b/apps/web/tests/web-search-round.e2e.ts index 3ca9bae009..f831fb6324 100644 --- a/apps/web/tests/web-search-round.e2e.ts +++ b/apps/web/tests/web-search-round.e2e.ts @@ -50,6 +50,16 @@ function resultSnippet(queryIndex: number, ordinal: number): string { return `Snapshot search ${queryIndex + 1} excerpt ${ordinal}: the harness replays this source list from a local endpoint.` } +/** One provider result's multi-line model-facing summary, by 1-based provider order. */ +function resultSummary(queryIndex: number, ordinal: number): string { + return `Snapshot search ${queryIndex + 1} summary ${ordinal}:\n\nthe model reads this longer excerpt\nfrom the Search API.` +} + +/** The rendered summary: whitespace collapsed to single spaces. */ +function renderedSummary(queryIndex: number, ordinal: number): string { + return resultSummary(queryIndex, ordinal).replace(/\s+/gu, ' ') +} + /** One provider result's publication date, by 1-based provider order (July 2026 days 01..12). */ function resultPageAge(ordinal: number): string { return `2026-07-${String(ordinal).padStart(2, '0')}` @@ -66,6 +76,11 @@ const KEPT_SOURCES = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map((_query, que publishedAt: resultPageAge(ordinal), }))).slice(0, WEB_SEARCH_MAX_RESULTS) +/** Model-visible source lines for the kept sources: the summary replaces the display snippet. */ +const KEPT_SOURCE_LINES = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map((_query, queryIndex) => + `- [${resultTitle(queryIndex, ordinal)}](${resultUrl(queryIndex, ordinal)}) — ${renderedSummary(queryIndex, ordinal)} (${resultPageAge(ordinal)})`, +)).slice(0, WEB_SEARCH_MAX_RESULTS) + /** URLs omitted after the combined source cap is reached. */ const DROPPED_SOURCE_URLS = RESULT_ORDINALS.flatMap(ordinal => QUERIES.map( (_query, queryIndex) => resultUrl(queryIndex, ordinal), @@ -107,6 +122,7 @@ async function startSearchServer(captured: CapturedSearchRequest[]): Promise<{ s url: resultUrl(queryIndex, ordinal), title: resultTitle(queryIndex, ordinal), snippet: resultSnippet(queryIndex, ordinal), + summary: resultSummary(queryIndex, ordinal), content: '', site_name: null, site_icon: null, date: resultPageAge(ordinal), })), @@ -212,16 +228,18 @@ describe('web e2e: opt-in Search API web search', () => { const rendered = message.content.filter(block => block.type === 'text').map(block => block.text).join('') // The tool interleaves sources from both seam results before applying the // combined cap, so each query remains represented in model-visible output. - for (const source of KEPT_SOURCES) { - expect(rendered).toContain(`[${source.title}](${source.url})`) + for (const line of KEPT_SOURCE_LINES) { + expect(rendered).toContain(line) } + expect(rendered).not.toContain(resultSnippet(0, 1)) for (const url of DROPPED_SOURCE_URLS) { expect(rendered).not.toContain(url) } expect(rendered).toContain( `(Showing the first ${WEB_SEARCH_MAX_RESULTS} sources. Refine the query for more.)`, ) - expect(searchResult.data.meta).toMatchObject({ + // The card keeps the display snippet; the summary reaches only the model. + expect(searchResult.data.meta).toEqual({ sources: KEPT_SOURCES, truncated: true, }) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index b4a9585515..4a9e624257 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1430,7 +1430,7 @@ export interface Config { ## `@deepseek-ai/dsh-fs-local` -- `source`: [`packages/fs/fs-local/src/index.ts:45`](../packages/fs/fs-local/src/index.ts) +- `source`: [`packages/fs/fs-local/src/index.ts:46`](../packages/fs/fs-local/src/index.ts) ```ts config-catalog /** Configuration for the local filesystem backend. */ @@ -4258,6 +4258,8 @@ export interface Config { searchMaxResults?: number /** Upper bound on queries accepted by one `web_search` call. */ searchMaxQueries?: number + /** Cap on each source summary in `web_search` output, in characters. Defaults to 800. */ + searchSummaryMaxChars?: number /** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */ fetchTimeoutMs?: number /** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */ diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 54fb42f3e4..f77cb50cba 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1432,7 +1432,7 @@ export interface Config { ## `@deepseek-ai/dsh-fs-local` -- `source`: [`packages/fs/fs-local/src/index.ts:45`](../packages/fs/fs-local/src/index.ts) +- `source`: [`packages/fs/fs-local/src/index.ts:46`](../packages/fs/fs-local/src/index.ts) ```ts config-catalog /** Configuration for the local filesystem backend. */ @@ -4260,6 +4260,8 @@ export interface Config { searchMaxResults?: number /** Upper bound on queries accepted by one `web_search` call. */ searchMaxQueries?: number + /** Cap on each source summary in `web_search` output, in characters. Defaults to 800. */ + searchSummaryMaxChars?: number /** Cooperative timeout budget (ms) for `web_fetch`. Defaults to 30000. */ fetchTimeoutMs?: number /** Cooperative timeout budget (ms) for `web_search`. Defaults to 30000. */ diff --git a/docs/module-graph.md b/docs/module-graph.md index 165a27b4b4..ea1c6a6823 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -489,7 +489,6 @@ flowchart TD pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_skill_office --> pkg_skill - pkg_web_fetch_http --> pkg_http_proxy pkg_web_fetch_http --> pkg_timeout pkg_web_fetch_http --> pkg_web pkg_web_search_exa --> pkg_launch_environment @@ -1600,7 +1599,7 @@ flowchart TD | [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | -| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 8d69755e9d..1d66068c63 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -491,7 +491,6 @@ flowchart TD pkg_remote_mock --> pkg_typert_protocol pkg_typert_loader --> pkg_typert_registry pkg_skill_office --> pkg_skill - pkg_web_fetch_http --> pkg_http_proxy pkg_web_fetch_http --> pkg_timeout pkg_web_fetch_http --> pkg_web pkg_web_search_exa --> pkg_launch_environment @@ -1602,7 +1601,7 @@ flowchart TD | [`remote-mock`](../packages/test-support/remote-mock) | `test-support` | [`typert-protocol`](../packages/typert/protocol) | | [`typert-loader`](../packages/typert/loader) | `typert` | [`typert-registry`](../packages/typert/registry) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | -| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`http-proxy`](../packages/util/http-proxy), [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | +| [`web-fetch-http`](../packages/web/web-fetch-http) | `web` | [`timeout`](../packages/util/timeout), [`web`](../packages/web/web) | | [`web-search-exa`](../packages/web/web-search-exa) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`web-search-perplexity`](../packages/web/web-search-perplexity) | `web` | [`launch-environment`](../packages/util/launch-environment), [`web`](../packages/web/web) | | [`spill`](../packages/spill/spill) | `spill` | [`brand`](../packages/util/brand), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | diff --git a/docs/subsystems/web.i18n.yaml b/docs/subsystems/web.i18n.yaml index bf0be78f0f..0de78d5234 100644 --- a/docs/subsystems/web.i18n.yaml +++ b/docs/subsystems/web.i18n.yaml @@ -18,11 +18,11 @@ en: da78eb96d684ad47 zh: 4b726cc04c92ef13 /web-access/fetch-network-policy: - en: 261fce9249be8b2b - zh: bf6abf77679aca6d + en: 6851874be5841d90 + zh: dc085bf3b82d77df /web-access/errors: en: b07c04f462b4a0d9 zh: de5f09cd2fb0324c /web-access/the-service: - en: c81fdcdfd6e22861 - zh: ac906c9148942041 + en: c9e7dc85c08e64e1 + zh: 277c52aac58f42dc diff --git a/docs/subsystems/web.md b/docs/subsystems/web.md index c5023fb00b..a3cc37f040 100644 --- a/docs/subsystems/web.md +++ b/docs/subsystems/web.md @@ -54,15 +54,18 @@ interface WebSearchResult { ```ts type-equiv /** - * One citeable source. A source always has a URL; `title`, `snippet`, and - * `publishedAt` are optional because not every provider returns them — forcing + * One citeable source. A source always has a URL; `title`, `snippet`, `summary`, + * and `publishedAt` are optional because not every provider returns them — forcing * adapters to invent them would make the seam lie (Perplexity citations may be * URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display. */ interface WebSearchSource { readonly url: string readonly title?: string + /** Short excerpt suited to display. */ readonly snippet?: string + /** Longer excerpt suited to model reading; consumers bound its length. */ + readonly summary?: string /** Publication/crawl timestamp as a provider-supplied ISO-8601 string. */ readonly publishedAt?: string } @@ -128,7 +131,7 @@ Selection never depends on registration, config, or HMR order: a capability has The shipped Cordis, Code, and Standard presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation. File sandbox presets do not govern Web network access. A deployment that needs confirmation must add a `tools/pre-execute` policy or disable fetch. -The HTTP provider resolves each actual request, rejects non-public answers including private IPv4 reached through the active DNS64 prefix, pins the validated address set, and repeats enforcement for each same-origin redirect. A cross-origin redirect requires a new tool call and fresh public-address validation. These checks prevent SSRF access to non-public destinations but do not stop a model from sending data to a public URL. +The HTTP provider does not restrict destination addresses: `web_fetch` reaches loopback, private-network, and cloud metadata hosts like any other host, and a model can send data to any URL it can reach. A cross-origin redirect requires a new tool call. A deployment that must keep the model away from private destinations needs network confinement, a `tools/pre-execute` policy, or no fetch tool. ## Errors @@ -136,7 +139,7 @@ The HTTP provider resolves each actual request, rejects non-public answers inclu ## The service -`WebRuntime` registers search and fetch providers, rejects duplicate ids with `WEB_DUPLICATE_PROVIDER`, and resolves providers at execution time with structured selection errors. The local fetch backend accepts only HTTP(S), rejects credentials, resolves each hostname once, rejects any answer set containing a non-public IPv4 or IPv6 destination or an active-prefix NAT64 translation to non-public IPv4, pins the request connection to the validated addresses, repeats those checks for every same-origin redirect hop, caps redirects, bytes, characters, and time, and decodes the body; the tool owns presentation. +`WebRuntime` registers search and fetch providers, rejects duplicate ids with `WEB_DUPLICATE_PROVIDER`, and resolves providers at execution time with structured selection errors. The local fetch backend accepts only HTTP(S), rejects credentials, sends each request through the installed proxy policy, repeats URL validation for every same-origin redirect hop, caps redirects, bytes, characters, and time, and decodes the body; the tool owns presentation. diff --git a/docs/subsystems/web.zh.md b/docs/subsystems/web.zh.md index 71f4b6353e..95d940c89b 100644 --- a/docs/subsystems/web.zh.md +++ b/docs/subsystems/web.zh.md @@ -54,15 +54,18 @@ interface WebSearchResult { ```ts type-equiv /** - * One citeable source. A source always has a URL; `title`, `snippet`, and - * `publishedAt` are optional because not every provider returns them — forcing + * One citeable source. A source always has a URL; `title`, `snippet`, `summary`, + * and `publishedAt` are optional because not every provider returns them — forcing * adapters to invent them would make the seam lie (Perplexity citations may be * URL-only). `dsh-tool-web` renders `title ?? hostname(url)` for display. */ interface WebSearchSource { readonly url: string readonly title?: string + /** Short excerpt suited to display. */ readonly snippet?: string + /** Longer excerpt suited to model reading; consumers bound its length. */ + readonly summary?: string /** Publication/crawl timestamp as a provider-supplied ISO-8601 string. */ readonly publishedAt?: string } @@ -128,7 +131,7 @@ type WebFetchBody = 已交付的 Cordis、Code 与 Standard preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。文件 sandbox preset 不管辖 Web 网络访问。需要确认步骤的部署必须添加 `tools/pre-execute` 策略或禁用抓取。 -HTTP 提供方会解析每个实际请求,拒绝包括通过当前 DNS64 前缀抵达私有 IPv4 在内的非公开结果,固定已验证的地址集合,并在每次同源重定向时重复强制执行。跨源重定向需要新的工具调用和新的公开地址校验。这些检查会阻止通过 SSRF 访问非公开目的地址,但不会阻止模型把数据发送到公开 URL。 +HTTP 提供方不限制目的地址:`web_fetch` 会像访问其他主机一样访问 loopback、内网与云元数据主机,模型也可以把数据发送到它能访问的任何 URL。跨源重定向需要新的工具调用。需要阻止模型访问私有目的地址的部署必须使用网络隔离、`tools/pre-execute` 策略,或不挂载抓取工具。 ## 错误 @@ -136,7 +139,7 @@ HTTP 提供方会解析每个实际请求,拒绝包括通过当前 DNS64 前 ## 服务 -`WebRuntime` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、对每个 hostname 只解析一次、拒绝包含任一非公开 IPv4/IPv6 目的地址或经当前前缀转换到非公开 IPv4 的 NAT64 地址的解析结果、把请求连接固定到已验证地址、对每一次同源重定向跳转重复这些校验、限制重定向次数、字节数、字符数和时间,并解码正文;展示由工具负责。 +`WebRuntime` 注册搜索与抓取提供方,以 `WEB_DUPLICATE_PROVIDER` 拒绝重复 id,并在执行时以结构化的选择错误解析提供方。本地抓取后端仅接受 HTTP(S)、拒绝凭证、经已安装的代理策略发送每个请求、对每一次同源重定向跳转重复 URL 校验、限制重定向次数、字节数、字符数和时间,并解码正文;展示由工具负责。 diff --git a/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.i18n.yaml b/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.i18n.yaml new file mode 100644 index 0000000000..c7c546a4e9 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.i18n.yaml @@ -0,0 +1,16 @@ +# Bilingual-pair consistency record for guide.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.md +/: + en: 8d967f90aee1902e + zh: 30735df66cafe05d +/web-fetch-reaches-private-destinations: + en: 8c157d5137d0a507 + zh: 27cef187efdb1323 +/web-fetch-reaches-private-destinations/change: + en: bcbce3e213b81afc + zh: dda264a679117c36 +/web-fetch-reaches-private-destinations/migration: + en: e076bdd8bd132ee8 + zh: 26d5da33ccc8e37c diff --git a/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.md b/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.md new file mode 100644 index 0000000000..4b99b614b1 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "The HTTP fetch provider no longer rejects non-public destinations, and HttpFetchProvider no longer accepts an address resolver." +--- +# `web_fetch` reaches private destinations + +English | [中文](guide.zh.md) + +## Change + +`@deepseek-ai/dsh-web-fetch-http` used to resolve each hostname, reject any loopback, private, link-local, or reserved answer with `WEB_BLOCKED_URL`, and pin the connection to the validated addresses. It now sends every request through the installed proxy policy and the system resolver, so `web_fetch` reaches loopback, private-network, and cloud metadata hosts. Fetches behind TUN-mode proxies with fake-IP DNS work without configuration. + +The package no longer exports `HttpFetchResolver`, and the `HttpFetchProvider` constructor takes only `HttpFetchLimits`. + +Deployments that relied on the provider to keep the model away from private services are affected, as is code that passed a resolver to `HttpFetchProvider`. + +## Migration + +1. If the model must not reach private destinations, confine the process's network access, add a `tools/pre-execute` policy that rejects those `web_fetch` URLs, or remove `@deepseek-ai/dsh-tool-web`'s fetch tool from the composition. +2. Replace `new HttpFetchProvider(limits, resolveAddresses)` with `new HttpFetchProvider(limits)`. To direct a test request to a local server, put the server's address in the URL instead of overriding resolution. +3. Confirm the migration with `pnpm run typecheck`; no reference to `HttpFetchResolver` remains. diff --git a/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.zh.md b/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.zh.md new file mode 100644 index 0000000000..a0139d1949 --- /dev/null +++ b/docs/upgrade-guide/v0.2.1-rc.2/web-fetch-private-destinations/guide.zh.md @@ -0,0 +1,21 @@ +--- +kind: upgrade-guide +description: "HTTP 抓取提供方不再拒绝非公开目标,HttpFetchProvider 也不再接受地址解析器。" +--- +# `web_fetch` 可以访问私有目标 + +[English](guide.md) | 中文 + +## 变更 + +`@deepseek-ai/dsh-web-fetch-http` 以前会解析每个主机名,以 `WEB_BLOCKED_URL` 拒绝任何 loopback、私有、link-local 或保留地址,并把连接固定到已校验地址。现在它通过已安装的代理策略和系统解析器发送每个请求,因此 `web_fetch` 可以访问 loopback、内网与云元数据主机。使用 fake-IP DNS 的 TUN 模式代理之后,抓取无需配置即可工作。 + +本包不再导出 `HttpFetchResolver`,`HttpFetchProvider` 构造函数只接受 `HttpFetchLimits`。 + +依赖该提供方阻止模型访问私有服务的部署会受影响;向 `HttpFetchProvider` 传入解析器的代码也会受影响。 + +## 迁移 + +1. 如果模型不得访问私有目标,请限制进程的网络访问、添加拒绝这些 `web_fetch` URL 的 `tools/pre-execute` 策略,或从组合中移除 `@deepseek-ai/dsh-tool-web` 的抓取工具。 +2. 把 `new HttpFetchProvider(limits, resolveAddresses)` 替换为 `new HttpFetchProvider(limits)`。如需把测试请求指向本地服务器,请把服务器地址写进 URL,而不是覆盖解析。 +3. 用 `pnpm run typecheck` 确认迁移完成,且不再引用 `HttpFetchResolver`。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index e96b8abe57..6f7e665a4f 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -15,8 +15,8 @@ en: 17867305a3e1b52a zh: 876fedf1f6f563e4 /configure-models/add-a-custom-model-api/discover-models: - en: 97318c5a115199ff - zh: 0c455e4232cf0265 + en: ec10ba53a00eddc6 + zh: 857373a4e0fefee4 /configure-models/select-a-model: en: 6a94264c99535caf zh: f97bb0dc6a40c0d3 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index 4a57c7b7bc..c1118e469f 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -32,7 +32,7 @@ The Provider ID is permanent because requests, saved sessions, model defaults, a ### Discover models -Under **Model catalog**, choose **Fetch available models** to ask the endpoint which models it serves. The request uses the base URL, protocol, and key currently in the form, or a saved provider's stored key, and the reply opens a searchable picker: search, tick the models you want, and choose **Add selected**. Nothing is stored until you save or create the provider. +Under **Model catalog**, choose **Fetch available models** to ask the endpoint which models it serves. The request uses the base URL, protocol, and key currently in the form, or a saved provider's stored key, and the reply opens a searchable picker: models already in the list start ticked, so search, tick the models to add, untick the ones to remove, and choose **Update list**. Configured models the endpoint does not list stay. Nothing is stored until you save or create the provider. Discovery reads the listing formats common gateways publish, but not every endpoint answers in one of them, so treat it as a convenience rather than a guarantee: when it fails or lists nothing, add the model ids by hand and they work just the same. A built-in provider is always answered from the installed catalog, even when its base URL points at a gateway, so fetch through a custom provider to see what the gateway really serves. diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index 9189f66815..ebf6147681 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -32,7 +32,7 @@ Provider ID 是永久的,因为请求、已保存会话、模型默认值和 ### 探测模型 -在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供商则用已存储的密钥;响应会打开一个可搜索的选择框,搜索、勾选想要的模型,再点**添加所选**。保存或创建提供商之前不会存储任何内容。 +在**模型目录**中选择**获取可用模型**,即可询问端点它提供哪些模型。请求使用表单当前的 API 地址、协议和密钥,已保存的提供商则用已存储的密钥;响应会打开一个可搜索的选择框,列表中已有的模型默认勾选,搜索后勾选要添加的模型、取消勾选要移除的模型,再点**更新列表**。端点未列出的已配置模型保持不变。保存或创建提供商之前不会存储任何内容。 探测读取的是常见网关公开的列表格式,但并非每个端点都用这些格式作答,所以它只是便利手段而非保证:探测失败或列表为空时,手动添加模型 ID 即可,效果完全一样。内置提供商一律由已安装目录作答,即使其 API 地址指向网关也是如此,要查看网关实际提供的模型,请通过自定义提供商探测。 diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 5b191312a7..c6f661f22d 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -21,8 +21,8 @@ en: 0f971f7ac7c09139 zh: ceb866da681c709c /deepseek-ai-dsh-base/use-this-package/what-you-get: - en: 5bfb7755e987862c - zh: 1f0e9ea7a826a509 + en: 2ef2653d55107eec + zh: 4936c0fff94b1984 /deepseek-ai-dsh-base/use-this-package/shell-tools-per-platform: en: 2dce12f82d6ee955 zh: d4599eb899a3cd88 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 9474a8ce63..f928d433d6 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -47,7 +47,7 @@ Run `dsh --profile my-profile "your task"` and you get a working agent with mode ### What you get -Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, public HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval; its provider rejects non-public destinations. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The default-on [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path. +Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, HTTP(S) fetch, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Web fetch runs without per-call approval and can reach loopback and private-network hosts. Feedback stays in the Session log. [OTel session upload](../../session/session-telemetry-otel/README.md) defaults to `FEEDBACK_ONLY` for all users, including `deepseek-official`: new text feedback, message ratings, edits, and withdrawals release the complete canonical prefix through that event, including context. Later records wait for the next explicit feedback; sending an authorized batch needs no further interaction or model call. `DISABLED` prevents OTel capture. The default-on [DeepSeek session-log contributor](../../session/session-log-deepseek/README.md) remains a separate request path. Both `subagent` and `subagent_fork` create managed children and return a child id immediately. Use `send_message` to steer active children or continue their conversations after completion. [Delegation-tool behavior](../../subagent/tool-subagent/README.md) describes result delivery and backend capabilities. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index b4e45bcaf6..132a40bab3 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -47,7 +47,7 @@ kind: "package-bundle" ### 你得到什么 -开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(提供方与模型可配置,你还可以在设置中启用额外提供方)、完整工具集——文件编辑、shell 命令、web 搜索、公开 HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,其提供方会拒绝非公开目的地址。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范会话日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。默认开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。 +开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(提供方与模型可配置,你还可以在设置中启用额外提供方)、完整工具集——文件编辑、shell 命令、web 搜索、HTTP(S) 抓取、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。Web 抓取无需逐次审批,并且可以访问 loopback 与内网主机。反馈保存在会话日志中。[OTel 会话上传](../../session/session-telemetry-otel/README.zh.md)对所有用户默认使用 `FEEDBACK_ONLY`,包括 `deepseek-official`:新的文本反馈、消息评分、编辑与撤回会释放截至该事件的完整规范会话日志前缀,包含上下文。后续记录等待下一次显式反馈;发送已授权批次无需进一步交互或模型调用。`DISABLED` 阻止 OTel 捕获。默认开启的 [DeepSeek 会话日志贡献器](../../session/session-log-deepseek/README.zh.md)仍是独立的请求路径。 `subagent` 与 `subagent_fork` 都创建受管理的子任务,并立即返回 child id。使用 `send_message` 可引导活跃子任务,或在完成后继续其对话。[委派工具行为](../../subagent/tool-subagent/README.zh.md)说明了结果交付和后端能力。 diff --git a/packages/client/ui-approval/src/client/ApprovalPanel.module.css b/packages/client/ui-approval/src/client/ApprovalPanel.module.css index edf5a7bf79..611dd4eaa5 100644 --- a/packages/client/ui-approval/src/client/ApprovalPanel.module.css +++ b/packages/client/ui-approval/src/client/ApprovalPanel.module.css @@ -63,5 +63,5 @@ .reject:hover:not(:disabled) { background: var(--dsw-alias-interactive-bg-hover-danger); color: var(--dsw-alias-state-error-primary); - border-color: transparent; + --dsh-button-outline-color: transparent; } diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index d43d47801c..492b4b4b39 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -32,6 +32,7 @@ "inject": [ "@deepseek-ai/dsh-api-session-controller", "@deepseek-ai/dsh-api-workspace-controller", + "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-conversation", "@deepseek-ai/dsh-client-ui-input-trigger", @@ -62,6 +63,7 @@ "@deepseek-ai/dsh-api-workspace-files": "workspace:*", "@deepseek-ai/dsh-attachment": "workspace:*", "@deepseek-ai/dsh-brand": "workspace:*", + "@deepseek-ai/dsh-client-connection": "workspace:*", "@deepseek-ai/dsh-client-locale": "workspace:*", "@deepseek-ai/dsh-client-store": "workspace:*", "@deepseek-ai/dsh-client-ui-approval": "workspace:*", diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 9421bc9020..6432e3e09e 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -12,7 +12,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-sidebar-browser/client' import type {} from '@deepseek-ai/dsh-client-ui-input-trigger/client' // The `file` entry of `SidebarRightResourceParamsMap`, which types `{ params: { line } }` below. import type {} from '@deepseek-ai/dsh-client-ui-sidebar-documentpreview/client' -import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' +import { fileAddressFor, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' // Type-only service and declaration merges used by the apply world. import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -21,6 +21,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' import type {} from '@deepseek-ai/dsh-client-ui-session/client' import type {} from '@deepseek-ai/dsh-client-ui-settings/client' import type {} from '@deepseek-ai/dsh-client-ui-workspace/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ChatFlowDataInjected, ChatFlowInjected, ChatNodeInjected, ChatScrollPosition, ChatViewInjected, QuotaNoticeInjected, QuotaNoticeState, TurnTailOwnerProps, @@ -74,7 +75,7 @@ export const CHAT_NODE_INJECT: ChatNodeInjected = { /** Services required by the Chat target and its presentation registrations. */ export const inject = [ 'slots', 'sessions', 'uiWorkspace', 'uiSession', 'uiConversation', 'locale', - 'configForms', 'remote', 'remote.session', 'sidebarRight', + 'configForms', 'connection', 'remote', 'remote.session', 'sidebarRight', ] /** @@ -203,7 +204,8 @@ export function apply(ctx: Context): void { }), }, TranscriptViewRow)) - const nodeSources = (binding: SessionBinding): ChatFlowDataInjected => { + const connectionState = (ctx.get('connection') as ConnectionHandle).state + const nodeSources = (binding: SessionBinding): Pick => { const chat = chatSource(binding) const conversation = ctx.uiConversation.binding(binding) return { @@ -225,7 +227,8 @@ export function apply(ctx: Context): void { inject: (sessionId: SessionId): ChatFlowDataInjected => { const binding = ctx.sessions.binding(sessionId) if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`) - return nodeSources(binding) + const sources = nodeSources(binding) + return { ...sources, hooks: { ...sources.hooks, connectionState } } }, }, ChatFlow)) @@ -250,19 +253,22 @@ export function apply(ctx: Context): void { fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId), // Files open in the right Sidebar, not in a desktop application: the // content stays in the product, beside the conversation that produced - // it. A relative path, or an absolute one inside the session's - // workspace, is addressed under this session's scope, - // `dsh-resource://file/session//`; an absolute path - // elsewhere keeps its absolute spelling in the same Session's address. - // Which tab type claims the - // address is the Sidebar's decision, not this call site's. + // it. A relative path resolves from the Session's working directory + // at click time, which a directory change can move away from the + // workspace root; links from earlier Turns follow later changes. A + // path inside the workspace is addressed under this + // session's scope, `dsh-resource://file/session//`; an + // absolute path elsewhere keeps its absolute spelling in the same + // Session's address. Which tab type claims the address is the + // Sidebar's decision, not this call site's. // A line travels as a navigation parameter, not as part of the // address: the file is one piece of content whether it is opened at // its top or at line 400, so the same tab is revealed and told where // to land. openFile: async (path, options) => { const cwd = ctx.sessions.list.getSnapshot().byId[sessionId]?.cwd - const url = fileAddressFor(sessionId, cwd, path) + const current = session.projections.faceOf('workingDirectory').getSnapshot() as string | null | undefined + const url = fileAddressFor(sessionId, cwd, resolveWorkspacePath(current ?? undefined, path)) if (options?.line === undefined) ctx.sidebarRight.openResource(url) else ctx.sidebarRight.openResource(url, { params: { line: options.line } }) await Promise.resolve() diff --git a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx index 224d5c7a4e..561632a671 100644 --- a/packages/client/ui-chat/src/client/chat/ChatFlow.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatFlow.tsx @@ -25,12 +25,13 @@ function isTranscriptEcho(item: PendingInput): boolean { /** Render the flow without adding a DOM parent or changing keyed row positions. */ export const ChatFlow = memo(function ChatFlow({ entries, pendingInputs, lastInputTurn, deferCollapse, - useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, + useSession, useChat, useChatNode, useChatNodeBottom, useChatNodeProcess, useChatGroup, usePresentation, useConnectionState, useStore, actions, renderSlot, renderFactorySlot, t, useGroupAction, useGroupHeaderAction, cwd, openFile, inspectCall, forkAt, loadImage, fileMentions, }: ChatFlowSlotProps) { const nodeStore = useChat(snapshot => snapshot.nodes) const running = useSession(snapshot => snapshot.running) + const disconnected = useConnectionState(state => state === 'disconnected' || state === 'connecting') const latestTurnAnchor = useChat(snapshot => snapshot.navigation.items().at(-1)?.anchorKey) const runningStartTime = useChatNode(latestTurnAnchor ?? '', (node) => { const location = node?.location @@ -85,7 +86,7 @@ export const ChatFlow = memo(function ChatFlow({ // Rows enter before the keyed status and echoes after it, so React never moves its DOM node. return [ ...rows, ...leadingRows, ...openingRows, - ...running ? [] : [], + ...running ? [] : [], ...pendingRows, ] }) diff --git a/packages/client/ui-chat/src/client/chat/RunningStatus.tsx b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx index fd145b6324..36a202c973 100644 --- a/packages/client/ui-chat/src/client/chat/RunningStatus.tsx +++ b/packages/client/ui-chat/src/client/chat/RunningStatus.tsx @@ -9,22 +9,24 @@ import css from './ChatView.module.css' interface RunningStatusProps { readonly startTime: number | undefined + /** Freeze the shown duration while the Client cannot observe the Session. */ + readonly paused: boolean readonly t: ChatViewSlotProps['t'] } /** * Show live elapsed time after the current Turn's content without announcing ticks. - * @param props - Current Turn start time and localized copy. + * @param props - Current Turn start time, pause state, and localized copy. * @returns the blue running indicator; mount only while the Session is running. */ -export const RunningStatus = memo(function RunningStatus({ startTime, t }: RunningStatusProps) { +export const RunningStatus = memo(function RunningStatus({ startTime, paused, t }: RunningStatusProps) { const [now, setNow] = useState(Date.now) useEffect(() => { - if (startTime === undefined) return + if (startTime === undefined || paused) return setNow(Date.now()) const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS) return () => { clearInterval(timer) } - }, [startTime]) + }, [startTime, paused]) const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', { duration: formatRunDuration(Math.max(1000, now - startTime), t).map(part => part.text).join(''), }) diff --git a/packages/client/ui-chat/src/client/contract/slots.ts b/packages/client/ui-chat/src/client/contract/slots.ts index a85e28798d..4bd88840f6 100644 --- a/packages/client/ui-chat/src/client/contract/slots.ts +++ b/packages/client/ui-chat/src/client/contract/slots.ts @@ -15,6 +15,7 @@ import type { PropsRenderFactories, PropsStore, SlotHookFactory, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { ConnectionStateSource } from '@deepseek-ai/dsh-client-connection/client' import type { MarkdownFileMentions, MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' // Type-only: the Session-scope access Hook (`useAccess`) the Chat entries read. @@ -325,8 +326,13 @@ export interface ChatViewInjected { fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined } -/** Existing Session-owned node sources shared by the view and its flow renderer. */ -export type ChatFlowDataInjected = Pick +/** Session-owned node sources shared with the view, plus the flow's connection state. */ +export type ChatFlowDataInjected = Pick & { + hooks: ChatViewInjected['hooks'] & { + /** Client connection lifecycle; the running clock pauses while it is not connected. */ + connectionState: ConnectionStateSource + } +} /** Ordered rows and reconciled local echoes rendered inside one Chat viewport. */ export interface ChatFlowOwnerProps extends Pick