Merge remote-tracking branch 'origin/master' into turtle/retry-policy-ui-fb23c6dee798

# Conflicts:
#	docs/event-producer-consumer.md
#	docs/event-producer-consumer.zh.md
This commit is contained in:
Turtle committed 2026-11-03 17:11:52 +08:00
commit b685ec0ddb
922 files changed
+20697 -6641

No files matched your search

@@ -6,23 +6,23 @@
en: ca120635fc40431c
zh: 6be7c5ae3c86cdcc
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/problem:
en: c8b932b02a4e1c80
zh: 96c466be1a521f3f
en: df1e82a74c42fa1d
zh: 8d5452360a0dec8a
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/decision:
en: 2e08d801099cbe18
zh: 8c5ab162ada9cc41
en: 7a4921d82cd11749
zh: 6ab506109afc1351
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/package-topology:
en: 5ea3ddfae82df181
zh: 2d05d95e51081696
en: 3394139c17d28ba8
zh: 66857733ba51d98c
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/ctx-web-contract:
en: b432f80a424c3e49
zh: faa26925fdd98387
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/provider-availability-and-selection:
en: 4e4ec068c2a22383
zh: b7030d0f00bfdb74
en: afc540d986e00d68
zh: 84795770d00228c3
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/search-request-and-result-schema:
en: ec3703b7e7c6b23d
zh: 325755e6e90092da
en: ccf6404ae7f3172e
zh: 3253035ea49e2548
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/fetch-request-and-result-schema:
en: 6dba3b9a51c28b6d
zh: 58a93f6ba2d269ea
@@ -33,8 +33,8 @@
en: 3440f2b34b93f4a1
zh: 57a8f784c7e26eff
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/testing:
en: 7f73699ac4459432
zh: c16be49ac50c34c8
en: 03da2f3422183384
zh: 10fede51a415ce11
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered:
en: 6119e2aff3b6be5a
zh: 76136ef5249c23eb
@@ -42,8 +42,8 @@
en: e0d84a9b1b9c2123
zh: 9e116a7b96751563
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/put-provider-dispatch-directly-in-dsh-tool-web:
en: dc01d014d03b49d2
zh: b956b02f41c62682
en: 68ef45e8e5a36de9
zh: 0013786ed77e03a8
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/alternatives-considered/split-search-and-fetch-into-two-seams-dsh-search-dsh-fetch:
en: 3fb72ba3e554f20a
zh: aff5f0add5773388
@@ -63,11 +63,11 @@
en: 3ea94ffb142d144a
zh: 89645f68412f6769
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/consequences:
en: 9774448fd6b21c41
zh: 8411c590ad70d5c4
en: 8e7523b2858efaf3
zh: aff8fc74eb439ed6
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/deferred-work:
en: 48b7b8e7310cd56b
zh: d406cd6a8a649e58
en: ddb599fd2ac1647c
zh: c0b8f446cf483b3c
/agent-note-web-capability-seam-stable-tools-over-multiple-providers/open-questions:
en: 63848c6ef9a6ab44
zh: 041b340802091e60
@@ -6,7 +6,7 @@ English | [中文](2026-06-24-web-capability-seam.zh.md)
## Problem
The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: supporting both Exa search and Perplexity search from the start — two deliberately different provider shapes (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`; Perplexity returns a generated answer plus citations) — is what proves the normalized web contract does not just mirror one vendor. Fetch is a separate operation: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search.
The harness needs model-facing web tools without binding the model contract to one vendor's API shape. Search is the immediate pressure point: search APIs return deliberately different formats (Exa returns a flat `results[]` of `{title, url, highlights, publishedDate}`, Perplexity returns a generated answer plus citations, and the DeepSeek Search API returns a flat `results[]` with snippets and summaries), and the normalized web contract must accept each of them without mirroring one vendor. The seam was designed against the Exa and Perplexity formats; neither provider ships, and a plugin can register one. Fetch is a separate operation: an anonymous public HTTP(S) fetch backend has transport, security, redirect, decoding, and size-limit concerns that are not the same as provider-backed search.
The model-facing API must stay stable while backends change. A search provider swap should not change how the model asks for a query, and a fetch implementation swap should not change how the model asks for a URL. Conversely, a provider package should not expose its own model-facing tool schema just because it has extra provider-specific knobs.
@@ -19,7 +19,7 @@ There is also a provider-selection question. Existing `tool-bash` and `tool-fs`
Web access is a first-class capability seam following [the capability-seam Agent Note](2026-06-13-capability-seams.md):
1. `@deepseek-ai/dsh-web` (`packages/web/web`) owns `ctx.web`, provider registration, provider selection, shared request/result vocabulary, and web-specific errors.
2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-exa`, `@deepseek-ai/dsh-web-search-perplexity`, `@deepseek-ai/dsh-web-search-deepseek`, and `@deepseek-ai/dsh-web-fetch-http`.
2. Provider packages implement concrete backends and register capabilities with `ctx.web`, for example `@deepseek-ai/dsh-web-search-deepseek`, `@deepseek-ai/dsh-web-deepseek-api`, and `@deepseek-ai/dsh-web-fetch-http`.
3. `@deepseek-ai/dsh-tool-web` (`packages/web/tool-web`) owns the model-facing `web_search` and `web_fetch` tool schemas, prompt sections, argument validation, result formatting, and tool-owned presentation over `ctx.web`.
Providers do not register tools. Providers register capabilities. `dsh-tool-web` is the only owner of model-facing names, descriptions, prompt guidance, JSON schemas, and presentation.
@@ -32,9 +32,9 @@ Search and fetch are separate tools but one web-access seam. `ctx.web` owns prov
- A tool is never unregistered merely because its selected provider is missing, misconfigured, missing credentials, ambiguous, or temporarily unavailable.
- The provider is resolved at execution time, and a structured `WebError` is returned when the selected capability cannot run.
This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` while the selected provider reports that the request's search cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`.
This keeps the model schema stable without making plugin load order, credential state, or HMR timing part of the model-facing contract. If web search is enabled but no usable search provider exists, `web_search` remains visible and execution fails with a structured `WebError` such as `WEB_PROVIDER_UNAVAILABLE` or `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`. `dsh-tool-web` narrows this per model request: it [withholds `web_search` or `web_fetch` while the selected provider reports that the request's operation cannot authenticate](2026-09-27-web-search-offered-by-provider-readiness.md). If a provider appears after `dsh-tool-web`, the next execution can use it without changing the schema. If a provider disappears mid-call, execution fails with a structured `WebError` instead of silently choosing another provider or falling through to `UNKNOWN_TOOL`.
The seam deliberately exposes no observation surface — no registry-change event and no aggregated capability-status query. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes.
The seam deliberately exposes no availability observation — no aggregated capability-status query and no per-provider availability report. Unavailability is a fact a caller observes by executing: `search()`/`fetch()` resolve the provider at call time and throw the structured `WebError` that names what failed. [The observation-surface Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) records that judgment: derived-on-call selection and enablement-based registration leave no consumer that needs a change signal or an availability probe distinct from executing and routing the error, and a future provider-status panel reintroduces the smallest signal or query it actually consumes. Provider pickers consume registration, not availability: `listProviders()` and the Remote method `web/listProviders` list every registered provider with its optional `title` and `description` (including unavailable providers), and the payload-free `web/providers-changed` event fires after each registry mutation. The [web provider selection decision](2026-10-29-web-provider-selection.md) records why registration is observable while availability is not.
## Package topology
@@ -43,11 +43,9 @@ The three-package Service Definition / Service Provider / Consumer split follows
The dependency direction mirrors bash and filesystem:
```text
@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa
@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-deepseek
consumer interface implementation
<--depends on-- @deepseek-ai/dsh-web-search-perplexity
implementation
<--depends on-- @deepseek-ai/dsh-web-search-deepseek
<--depends on-- @deepseek-ai/dsh-web-deepseek-api
implementation
<--depends on-- @deepseek-ai/dsh-web-fetch-http
implementation
@@ -57,9 +55,7 @@ At runtime, provider packages register capabilities with `ctx.web`; `tool-web` r
```mermaid
flowchart LR
exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web
fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
@@ -67,9 +63,9 @@ flowchart LR
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
```
`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages.
`@deepseek-ai/dsh-web` depends only on Cordis and low-level harness support, including the Typert protocol for its Remote method. It declares `ctx.web`, provider interfaces, request/result types, the provider availability contract, and error codes. It does not import tool, agent, session, LLM, or provider packages.
Provider packages depend only on `dsh-web` and Cordis. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service.
Provider packages depend on `dsh-web`, Cordis, and their credential/configuration helpers. They own credentials, endpoints, wire mapping, parsing, and `WebError` translation, using platform `fetch`. Each provider injects the shared service and registers a backend; only `dsh-web` owns the `ctx.web` key. Provider-private protocol shapes do not create dependencies on `ctx.llm` or a Cordis HTTP service.
`@deepseek-ai/dsh-tool-web` depends on `@deepseek-ai/dsh-web`, `@deepseek-ai/dsh-tools`, `@deepseek-ai/dsh-system-prompt`, and Cordis. It never imports concrete provider packages.
@@ -78,16 +74,22 @@ Provider packages depend only on `dsh-web` and Cordis. They own credentials, end
`ctx.web` is a provider registry plus a provider-selecting execution API. The registry half stays close to `LlmRuntime`: a `Map<id, provider>` per capability kind, `registerSearchProvider` / `registerFetchProvider` methods that return disposers, duplicate ids that throw `WebError`, and execution-time resolution that throws when the selected provider is absent or unusable. The authoritative signatures live in `packages/web/web/src/types.ts`; the seam's shape:
```ts
import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
import type {
WebFetchRequest, WebFetchResult, WebProviderInfo, WebProviderList, WebSearchRequest, WebSearchResult,
} from '@deepseek-ai/dsh-web'
interface WebSearchProvider {
readonly id: string
readonly title?: string
readonly description?: WebProviderInfo['description']
available(): boolean
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
}
interface WebFetchProvider {
readonly id: string
readonly title?: string
readonly description?: WebProviderInfo['description']
available(): boolean
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
}
@@ -95,6 +97,7 @@ interface WebFetchProvider {
interface WebRuntime {
registerSearchProvider(provider: WebSearchProvider): () => void
registerFetchProvider(provider: WebFetchProvider): () => void
listProviders(): WebProviderList
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
@@ -131,18 +134,15 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa
searchProvider: deepseek-search-api
fetchProvider: http
- id: web-search-exa
name: '@deepseek-ai/dsh-web-search-exa'
- id: web-search-perplexity
name: '@deepseek-ai/dsh-web-search-perplexity'
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
- id: web-deepseek-api
name: '@deepseek-ai/dsh-web-deepseek-api'
- id: web-fetch-http
name: '@deepseek-ai/dsh-web-fetch-http'
@@ -150,9 +150,9 @@ The "single provider auto-selects" rule is for tests, demos, and simple deployme
name: '@deepseek-ai/dsh-tool-web'
```
Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=perplexity` is equivalent to config `searchProvider: perplexity`, not a hidden priority chain inside `dsh-tool-web`.
Operational overrides feed the same explicit selection path: `DSH_WEB_SEARCH_PROVIDER=deepseek-search-api` is equivalent to config `searchProvider: deepseek-search-api`, not a hidden priority chain inside `dsh-tool-web`.
Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Web search settings switch relies on this to move both capabilities to the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md) at once.
Each search or fetch reads the configured id when it runs, so a saved settings change selects another provider for the next operation without remounting `dsh-web`. The Search and Fetch menus on the Web search and fetch settings page rely on this to switch each capability's provider independently, including the [DeepSeek Search and Fetch APIs](../feature/2026-10-15-deepseek-search-fetch-api.md); the [web provider selection decision](2026-10-29-web-provider-selection.md) owns the shipped defaults.
`ctx.web.search()` and `ctx.web.fetch()` resolve the provider at execution time using the selection rules above. If the selected capability is unavailable, they throw `WebError` with a structured code such as `WEB_PROVIDER_UNAVAILABLE`, `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, or `WEB_PROVIDER_AMBIGUOUS`. If no provider is explicitly configured and no usable provider exists, the execution error is the generic `WEB_PROVIDER_UNAVAILABLE` case; there is deliberately no diagnostic summary of every unavailable provider.
@@ -168,8 +168,8 @@ The `web_search` model-facing tool is small. The only model-facing argument is:
- `dsh-tool-web` owns the value and puts it on `WebSearchRequest.maxResults`.
- `ctx.web` passes the request through to the selected provider unchanged.
- A provider applies `maxResults` at the request layer when its API supports it (Exa's `numResults`), as a cost/latency optimization.
- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control (Perplexity) or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor.
- A provider applies `maxResults` at the request layer when its API supports it (the DeepSeek Search API's `count`), as a cost/latency optimization.
- `ctx.web` enforces the bound on the result: if a provider returns more than `maxResults` sources — because its API has no result-count control or ignored the hint — the seam truncates `sources[]` to `maxResults` and sets `WebSearchResult.truncated` to `true` before returning. This makes the bound a single cross-provider guarantee the model-facing layer can rely on, rather than something each provider must remember to honor.
The seam request carries no provider-specific controls — no Perplexity model selection, search recency, domain filters, Exa `livecrawl`, Exa `type`, regional hints, generated-answer budgets, or search depth. Such a field is added only when it has provider-neutral semantics that both the tool schema and selected providers can honor honestly.
@@ -190,13 +190,14 @@ interface WebSearchSource {
readonly url: string
readonly title?: string
readonly snippet?: string
readonly summary?: string
readonly publishedAt?: string
}
```
`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation shape. A source always has a URL; title, snippet, and `publishedAt` are optional because not every provider returns them. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — Exa returns it as `publishedDate` on each result and Perplexity returns a `date` on search results, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer.
`content` is optional provider-generated answer text, search context, or summary. `sources[]` is the portable citation shape. A source always has a URL; title, snippet, summary, and `publishedAt` are optional because not every provider returns them. `snippet` is a short excerpt for display, and `summary` is a longer excerpt for model reading whose length the consumer bounds. `title` is not required: Perplexity-style citations may provide only URLs, and forcing adapters to invent titles would make the seam lie. `dsh-tool-web` renders a `title ?? hostname(url)`-style fallback label for display. `publishedAt` is an optional publication/crawl timestamp as an ISO-8601 string — the DeepSeek Search API returns it as a nullable `date` on each result, Exa as `publishedDate`, and Perplexity as `date`, so it is real provider data, not derived; the seam carries it as a string and leaves date parsing to the consumer.
Exa search maps each entry of the provider's flat `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← the first `highlights[]` entry (an entry with no highlight has no portable snippet and is dropped), `publishedAt` ← `publishedDate`. Exa returns no provider-generated answer, so `content` is omitted. Perplexity search maps `choices[0].message.content` to `content` and prefers the structured top-level `search_results[]` for `sources[]` — `url` ← `url`, `title` ← `title`, `snippet` ← `snippet` (often empty), `publishedAt` ← `date` — falling back to the URL-only `citations[]` array only when `search_results` is absent (those sources carry just a `url`). If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields.
The DeepSeek Search API provider maps each entry of the response's `results[]` into a `WebSearchSource`: `url` ← `url`, `title` ← `title`, `snippet` ← `snippet`, `summary` ← `summary`, `publishedAt` ← `date`, omitting empty values. It returns no provider-generated answer, so `content` is omitted. If a provider returns fewer structured fields than the seam supports, the adapter omits those optional fields.
Full page retrieval remains the job of `web_fetch(url)`. Search snippets are discovery context, not fetched page bodies.
@@ -283,7 +284,7 @@ Tool execution lets these errors flow through `ToolRuntime.execute()`, which alr
## Testing
Each layer is pinned at its own boundary: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures (Perplexity fixtures include URL-only citations so the optional source fields stay honest) plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-http`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`.
Each layer is pinned at its own boundary: the registry/selection/truncation/abort contract and the `WebError` codes in `dsh-web`; per-provider request/response mapping over recorded fixtures plus a self-skipping with-key smoke per real provider; real local-HTTP behavior in `web-fetch-http`; and enablement-driven registration, structured execution errors, and result formatting through the real tool registry in `dsh-tool-web`. A real-Loader smoke guards the two export shapes ([postmortem 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)): `dsh-web` is a default-exported service, while the providers and `tool-web` are namespace plugins where a stray `export default` would drop `inject`.
## Alternatives considered
@@ -293,7 +294,7 @@ This matches the most flexible provider-plugin systems: every provider can expos
### Put provider dispatch directly in `dsh-tool-web`
This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add Exa and Perplexity without baking their differences into the tool schema.
This resembles OpenCode's local web search: one stable `websearch` tool dispatches to Exa or Parallel internally. It is acceptable for a small product path but wrong as a harness foundation. The tool package would own provider selection, credentials, request mapping, transport, response parsing, and presentation, making it hard to add providers with different formats without baking their differences into the tool schema.
### Split search and fetch into two seams (`dsh-search`, `dsh-fetch`)
@@ -321,9 +322,9 @@ Rejected for the shipped presets. Per-call confirmation would interrupt ordinary
## Consequences
**The search schema is deliberately thin.** Exa and Perplexity both expose useful provider-specific controls; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution.
**The search schema is deliberately thin.** Search APIs expose useful provider-specific controls, such as Exa's `livecrawl`, Perplexity's search recency, and the DeepSeek Search API's `localization`; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution.
**Perplexity citations can be sparse.** A citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels.
**Citations can be sparse.** A Perplexity-style citation may be only a URL. Making `title` and `snippet` optional keeps the seam truthful but means `tool-web` renders fallback labels.
**Stable tool registration defers misconfiguration to execution.** Keeping the tool visible is correct when the product enabled web access, but product apps that expect web search should surface the structured `WEB_PROVIDER_CONFIGURED_MISSING` / `WEB_PROVIDER_CONFIGURED_UNAVAILABLE` / `WEB_PROVIDER_AMBIGUOUS` failures loudly so users do not discover setup problems only after the model calls the tool.
@@ -337,7 +338,7 @@ Rejected for the shipped presets. Per-call confirmation would interrupt ordinary
- A `pdf` `WebFetchBody` kind: the `http` provider decodes text-extractable PDFs (best-effort, capped, `truncated`) into a `{ kind: 'pdf'; content; pageCount? }` arm, and `tool-web` renders it. This is fetch, not `web_extract` — PDF retrieval is a concrete HTTP 200 plus deterministic local decoding, not provider-side extraction of a non-HTTP resource. Adding it is a coordinated change across `dsh-web` (declare the arm), the provider (decode + narrow "binary rejection" to "reject binary except text-extractable PDF"; scanned/image PDFs needing OCR stay out of scope), and `tool-web` (render). The closed `WebFetchBody` union makes the consumer side fail to compile until the new arm is handled.
- Provider-backed extraction as a separate `web_extract` capability, rather than widening `web_fetch` silently.
- Provider-neutral search controls beyond `query` and `maxResults`, once Exa and Perplexity can both honor them honestly.
- Provider-neutral search controls beyond `query` and `maxResults`, once the registered providers can honor them honestly.
## Open questions
@@ -6,7 +6,7 @@ Status: implemented
## 问题
harness 需要面向模型的 web 工具,但不能将模型约定绑定到某一家厂商的 API 形状上。搜索是当前的压力点:从一开始就同时支持 Exa 搜索和 Perplexity 搜索——两种刻意不同的提供方形状(Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`;Perplexity 返回一段生成式回答加引用列表)——正是用来证明归一化的 web 约定并非只是镜像某一家厂商。Fetch 是另一项独立操作:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。
harness 需要面向模型的 web 工具,但不能将模型约定绑定到某一家厂商的 API 形状上。搜索是当前的压力点:各搜索 API 返回刻意不同的格式(Exa 返回扁平的 `results[]`,每项包含 `{title, url, highlights, publishedDate}`;Perplexity 返回一段生成式回答加引用列表;DeepSeek Search API 返回带 snippet 与摘要的扁平 `results[]`),归一化的 web 约定必须能接纳每一种格式,而不是镜像某一家厂商。seam 是以 Exa 和 Perplexity 的格式为参照设计的;这两个提供方都不随附,插件可以注册它们。Fetch 是另一项独立操作:匿名公开 HTTP(S) fetch 后端涉及传输、安全、重定向、解码和大小限制等关注点,与提供方支撑的搜索并不相同。
面向模型的 API 必须保持稳定,而后端可以更换。更换搜索提供方不应改变模型发起查询的方式;更换 fetch 实现不应改变模型请求 URL 的方式。反过来,提供方包也不应仅仅因为自己有额外的提供方特有旋钮就暴露自己的面向模型工具 schema。
@@ -19,7 +19,7 @@ harness 需要面向模型的 web 工具,但不能将模型约定绑定到某
Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md):
1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。
2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa`、`@deepseek-ai/dsh-web-search-perplexity`、`@deepseek-ai/dsh-web-search-deepseek` 和 `@deepseek-ai/dsh-web-fetch-http`。
2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-deepseek`、`@deepseek-ai/dsh-web-deepseek-api` 和 `@deepseek-ai/dsh-web-fetch-http`。
3. `@deepseek-ai/dsh-tool-web`(`packages/web/tool-web`)拥有面向模型的 `web_search` 和 `web_fetch` 工具 schema、提示词段落、参数校验、结果格式化,以及通过 `ctx.web` 实现的工具展示。
提供方不注册工具。提供方注册能力。`dsh-tool-web` 是面向模型的名称、描述、提示词引导、JSON Schema、展示的唯一所有者。
@@ -32,9 +32,9 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13
- 工具绝不会仅仅因为其选定的提供方缺失、配置错误、缺少凭证、存在歧义或暂时不可用就被注销。
- 提供方在执行时解析,当选定的能力无法运行时返回结构化的 `WebError`。
这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。发行版的 `dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的搜索无法鉴权时,它会[撤下 `web_search`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。
这使模型 schema 保持稳定,而不将插件加载顺序、凭证状态或 HMR(热模块替换)时序纳入面向模型的约定。如果 web 搜索已启用但不存在可用的搜索提供方,`web_search` 仍然可见,执行时以结构化的 `WebError`(如 `WEB_PROVIDER_UNAVAILABLE` 或 `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`)失败。`dsh-tool-web` 按模型请求收窄了这一点:选中的提供方报告该请求的操作无法鉴权时,它会[撤下 `web_search` 或 `web_fetch`](2026-09-27-web-search-offered-by-provider-readiness.zh.md)。如果某个提供方在 `dsh-tool-web` 之后出现,下一次执行即可使用它而无需更改 schema。如果某个提供方在调用过程中消失,执行以结构化的 `WebError` 失败,而不是静默选择另一个提供方或回退到 `UNKNOWN_TOOL`。
该 seam 刻意不暴露任何观察面——没有注册表变更事件,也没有聚合的能力状态查询。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。
该 seam 刻意不暴露可用性观察——没有聚合的能力状态查询,也没有逐个提供方的可用性报告。不可用性是调用方通过执行观察到的事实:`search()`/`fetch()` 在调用时解析提供方,并抛出命名了失败原因的结构化 `WebError`。[观察面 Agent Note](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) 记录了这一判断:基于调用的派生选择与基于启用的注册使得没有消费方需要变更信号或独立于执行和错误路由的可用性探测;未来的提供方状态面板会重新引入它实际消费的最小信号或查询。提供方选择器消费的是注册情况而非可用性:`listProviders()` 与 Remote 方法 `web/listProviders` 列出每个已注册提供方及其可选的 `title` 与 `description`(包括不可用的提供方),且每次注册表变更后都会触发无载荷的 `web/providers-changed` 事件。[Web 提供方选择决策](2026-10-29-web-provider-selection.zh.md)记录了为何注册情况可观察而可用性不可观察。
## 包拓扑
@@ -43,11 +43,9 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13
依赖方向与 bash 和 filesystem 一致:
```text
@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-exa
@deepseek-ai/dsh-tool-web --depends on--> @deepseek-ai/dsh-web <--depends on-- @deepseek-ai/dsh-web-search-deepseek
consumer interface implementation
<--depends on-- @deepseek-ai/dsh-web-search-perplexity
implementation
<--depends on-- @deepseek-ai/dsh-web-search-deepseek
<--depends on-- @deepseek-ai/dsh-web-deepseek-api
implementation
<--depends on-- @deepseek-ai/dsh-web-fetch-http
implementation
@@ -57,9 +55,7 @@ Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13
```mermaid
flowchart LR
exa["@deepseek-ai/dsh-web-search-exa"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
perplexity["@deepseek-ai/dsh-web-search-perplexity"] -->|registerSearchProvider| web
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web
deepseek["@deepseek-ai/dsh-web-search-deepseek"] -->|registerSearchProvider| web["@deepseek-ai/dsh-web / ctx.web"]
deepseekApi["@deepseek-ai/dsh-web-deepseek-api"] -->|registerSearchProvider / registerFetchProvider| web
fetchLocal["@deepseek-ai/dsh-web-fetch-http"] -->|registerFetchProvider| web
toolWeb["@deepseek-ai/dsh-tool-web"] -->|search/fetch| web
@@ -67,9 +63,9 @@ flowchart LR
toolWeb -->|ctx.tools.register| webFetch["tool: web_fetch"]
```
`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性约定和错误码。它不导入工具、agent(智能体)、会话、LLM 或提供方包。
`@deepseek-ai/dsh-web` 仅依赖 Cordis 和底层 harness 支持,其中包括其 Remote 方法所用的 Typert 协议。它声明 `ctx.web`、提供方接口、请求/结果类型、提供方可用性约定和错误码。它不导入工具、agent(智能体)、会话、LLM 或提供方包。
提供方包仅依赖 `dsh-web` 和 Cordis。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。
提供方包依赖 `dsh-web`、Cordis 及其凭据和配置函数。它们拥有凭证、端点、协议格式映射、解析和 `WebError` 转换,使用平台 `fetch`。每个提供方注入共享服务并注册后端;只有 `dsh-web` 拥有 `ctx.web` 键。提供方私有的协议形状不会产生对 `ctx.llm` 或 Cordis HTTP 服务的依赖。
`@deepseek-ai/dsh-tool-web` 依赖 `@deepseek-ai/dsh-web`、`@deepseek-ai/dsh-tools`、`@deepseek-ai/dsh-system-prompt` 和 Cordis。它从不导入具体的提供方包。
@@ -78,16 +74,22 @@ flowchart LR
`ctx.web` 是一个提供方注册表加上一个带提供方选择的执行 API。注册表部分与 `LlmRuntime` 保持接近:每种能力类别一个 `Map<id, provider>`,`registerSearchProvider`/`registerFetchProvider` 方法返回 disposer,重复 id 抛出 `WebError`,执行时解析在选定提供方缺失或不可用时抛出异常。权威签名见 `packages/web/web/src/types.ts`;seam 的形状:
```ts
import type { WebFetchRequest, WebFetchResult, WebSearchRequest, WebSearchResult } from '@deepseek-ai/dsh-web'
import type {
WebFetchRequest, WebFetchResult, WebProviderInfo, WebProviderList, WebSearchRequest, WebSearchResult,
} from '@deepseek-ai/dsh-web'
interface WebSearchProvider {
readonly id: string
readonly title?: string
readonly description?: WebProviderInfo['description']
available(): boolean
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
}
interface WebFetchProvider {
readonly id: string
readonly title?: string
readonly description?: WebProviderInfo['description']
available(): boolean
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
}
@@ -95,6 +97,7 @@ interface WebFetchProvider {
interface WebRuntime {
registerSearchProvider(provider: WebSearchProvider): () => void
registerFetchProvider(provider: WebFetchProvider): () => void
listProviders(): WebProviderList
search(request: WebSearchRequest, signal?: AbortSignal): Promise<WebSearchResult>
fetch(request: WebFetchRequest, signal?: AbortSignal): Promise<WebFetchResult>
@@ -131,18 +134,15 @@ interface WebRuntime {
- id: web
name: '@deepseek-ai/dsh-web'
config:
searchProvider: exa
searchProvider: deepseek-search-api
fetchProvider: http
- id: web-search-exa
name: '@deepseek-ai/dsh-web-search-exa'
- id: web-search-perplexity
name: '@deepseek-ai/dsh-web-search-perplexity'
- id: web-search-deepseek
name: '@deepseek-ai/dsh-web-search-deepseek'
- id: web-deepseek-api
name: '@deepseek-ai/dsh-web-deepseek-api'
- id: web-fetch-http
name: '@deepseek-ai/dsh-web-fetch-http'
@@ -150,9 +150,9 @@ interface WebRuntime {
name: '@deepseek-ai/dsh-tool-web'
```
运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=perplexity` 等同于配置 `searchProvider: perplexity`,而非 `dsh-tool-web` 内部的隐式优先级链。
运维覆盖走同一条显式选择路径:`DSH_WEB_SEARCH_PROVIDER=deepseek-search-api` 等同于配置 `searchProvider: deepseek-search-api`,而非 `dsh-tool-web` 内部的隐式优先级链。
每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即对下一次操作选择另一个提供方。网页搜索设置页的开关借此把两种能力同时切换到 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)。
每次搜索或抓取在执行时读取配置的 id,因此保存的设置变更无需重新挂载 `dsh-web` 即可让下一次操作选用新提供方。「网页搜索与获取」设置页的 Search 和 Fetch 下拉框借此分别切换各能力的提供方(包括 [DeepSeek Search 与 Fetch API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md));[Web 提供方选择决策](2026-10-29-web-provider-selection.zh.md)负责内置默认值。
`ctx.web.search()` 和 `ctx.web.fetch()` 在执行时按上述选择规则解析提供方。如果选定的能力不可用,它们抛出带有结构化代码的 `WebError`,如 `WEB_PROVIDER_UNAVAILABLE`、`WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 或 `WEB_PROVIDER_AMBIGUOUS`。如果未显式配置提供方且不存在可用提供方,执行错误是通用的 `WEB_PROVIDER_UNAVAILABLE` 情况;刻意不提供对每个不可用提供方的诊断汇总。
@@ -168,8 +168,8 @@ interface WebRuntime {
- `dsh-tool-web` 拥有该值并将其放在 `WebSearchRequest.maxResults` 上。
- `ctx.web` 将请求原样传递给选定的提供方。
- 当提供方的 API 支持结果数量控制时(Exa 的 `numResults`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。
- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制(Perplexity)或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。
- 当提供方的 API 支持结果数量控制时(DeepSeek Search API 的 `count`),提供方在请求层应用 `maxResults`,作为成本/延迟优化。
- `ctx.web` 在结果上强制执行上限:如果提供方返回的 source 数量超过 `maxResults`——因为其 API 没有结果数量控制或忽略了提示——seam 将 `sources[]` 截断到 `maxResults` 并在返回前将 `WebSearchResult.truncated` 设为 `true`。这使上限成为面向模型层可以依赖的单一跨提供方保证,而非每个提供方都必须记得遵守的东西。
seam 请求不携带提供方特有的控制——没有 Perplexity 模型选择、搜索时效性、域名过滤器、Exa `livecrawl`、Exa `type`、区域提示、生成式回答预算或搜索深度。只有当某个字段具有提供方无关的语义,且工具 schema 和选定的提供方都能诚实地遵守时,才会添加。
@@ -190,13 +190,14 @@ interface WebSearchSource {
readonly url: string
readonly title?: string
readonly snippet?: string
readonly summary?: string
readonly publishedAt?: string
}
```
`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用结构。source 必有 URL;title、snippet 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`title` 不是必填:Perplexity 风格的引用可能只提供 URL,强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——Exa 在每条结果上以 `publishedDate` 返回它,Perplexity 在搜索结果上返回 `date`,因此它是真实的提供方数据而非派生值;seam 以字符串形式传递,日期解析留给消费方。
`content` 是可选的提供方生成的回答文本、搜索上下文或摘要。`sources[]` 是可移植的引用结构。source 必有 URL;title、snippet、summary 和 `publishedAt` 可选,因为并非每个提供方都返回它们。`snippet` 是用于展示的短摘录,`summary` 是供模型阅读的较长摘录,其长度由消费方限制。`title` 不是必填:Perplexity 风格的引用可能只提供 URL,强制适配器编造标题会让 seam 说谎。`dsh-tool-web` 渲染 `title ?? hostname(url)` 风格的回退标签用于展示。`publishedAt` 是可选的发布/抓取时间戳,为 ISO-8601 字符串——DeepSeek Search API 在每条结果上以可为 null 的 `date` 返回它,Exa 以 `publishedDate` 返回,Perplexity 以 `date` 返回,因此它是真实的提供方数据而非派生值;seam 以字符串形式传递,日期解析留给消费方。
Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← 第一个 `highlights[]` 条目(没有 highlight 的条目没有可移植的 snippet,被丢弃)、`publishedAt` ← `publishedDate`。Exa 不返回提供方生成的回答,因此 `content` 省略。Perplexity 搜索将 `choices[0].message.content` 映射为 `content`,并优先使用结构化的顶层 `search_results[]` 作为 `sources[]`——`url` ← `url`、`title` ← `title`、`snippet` ← `snippet`(常为空)、`publishedAt` ← `date`——仅在 `search_results` 缺失时回退到纯 URL 的 `citations[]` 数组(这些 source 只有 `url`)。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。
DeepSeek Search API 提供方将响应 `results[]` 的每一项映射为 `WebSearchSource`:`url` ← `url`、`title` ← `title`、`snippet` ← `snippet`、`summary` ← `summary`、`publishedAt` ← `date`,空值省略。它不返回提供方生成的回答,因此 `content` 省略。如果提供方返回的结构化字段少于 seam 支持的,适配器省略那些可选字段。
完整页面获取仍是 `web_fetch(url)` 的职责。搜索 snippet 是发现上下文,不是获取到的页面正文。
@@ -283,7 +284,7 @@ fetch 提供方的资源控制:
## 测试
每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。
每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射,加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。
## 曾考虑的替代方案
@@ -293,7 +294,7 @@ fetch 提供方的资源控制:
### 将提供方调度直接放在 `dsh-tool-web` 中
这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受,但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将 Exa 和 Perplexity 的差异烘焙进工具 schema 的情况下难以添加它们。
这类似 OpenCode 的本地 web 搜索:一个稳定的 `websearch` 工具在内部调度到 Exa 或 Parallel。对于小型产品路径可以接受,但作为 harness 基础是错误的。工具包将拥有提供方选择、凭证、请求映射、传输、响应解析和展示,使得在不将各提供方格式差异烘焙进工具 schema 的情况下难以添加格式不同的提供方。
### 将搜索和 fetch 拆为两个 seam(`dsh-search`、`dsh-fetch`)
@@ -322,9 +323,9 @@ fetch 提供方的资源控制:
## 后果
**搜索 schema 刻意精简。** Exa 和 Perplexity 都暴露了有用的提供方特有控制;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。
**搜索 schema 刻意精简。** 各搜索 API 暴露了有用的提供方特有控制,例如 Exa 的 `livecrawl`、Perplexity 的搜索时效性和 DeepSeek Search API 的 `localization`;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。
**Perplexity 引用可能稀疏。** 一条引用可能只有 URL。将 `title` 和 `snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。
**引用可能稀疏。** 一条 Perplexity 风格的引用可能只有 URL。将 `title` 和 `snippet` 设为可选使 seam 保持诚实,但意味着 `tool-web` 需要渲染回退标签。
**稳定的工具注册将配置错误推迟到执行时。** 当产品启用了 web 访问时,保持工具可见是正确的;但期望 web 搜索可用的产品应用应当明确暴露结构化的 `WEB_PROVIDER_CONFIGURED_MISSING`/`WEB_PROVIDER_CONFIGURED_UNAVAILABLE`/`WEB_PROVIDER_AMBIGUOUS` 失败,避免用户直到模型调用工具后才发现配置问题。
@@ -340,7 +341,7 @@ fetch 提供方的资源控制:
- `pdf` `WebFetchBody` 类别:`http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。
- 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。
- `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。
- `query` 和 `maxResults` 之外的提供方无关搜索控制,待已注册的提供方都能诚实遵守时再添加。
## 开放问题
@@ -12,8 +12,8 @@
en: aea80ece13f2905b
zh: a880bc1a7839cf12
/agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/consequences:
en: 640b7ee4e8c57ed0
zh: 1c17b3fba16861a8
en: 41ebe22077560090
zh: da3475e4c196c3f9
/agent-note-one-ordering-for-configuration-sources-and-what-a-discovered-file-may-not-decide/alternatives-considered:
en: ac43499cfe7b8495
zh: e5ab599e7d3ed753
@@ -58,7 +58,6 @@ The line is that these take effect with no user action, before any turn, outside
- A `.env` holding `DSH_*`, `PATH`, `BROWSER`, or — in the invoking directory — a proxy variable fails the launch instead of being applied. Developers keeping switches in a repository `.env` move them to their shell — a deliberate, loud break.
- Composition is no longer overridable by a stale shell endpoint. It is still overridable by a user's stored `settings.yaml`, which is the settings seam's layering and not something this note changes; the product CLI offers no flag above it, so a deployment that must win against stored settings owns its own bin or loader tree.
- Not solved: the layers are still materialized into `process.env`, so ordinary project variables continue to reach child processes under the subprocess scrub. Bootstrap variables cannot come from a file at all; the environment package records the remaining subprocess reach as a limitation.
- Exa and Perplexity still capture their key at load time rather than through the credential seam. They no longer read raw `process.env` — they resolve through the trusted layers — but converting them to per-request credential resolution is separate work.
## Alternatives considered
@@ -59,7 +59,6 @@ inherited process environment (read-only, wins)
- 含 `DSH_*`、`PATH`、`BROWSER` 或(在调用目录中)proxy 变量的 `.env` 会导致启动失败而不是被应用。把开关放在仓库 `.env` 里的开发者需要改放到 shell——这是一次刻意且响亮的破坏。
- composition 不再会被陈旧的 shell endpoint 覆盖。但它仍然会被用户已存的 `settings.yaml` 覆盖,这是 settings seam 的分层方式,本 Note 不改变它;产品 CLI 没有高于它的标志,因此需要压过已存 settings 的部署方要自带 bin 或 loader 配置树。
- 未解决的:各层仍然会被物化进 `process.env`,因此普通项目变量继续按子进程清洗规则抵达子进程。bootstrap 变量完全不能来自文件;环境包将其余变量仍可抵达子进程这一点记录为一项限制。
- Exa 与 Perplexity 仍在加载时捕获密钥,而不是经凭据 seam。它们不再读裸 `process.env`——改为经受信层解析——但把它们改造成按请求解析凭据是另一件事。
## Alternatives considered
@@ -9,8 +9,8 @@
en: 2abacff687661d28
zh: 2d7ba3ae27dba888
/agent-note-keep-live-configuration-in-the-owning-profile/decision:
en: 66c3884425cfb90a
zh: 0fe6a1f1a576470b
en: f9b1dd3d158457a9
zh: 06ca76fcbbc76cda
/agent-note-keep-live-configuration-in-the-owning-profile/alternatives-considered:
en: 4089464ad1fb60bd
zh: 3168e9e4a205145d
@@ -14,7 +14,7 @@ A plugin declares every configurable value in its Cordis Config. Fields that can
Form writes target the active profile. Home patches and command-line overlays remain higher-priority deployment inputs; an edit they would shadow fails before persistence. Two plugin instances are addressed by their distinct profile entry ids. Nested Includes retain independent ownership and are not writable through the profile form.
Cordis config patches replace complete entry configs. An edit preserves ordinary fields and unedited secrets, including raw configuration expressions. A field reset restores the currently inherited value; resetting the complete entry removes its config overrides so later bundle changes are inherited. Other edits store the entry's complete config in the profile row: the ordinary fields as composed at write time plus every volatile field. Later bundle changes to those fields do not reach that profile until the entry's config override is removed. In the shipped bundles this pins `permission.presets` after a default-preset change, `agent-presets` after a preset choice, `agent-loop.agents` after a parallelism edit, and `web-search-deepseek.apiKeyEnv` after a Web Search edit, and the client marks every volatile field of such a row as overridden, not only the edited one. Narrowing a write to the edited fields needs merge semantics for patch `config`, which Include does not provide.
Cordis config patches replace complete entry configs. An edit preserves ordinary fields and unedited secrets, including raw configuration expressions. A field reset restores the currently inherited value; resetting the complete entry removes its config overrides so later bundle changes are inherited. Other edits store the entry's complete config in the profile row: the ordinary fields as composed at write time plus every volatile field. Later bundle changes to those fields do not reach that profile until the entry's config override is removed. In the shipped bundles this pins `permission.presets` after a default-preset change, `agent-presets` after a preset choice, `agent-loop.agents` after a parallelism edit, and `web-search-deepseek.apiKeyEnv` after a legacy Messages search edit on the Web search and fetch page, and the client marks every volatile field of such a row as overridden, not only the edited one. Narrowing a write to the edited fields needs merge semantics for patch `config`, which Include does not provide.
The removed `$DSH_HOME/settings.yaml` is imported once into the active profile when the Loader has settled every entry after Settings starts: section ids are entry ids, with `ui-developer-tools` → `ui-settings`, `ui-onboarding` → `ui-settings-general`, and `shell` → the platform's shell executor entry. The file is renamed to `settings.yaml.imported` before the first write, so the import never repeats; a rejected section is logged and stays in the renamed file.
@@ -14,7 +14,7 @@ Status: implemented
表单写入当前 profile。home patch 和命令行 overlay 仍是优先级更高的部署输入;如果它们会遮蔽某次修改,编辑器会在持久化前拒绝写入。两个插件实例通过各自不同的 profile entry id 寻址。嵌套 Include 保留独立所有权,不能通过该 profile 表单写入。
Cordis config patch 替换完整 entry config。编辑会保留普通字段、未编辑的秘密值和原始配置表达式。字段重置恢复当前继承值;整个 entry 重置后会移除其 config override,从而继承后续 bundle 变化。其他编辑会把该 entry 的完整 config 存入 profile 行:写入时组合出的普通字段加上全部 volatile 字段。此后 bundle 对这些字段的修改在该 entry 的 config override 被移除之前不会到达该 profile。在已发布的 bundle 中,修改默认权限预设会钉住 `permission.presets`,选择预设会钉住 `agent-presets`,编辑并行度会钉住 `agent-loop.agents`,编辑网页搜索会钉住 `web-search-deepseek.apiKeyEnv`;客户端会把这类行的每个 volatile 字段都标为已覆盖,而不只是被编辑的那个。要把写入收窄到被编辑字段需要 patch `config` 的合并语义,Include 不提供。
Cordis config patch 替换完整 entry config。编辑会保留普通字段、未编辑的秘密值和原始配置表达式。字段重置恢复当前继承值;整个 entry 重置后会移除其 config override,从而继承后续 bundle 变化。其他编辑会把该 entry 的完整 config 存入 profile 行:写入时组合出的普通字段加上全部 volatile 字段。此后 bundle 对这些字段的修改在该 entry 的 config override 被移除之前不会到达该 profile。在已发布的 bundle 中,修改默认权限预设会钉住 `permission.presets`,选择预设会钉住 `agent-presets`,编辑并行度会钉住 `agent-loop.agents`,在「网页搜索与获取」页编辑旧版 Messages 搜索会钉住 `web-search-deepseek.apiKeyEnv`;客户端会把这类行的每个 volatile 字段都标为已覆盖,而不只是被编辑的那个。要把写入收窄到被编辑字段需要 patch `config` 的合并语义,Include 不提供。
被移除的 `$DSH_HOME/settings.yaml` 会在 Settings 启动后、Loader 完成所有条目加载时一次性导入当前 profile:section id 即 entry id,其中 `ui-developer-tools` → `ui-settings`、`ui-onboarding` → `ui-settings-general`、`shell` → 当前平台的 shell 执行器条目。文件在第一次写入前改名为 `settings.yaml.imported`,因此导入不会重复;被拒绝的 section 会记录日志并保留在改名后的文件中。
@@ -6,14 +6,14 @@
en: 9163af91ea0e030d
zh: 92e44043b999d32f
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/problem:
en: 6fac3c73a4415c83
zh: f65b61cc1705348b
en: 32ba2b2b42e8deb5
zh: 6a6532561f81b8e2
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/decision:
en: 458416ed7e47f644
zh: 8b4dd820228e9dfd
en: 08a54b37e157da29
zh: 65f633055d9df242
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/alternatives-considered:
en: b731254983be656d
zh: 5dee2b09f7faa537
en: 6da221f6b821ea44
zh: b15774011abed7ed
/agent-note-offer-web-search-and-web-fetch-only-while-the-selected-provider-is-ready/consequences:
en: 798faeaa0b406eac
zh: 97bbfdaebc84b1d4
en: c90a122c1f33bb83
zh: 0175ca0f599d086b
@@ -6,17 +6,17 @@ English | [中文](2026-09-27-web-search-offered-by-provider-readiness.zh.md)
## Problem
`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The shipped search provider, `dsh-web-search-deepseek`, authenticates a search with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. A Session on an API-key route without a search key was therefore offered a tool whose every call failed with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Search and Fetch APIs of `dsh-web-deepseek-api`, which the Web search settings switch selects together, need that credential for both operations, so the same Session was also offered a `web_fetch` whose every call failed.
`dsh-tool-web` registers `web_search` whenever search is enabled, and the [web capability seam decision](2026-06-24-web-capability-seam.md) keeps it visible while the selected provider cannot run. The DeepSeek web providers authenticate an operation with the DeepSeek account token only for a model request on the `deepseek-account` route; every other route needs a configured API key. Without a readiness check, a Session on an API-key route without a key is offered a tool whose every call fails with `WEB_PROVIDER_CREDENTIAL_MISSING`. Sessions in one Host can use different routes, a Session can switch routes between requests, and the Web app registers `web_search` once per agent preset, so no registration decision matches every request. The Fetch API of `dsh-web-deepseek-api`, which the user selects separately from the search provider ([provider selection](2026-10-29-web-provider-selection.md)), needs the same credential, so such a Session with it selected would also be offered a `web_fetch` whose every call fails.
## Decision
Each role of the web capability seam owns one part of a per-step check:
- **Service Definition.** `WebSearchProvider` and `WebFetchProvider` have an optional `ready(llmProvider)` that answers whether an operation made for a model request on that chat route has a credential to send. It checks configuration only and makes no network calls. `ctx.web.searchReady(llmProvider?)` and `ctx.web.fetchReady(llmProvider?)` resolve the provider with the same rules as `search()` and `fetch()` and return its answer. A provider without `ready()` counts as ready, and a selection failure answers true because the operation reports that failure as a structured `WebError`.
- **Service Provider.** `dsh-web-search-deepseek` answers true when the route is `deepseek-account` and `ctx.deepseekAccount.resolveToken()` returns a token for the configured search endpoint, when the section carries a literal `apiKey`, or when `ctx.credentials.describe()` reports the `apiKeyEnv` reference configured (without that service, when the launching environment carries it). It checks credentials in the order `search()` resolves them, and a failed account-token or credential-store read answers false, because `search()` fails on that read instead of trying the next credential. The Search and Fetch API providers of `dsh-web-deepseek-api` share one credential and answer the same way for their API base; their transport repeats these checks, so neither package depends on the other. The providers name no tool or prompt section.
- **Service Provider.** Each DeepSeek provider checks its credentials in the order its operation resolves them. `dsh-web-search-deepseek` checks, on the `deepseek-account` route, whether `ctx.deepseekAccount.resolveToken()` returns a token for its Messages endpoint, and then its literal `apiKey` or its `apiKeyEnv` reference. The Search and Fetch API providers of `dsh-web-deepseek-api` resolve the same plan as the operation ([credential resolution](2026-10-29-web-provider-selection.md)): a capability with its own YAML fields checks only the key under its `*ApiKeyEnv`; otherwise, on the account route with the account model route mounted, a token for that route's `/v1/tools` base counts, and then the key of the `deepseek-official` route, or `DEEPSEEK_API_KEY` without that route. A key counts when `ctx.credentials.describe()` reports its reference configured (without that service, when the launching environment carries it); the [shared credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts) perform these presence checks. A failed account-token or credential-store read answers false, because the operation fails on that read instead of trying the next credential. The providers name no tool or prompt section.
- **Consumer.** `dsh-tool-web` registers `web_search` and `web_fetch` with [step activation conditions](../../../../packages/core/tools/README.md#conditional-tools) that ask `ctx.web.searchReady()` and `ctx.web.fetchReady()` about the route of the step's model selection. The agent loop resolves conditions before it assembles the step, so the declared tools, the `tool:web_search` and `tool:web_fetch` prompt sections, PTC program bindings, and dispatch all read the same admitted set.
The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. The providers do not compare the chat route's endpoint with their own: each operation sends the configured key, `DEEPSEEK_API_KEY` by default, to the configured endpoint, even when the official route chats through a gateway.
The route decides only whether the account token is checked before the API key, which happens on the `deepseek-account` route; an API key counts on every route, so a conversation on another provider's model is offered the DeepSeek web tools whenever a DeepSeek key is configured. A Search or Fetch API capability with its own YAML fields ignores the route and is ready exactly when its own key is configured.
Only presence decides: no API key value is read and no endpoint is contacted, so a wrong key or an endpoint that does not serve the native `web_search_20250305` server tool keeps the tool admitted. A step that does not admit a web tool also refuses calls to it as `UNKNOWN_TOOL`, so a model that repeats an earlier call reaches no provider; an operation made outside a model step resolves its credential when it runs and fails with `WEB_PROVIDER_CREDENTIAL_MISSING` when there is none.
@@ -24,7 +24,7 @@ The request header records the assembled tools, so the agent loop announces each
## Alternatives considered
**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as Exa, whose searches would succeed.
**Let the DeepSeek provider remove `web_search` in its own assemble listener.** The seam stays unchanged, but the provider then names another package's tool, prompt section, and PTC transport, and it removes the tool even when the deployment selects another search provider, such as one a plugin registers, whose searches would succeed.
**Extend `available()`.** It is synchronous and takes no request, while this check reads an asynchronous credential store and depends on the request's route. A route-dependent `available()` would also make provider selection depend on the conversation.
@@ -36,9 +36,9 @@ The request header records the assembled tools, so the agent loop announces each
**Offer search only on the DeepSeek account route.** Account tokens are issued only for the official endpoint, so a gateway key could never reach DeepSeek. But every shipped profile defaults to the `deepseek-official` route, so search would be off by default, including the official API-key case.
**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Web search settings page saves its key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so a key entered there could never serve another model; a `routes` setting that opens them would be one more thing a user has to find.
**Withhold the tools from other providers' conversations unless search has a key of its own.** A search from another model's conversation bills the DeepSeek key, but DeepSeek search is one of the services that key pays for, and a user who configured it expects search to work whatever model chats. The Search and Fetch APIs send the chat route's own key, and the settings page saves the legacy Messages search key under `apiKeyEnv`, `DEEPSEEK_API_KEY` by default, which the chat route also reads, so withholding them would leave a configured key unable to serve another model; a `routes` setting that opens them would be one more thing a user has to find.
**Compare the official chat route's endpoint with the search endpoint.** Withholding the tools or the key when the two endpoints differ in origin keeps a gateway's key away from DeepSeek, but the providers would have to find the chat route's configuration themselves. Taking the first active `dsh-llm-deepseek-api-key` Loader row answers for an unrelated instance when a composition isolates several, which disables a correct route or lets a gateway key through; only the route's owner knows which endpoint serves a request, and an endpoint field in `dsh-llm` would reach the browser through the Remote `listProviders()`. The check would also withhold search from users who chat through a proxy or development endpoint with a key the official search endpoint accepts. Without it, a gateway user's search fails at the configured endpoint.
**Compare the official chat route's endpoint with the web endpoint and withhold the tools when they differ.** It would keep a gateway's key away from DeepSeek, but it would also withhold web tools from users who chat through a proxy or development endpoint with a key the web endpoint accepts. The Search and Fetch APIs instead derive their endpoint from the origin of the route whose key they send, so the key and endpoint come from one route; legacy Messages search keeps its own endpoint, where a gateway user's search fails.
**Keep the tool offered and fail at execution.** The model spends a call on a search that cannot authenticate, and the failure repeats on every attempt.
@@ -46,6 +46,6 @@ The request header records the assembled tools, so the agent loop announces each
## Consequences
`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The Exa, Perplexity, and HTTP fetch providers implement no `ready()`, so their behavior is unchanged, and `web_fetch` stays admitted whenever the switch leaves HTTP fetch selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key, and a deployment whose official route chats through a gateway sends the gateway's key to the configured endpoint, the official one by default, where each operation fails unless that endpoint accepts the key. The DeepSeek web packages read no Loader rows and depend on no LLM package.
`dsh-web` gains public members: the optional `ready()` of both provider interfaces, `WebRuntime.searchReady()`, and `WebRuntime.fetchReady()`. Each readiness method is the smallest query its one consumer needs, which is how the [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) lets a status query return; neither reports availability or selection failures. The HTTP fetch provider implements no `ready()`, so `web_fetch` stays admitted whenever HTTP fetch is selected. A step that does not admit a web tool leaves it out of PTC program bindings too. An endpoint that cannot serve the native server tool keeps the tool admitted and fails each call. Removing a tool on an unchanged route changes the declared tools and misses the prompt cache once; a model switch already invalidates it. Searches and fetches from other providers' conversations bill the configured DeepSeek key. A deployment whose official route chats through a gateway sends the gateway's key to that gateway's origin with `/v1/tools` for the Search and Fetch APIs, and to the configured Messages endpoint, the official one by default, for legacy Messages search, where each search fails unless that endpoint accepts the key. The Search and Fetch APIs read the DeepSeek model routes' Loader entries through the model provider directory.
Unit tests pin each role: `packages/web/web/tests/web.spec.ts` covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, `packages/web/web-search-deepseek/tests/deepseek.spec.ts` and `packages/web/web-deepseek-api/tests/readiness.spec.ts` cover each credential case of `ready()` on the account route and on other routes, and `packages/web/tool-web/tests/tool-web.spec.ts` resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`.
Unit tests pin each role: [web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers which provider `searchReady()` and `fetchReady()` ask and their answer on selection failure, [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) cover each credential case of `ready()` on the account route and on other routes, including readiness that matches each operation, and [tool-web.spec.ts](../../../../packages/web/tool-web/tests/tool-web.spec.ts) resolves step admission of both tools through the real prompt, tool, and executor services, including the `UNKNOWN_TOOL` refusal of a call in a step that withheld the tool. In real compositions, the shipped headless keyless smoke test and the `web-search-unconfigured` recorded-session snapshot, whose search key references are unset, pin the withheld search tool. Snapshot replays supply a placeholder DeepSeek key, so their tool lists do not depend on the runner's environment; with it, the `pi-ai-conversation-updates` conversation on a pi-ai route keeps `web_search`.
@@ -6,17 +6,17 @@ Status: implemented
## Problem
`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。发行版的搜索提供方 `dsh-web-search-deepseek` 只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 鉴权;其他路由都需要已配置的 API 密钥。因此,API 密钥路由上没有搜索密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。Web 搜索设置开关会同时选中 `dsh-web-deepseek-api` 的 Search 与 Fetch API,两个操作都需要这份凭据,因此同一会话还会获得一个每次调用都失败的 `web_fetch`。
`dsh-tool-web` 在启用搜索时总会注册 `web_search`,而 [web 能力 seam 决策](2026-06-24-web-capability-seam.zh.md)让它在选中的提供方无法运行时仍然可见。DeepSeek Web 提供方只对 `deepseek-account` 路由上的模型请求使用 DeepSeek 账号 token 为操作鉴权;其他路由都需要已配置的 API 密钥。没有就绪检查时,API 密钥路由上没有密钥的会话会获得一个每次调用都以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败的工具。同一 Host 中的会话可以使用不同路由,一个会话可以在请求之间切换路由,而 Web 应用按 agent preset 只注册一次 `web_search`,因此任何注册层面的决定都无法与每个请求相符。`dsh-web-deepseek-api` 的 Fetch API 由用户与搜索提供方分别选择([提供方选择](2026-10-29-web-provider-selection.zh.md)),它需要同一份凭据,因此这样的会话选中它时,还会获得一个每次调用都失败的 `web_fetch`。
## Decision
web 能力 seam 的每个角色各负责逐步骤检查的一部分:
- **Service Definition。** `WebSearchProvider` 与 `WebFetchProvider` 有一个可选的 `ready(llmProvider)`,回答为该对话路由上的模型请求发起的操作是否有可发送的凭据。它只检查配置,不发起网络调用。`ctx.web.searchReady(llmProvider?)` 与 `ctx.web.fetchReady(llmProvider?)` 按与 `search()`、`fetch()` 相同的规则解析提供方并返回其回答。没有 `ready()` 的提供方视为就绪;选择失败时回答 true,因为对应操作会把该失败报告为结构化 `WebError`。
- **Service Provider。** `dsh-web-search-deepseek` 在以下任一情况下回答 true:路由为 `deepseek-account` 且 `ctx.deepseekAccount.resolveToken()` 为配置的搜索端点返回 token;配置段带有字面 `apiKey`;或 `ctx.credentials.describe()` 报告 `apiKeyEnv` 引用已配置(没有该服务时,启动环境中存在该变量)。它按 `search()` 解析凭据的顺序检查凭据,读取账号 token 或凭据存储失败时回答 false,因为 `search()` 会在该次读取上失败,而不会尝试下一种凭据。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方共用一份凭据,并对其 API 基础地址以同样方式回答;它们的传输层重复了这些检查,因此两个包互不依赖。提供方不点名任何工具或提示词段落。
- **Service Provider。** 每个 DeepSeek 提供方都按其操作解析凭据的顺序检查凭据。`dsh-web-search-deepseek` 在 `deepseek-account` 路由上检查 `ctx.deepseekAccount.resolveToken()` 是否为其 Messages 端点返回 token,然后检查其字面 `apiKey` 或 `apiKeyEnv` 引用。`dsh-web-deepseek-api` 的 Search 与 Fetch API 提供方解析与操作相同的方案([凭据解析](2026-10-29-web-provider-selection.zh.md)):在 YAML 中设置了单独字段的能力只检查其 `*ApiKeyEnv` 下的密钥;否则,在账号路由上且账号模型路由已挂载时,该路由 `/v1/tools` 基础地址的 token 算数,然后是 `deepseek-official` 路由的密钥,没有该路由时为 `DEEPSEEK_API_KEY`。`ctx.credentials.describe()` 报告其引用已配置时(没有该服务时,启动环境中存在该变量),密钥即算存在;这些存在性检查由[共享凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)执行。读取账号 token 或凭据存储失败时回答 false,因为操作会在该次读取上失败,而不会尝试下一种凭据。提供方不点名任何工具或提示词段落。
- **Consumer。** `dsh-tool-web` 注册 `web_search` 与 `web_fetch` 时各带一个[步骤激活条件](../../../../packages/core/tools/README.zh.md#conditional-tools),以该步骤模型选择的路由询问 `ctx.web.searchReady()` 与 `ctx.web.fetchReady()`。agent loop 在组装该步骤之前解析各条件,因此声明的工具、`tool:web_search` 与 `tool:web_fetch` 提示词段落、PTC 程序绑定与派发都读取同一个准入集合。
路由只决定是否在 API 密钥之前检查账号 token,这只发生在 `deepseek-account` 路由上;API 密钥在任何路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。提供方不比较聊天路由的地址与自己的端点:即使官方路由经网关聊天,每次操作也把配置的密钥(默认 `DEEPSEEK_API_KEY`)发往配置的端点。
路由只决定是否先检查账号 token 再检查 API 密钥,这发生在 `deepseek-account` 路由上;API 密钥在每条路由上都算数,因此只要配置了 DeepSeek 密钥,使用其他提供方模型的对话也会获得 DeepSeek Web 工具。在 YAML 中设置了单独字段的 Search 或 Fetch API 能力不看路由,恰在其自身密钥已配置时就绪。
只看凭据是否存在:不读取 API 密钥的值,也不访问任何端点,因此错误的密钥,或不提供原生 `web_search_20250305` 服务器工具的端点,仍会让该工具保持准入。未准入某个 Web 工具的步骤也会以 `UNKNOWN_TOOL` 拒绝对它的调用,因此模型重复先前的调用也到达不了任何提供方;在模型步骤之外发起的操作在运行时解析凭据,没有凭据时以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败。
@@ -24,7 +24,7 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分:
## Alternatives considered
**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如 Exa),它也会移除该工具。
**让 DeepSeek 提供方在自己的 assemble 监听器中移除 `web_search`。** seam 保持不变,但提供方因此要点名另一个包的工具、提示词段落与 PTC 传输工具;而且即使部署选择了另一个搜索成功的提供方(例如插件注册的提供方),它也会移除该工具。
**扩展 `available()`。** 它是同步的且不接收请求,而这项检查要读取异步凭据存储,并取决于请求的路由。依赖路由的 `available()` 还会让提供方选择取决于对话。
@@ -36,9 +36,9 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分:
**只在 DeepSeek 账号路由上提供搜索。** 账号 token 只对官方端点签发,因此网关的密钥不可能到达 DeepSeek。但所有发行 profile 的默认路由都是 `deepseek-official`,搜索会因此默认关闭,连官方 API 密钥的情况也一并关闭。
**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Web 搜索设置页把密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此在那里输入的密钥永远无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。
**除非搜索有自己的密钥,否则不向其他提供方的对话提供这些工具。** 其他模型对话中的搜索会由 DeepSeek 密钥计费,但 DeepSeek 搜索本就是该密钥付费使用的服务之一,配置了它的用户也期望无论用哪个模型对话,搜索都能用。Search 与 Fetch API 发送聊天路由自己的密钥,设置页把旧版 Messages 搜索的密钥存在 `apiKeyEnv` 指向的引用下,默认是聊天路由也在读取的 `DEEPSEEK_API_KEY`,因此撤下工具会让已配置的密钥无法服务其他模型;再加一个开放这些路由的 `routes` 配置项,只会让用户多找一样东西。
**比较官方聊天路由的端点与搜索端点。** 两个端点不同源时撤下工具或不发送密钥,能让网关的密钥远离 DeepSeek,但提供方得自己找出聊天路由的配置。取第一条激活的 `dsh-llm-deepseek-api-key` Loader 行时,如果组合隔离了多个实例,回答的可能是无关的实例,从而关掉正确的路由,或放过网关的密钥;只有路由的所有者知道哪个端点服务某个请求,而在 `dsh-llm` 加地址字段会让它经 Remote 方法 `listProviders()` 进入浏览器。这项检查还会让经代理或开发端点对话、而密钥能被官方搜索端点接受的用户失去搜索。不做这项检查时,网关用户的搜索会在配置的端点上失败。
**比较官方聊天路由的端点与 Web 端点,二者不同时撤下工具。** 这能让网关的密钥远离 DeepSeek,但也会让经代理或开发端点对话、而密钥能被 Web 端点接受的用户失去网页工具。Search 与 Fetch API 改为从其所发送密钥的路由的 origin 推导端点,因此密钥与端点来自同一条路由;旧版 Messages 搜索保留自己的端点,网关用户的搜索会在那里失败。
**保持工具可见,在执行时失败。** 模型会把一次调用花在无法鉴权的搜索上,并且每次尝试都会重复失败。
@@ -46,6 +46,6 @@ web 能力 seam 的每个角色各负责逐步骤检查的一部分:
## Consequences
`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。Exa、Perplexity 与 HTTP 抓取提供方没有实现 `ready()`,其行为不变;只要开关让 HTTP 抓取保持选中,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费;官方路由经网关对话的部署会把网关的密钥发往配置的端点(默认是官方端点),除非该端点接受这个密钥,否则每次操作都会失败。DeepSeek Web 包不读取 Loader 的行,也不依赖任何 LLM 包。
`dsh-web` 增加以下公共成员:两个提供方接口上可选的 `ready()`、`WebRuntime.searchReady()` 与 `WebRuntime.fetchReady()`。每个就绪方法都是其唯一消费方所需的最小查询,这正是[观测接口决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)允许状态查询回归的方式;它们既不报告可用性,也不报告选择失败。HTTP 抓取提供方没有实现 `ready()`,因此只要选中 HTTP 抓取,`web_fetch` 就始终准入。未准入某个 Web 工具的步骤,其 PTC 程序绑定中也没有它。无法提供原生服务器工具的端点仍会让工具保持准入,并使每次调用失败。在路由不变时移除工具会改变声明的工具,并使提示词缓存失效一次;模型切换本身已经使缓存失效。其他提供方对话中的搜索与抓取由配置的 DeepSeek 密钥计费。官方路由经网关对话的部署,对 Search 与 Fetch API 会把网关的密钥发往该网关 origin 加 `/v1/tools`,对旧版 Messages 搜索则发往配置的 Messages 端点(默认是官方端点),除非该端点接受这个密钥,否则每次搜索都会失败。Search 与 Fetch API 通过模型提供方目录读取 DeepSeek 模型路由的 Loader 条目。
单元测试分别固定每个角色:`packages/web/web/tests/web.spec.ts` 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,`packages/web/web-search-deepseek/tests/deepseek.spec.ts` 与 `packages/web/web-deepseek-api/tests/readiness.spec.ts` 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,`packages/web/tool-web/tests/tool-web.spec.ts` 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。
单元测试分别固定每个角色:[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖 `searchReady()` 与 `fetchReady()` 询问哪个提供方以及选择失败时的回答,[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 与 [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 覆盖 `ready()` 在账号路由与其他路由上的每种凭据情况,包括就绪判断与每次操作一致,[tool-web.spec.ts](../../../../packages/web/tool-web/tests/tool-web.spec.ts) 通过真实的提示词、工具与执行器服务解析两个工具的步骤准入,包括在撤下该工具的步骤中调用它时以 `UNKNOWN_TOOL` 拒绝。在真实组合中,发行版 headless 无密钥冒烟测试与 `web-search-unconfigured` 录制会话快照的搜索密钥引用都未设置,二者固定了搜索工具的撤下行为。快照回放提供占位 DeepSeek 密钥,因此其工具列表不依赖运行环境;借助该密钥,`pi-ai-conversation-updates` 中 pi-ai 路由上的对话保留 `web_search`。
@@ -9,11 +9,11 @@
en: db7a81ab452fdd0f
zh: a14fdcdb8ff4cd47
/agent-note-team-availability-is-selected-per-session/decision:
en: f4e4ba7be0eda796
zh: 410649e86ff5ca72
en: d1174db5157b91ee
zh: ceb6dc5a89a0a8d6
/agent-note-team-availability-is-selected-per-session/alternatives-considered:
en: cd3cc1f3fab84f80
zh: 6d7792742d62f9fe
en: d50ed45e8f902281
zh: c01d9be4b6e5df06
/agent-note-team-availability-is-selected-per-session/consequences:
en: b56a6a1c8ad1aa7a
zh: 43bdd8a5bccce8bf
@@ -14,12 +14,16 @@ The base bundle loads the Team service and task tools, and the Web bundle loads
The model and reasoning menu owns the user’s selection. The root conversation’s Subagent panel displays members above shared tasks. Disabling Team keeps the journal and children; re-enabling restores the board. Historical task boards without a saved selection stay enabled. Profile loading removes the retired optional bundle selection, retaining row overrides.
Headless deployments may set `agent-team.defaultEnabled` to save an initial selection for a new root during startup creation. Saved selections take precedence, and resumed Sessions retain their mode even when no selection exists. Ordinary forks initialize independently; subagents share their live root’s selection. Creation identifies subagents by their Session origin because activation ownership is registered only after creation listeners settle. Task access still requires the live runtime owner.
This supersedes the explicit-composition choice in [Agent Teams](../feature/2026-08-05-agent-teams.md). Its durability, ownership, and lifecycle decisions remain active. [Optional bundles](../process/2026-09-15-shipped-optional-bundles.md) still governs other experimental features.
## Alternatives considered
**Keep both switches.** Capability installation remains a prerequisite that the conversation control cannot explain or resolve; users must manage shared profile state for a Session decision.
**Apply the deployment default on resume or at read time.** Changing a profile would change an existing conversation’s tools without a saved selection. Persisting the default once at creation keeps subsequent capability decisions reconstructable from the Session log.
**Hide the plugin card without loading Team.** New profiles would have no way to obtain the capability. Loading experimental packages in the default profile would violate product isolation; moving them to their owning product groups makes the delivered support explicit.
## Consequences
@@ -14,12 +14,16 @@ Status: implemented
模型与推理等级菜单承载用户选择。根会话的 Subagent 面板在成员下方显示共享任务。关闭 Team 保留日志与子智能体;重新开启恢复任务板。没有保存选择的历史任务板保持开启。profile 加载时移除已停用的可选 bundle 选择,保留配置行覆盖。
无界面部署可设置 `agent-team.defaultEnabled`,在新根会话的启动创建阶段保存初始选择。已保存的选择优先,恢复的 Session 即使没有保存选择也保留原模式。普通分叉独立初始化;子智能体共享当前运行时根的选择。创建阶段通过 Session origin 识别子智能体,因为激活归属在创建监听器结束后才登记。任务访问仍要求当前运行时归属有效。
本决策替代 [Agent Teams](../feature/2026-08-05-agent-teams.zh.md) 中显式选择组合的部分;其持久性、归属与生命周期决策继续有效。[可选 bundle](../process/2026-09-15-shipped-optional-bundles.zh.md)仍约束其他实验性功能。
## Alternatives considered
**保留两个开关。** 安装能力仍是会话控件无法解释或解决的前置条件;用户必须为 Session 决策管理共享 profile 状态。
**在恢复或读取时应用部署默认值。** 修改 profile 将在没有保存选择的情况下改变现有会话的工具。在创建时一次性持久化默认值,可让后续能力选择从 Session 日志重建。
**隐藏插件卡片但不加载 Team。** 新 profile 将无法获得该能力。默认 profile 加载实验性包会违反产品隔离;将相关包移入所属产品组可明确随产品提供的支持。
## Consequences
@@ -0,0 +1,37 @@
# Bilingual-pair consistency record for 2026-10-29-web-provider-selection.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 .agents/notes/implemented/architecture/2026-10-29-web-provider-selection.md
/agent-note-web-provider-selection-independent-menus-over-registered-providers:
en: 2a2fa1def8a81625
zh: 317d8b115f34e677
/agent-note-web-provider-selection-independent-menus-over-registered-providers/problem:
en: 3f5dacf86015cebb
zh: cf0d5bceb9941554
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision:
en: 0682ebef17f6a5e7
zh: 6ea399459262eb0c
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/registered-providers-are-observable-availability-is-not:
en: 38ce796dea91fa35
zh: d312a9cd29fd5cc9
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/independent-selection:
en: 9d68ffca5c8be681
zh: 36bb661f98de0ebb
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/credential-resolution:
en: 32f59c35d71a4832
zh: e9288313bb18b78d
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/defaults-and-stored-selections:
en: 5fcd3319fbceed1d
zh: 20771646b9c36fa6
/agent-note-web-provider-selection-independent-menus-over-registered-providers/decision/legacy-messages-search:
en: 2c7c84f8fa1e8745
zh: 5d0244e4f17ba12a
/agent-note-web-provider-selection-independent-menus-over-registered-providers/alternatives-considered:
en: 0c227e270894cd71
zh: 114484a3e69f5c0a
/agent-note-web-provider-selection-independent-menus-over-registered-providers/consequences:
en: 50f046bf81a7783d
zh: 5c0e27bdcaf9f12e
/agent-note-web-provider-selection-independent-menus-over-registered-providers/verification:
en: 26ade0695094e9bc
zh: 7b265c43b1886da5
@@ -0,0 +1,85 @@
# Agent Note: Web provider selection - independent menus over registered providers
Status: implemented
English | [中文](2026-10-29-web-provider-selection.zh.md)
## Problem
The web service selects search and fetch providers through two independent fields, `web.searchProvider` and `web.fetchProvider`. A settings control that writes both at once ties fetch billing and target access policy to the search choice, and a control that offers only the DeepSeek choices it knows cannot select a provider that another plugin registers. A picker over registered providers needs the Host to report them, and the [web capability seam](2026-06-24-web-capability-seam.md) has no availability observation for it to reuse. Messages search starts an auxiliary model request for each query, whereas the [Search API](../feature/2026-10-15-deepseek-search-fetch-api.md) returns structured sources directly. The Search and Fetch APIs also need a DeepSeek credential, and a credential setting of their own makes every user reconcile it with the model provider they chat with.
## Decision
Users choose search and fetch providers independently from the Host's registered provider lists. The shipped composition selects the Search API for search and local HTTP fetch for fetch, and the DeepSeek Search and Fetch APIs follow the conversation's DeepSeek credential.
### Registered providers are observable; availability is not
`WebSearchProvider` and `WebFetchProvider` carry an optional `title`, the provider's display name, and an optional localized `description` of what the provider suits and what it costs or cannot do. `ctx.web.listProviders()` returns `{ search, fetch }`, each a registration-ordered list of `{ id, title?, description? }` entries, and the `web` Remote namespace exposes it as `web/listProviders`. Every registration and disposal emits the payload-free `web/providers-changed` event after the registry changes; the web service contains listener failures, so an observer cannot veto a registry change. [dsh-api-remotes](../../../../packages/api/remotes/README.md) mounts the namespace and forwards the event, and Clients re-read the list after each notification and reconnect.
The lists include providers whose `available()` answers false. A picker shows what can be pinned, including a provider whose credential the user has not configured yet, whereas availability depends on per-request facts such as the Session's model route and credential state. Availability therefore stays an execution result: `search()` and `fetch()` resolve the configured provider at call time and throw the structured `WebError`, as the seam note records. The tool layer still never enumerates providers.
### Independent selection
The Web search and fetch page writes `web.searchProvider` from its Search menu and `web.fetchProvider` from its Fetch menu. Each menu lists every registered provider for its capability by `title` (or `id` when `title` is absent), so plugin providers appear without page changes, and a selected id that the Host does not register stays visible as such. Official providers register no `title`; the page names them from its own locale dictionary and lists them first in a fixed order: the Search API (`deepseek-search-api`) followed by Messages search (`deepseek-official`) for search, and local HTTP fetch (`http`) followed by the Fetch API (`deepseek-fetch-api`) for fetch. Other providers follow in Host registration order. Selecting the deployment's composed provider clears the stored override instead of storing a duplicate value. The [settings page README](../../../../packages/client/ui-settings-web-search/README.md) owns the page's controls.
Every registered provider is listed; a provider has no flag that keeps it out of the menus. This note supersedes the [Search and Fetch API decision](../feature/2026-10-15-deepseek-search-fetch-api.md)'s rejection of listing every registered provider in a settings dropdown. Independent menus must offer providers that other plugins register, and the display metadata that listing needs is only the optional `title` and `description`. The description lives on the registration rather than in the page's dictionaries so that official and plugin providers explain themselves through one field, and a new provider needs no page change to describe its trade-offs. The rejected generic editor still stands: the page edits only the DeepSeek providers' settings, and other providers keep their own settings pages.
The Search and Fetch APIs read one `web-deepseek-api` namespace, and each resolves its own credential and endpoint, as the next section records. Messages search reads `web-search-deepseek` for its independent endpoint and key reference.
### Credential resolution
Search and Fetch follow the DeepSeek credential of the conversation, and the settings page shows no credential control for them. The conversation's route is the `provider` of the initiating Session's latest request context, which account sign-out also reads; `ready()` uses the route of the model request being prepared, so readiness and execution resolve the same plan.
On the `deepseek-account` route, with an active, valid `@deepseek-ai/dsh-llm-deepseek-account` Loader entry at the entry root that the model provider directory lists, the operation sends only the account token that `ctx.deepseekAccount` releases for the dispatch endpoint under that route's origin with `/v1/tools`; an HTTP 401 invalidates that token. Every other operation, including one in an account conversation without a sign-in or a token, sends the key under the `apiKeyEnv` of the active `@deepseek-ai/dsh-llm-deepseek-api-key` entry for `deepseek-official`, resolved with that package's own resolver (including `DEEPSEEK_BASE_URL`), to the origin of that route's base URL with `/v1/tools`; without that entry, `DEEPSEEK_API_KEY` goes to `https://api.deepseek.com/v1/tools`. Sending the API key in a signed-out account conversation keeps web tools available there. A user with only `DEEPSEEK_API_KEY`, including every headless, SDK, and ACP run, therefore searches and fetches by default, and a gateway or internal deployment set through the model provider's base URL serves web access at the same origin. A missing key makes the capability not ready, and an operation fails with `WEB_PROVIDER_CREDENTIAL_MISSING` and guidance naming the Models page, the reference, and the account sign-in. A mounted route entry whose settings its resolver rejects is not treated as absent: an operation that needs that route is not ready and fails with `WEB_PROVIDER_MISCONFIGURED` without reading a credential or sending the request, so a broken gateway setting never redirects requests to the official endpoint.
A capability whose YAML sets a non-empty `searchBaseURL` or `searchApiKeyEnv` (`fetchBaseURL` or `fetchApiKeyEnv` for Fetch) instead always sends the key under its `*ApiKeyEnv` (`DEEPSEEK_API_KEY` when unset) to its `*BaseURL` (the official base when unset) and never the account token, so an intranet or third-party endpoint receives only the key chosen for it. Each capability has its own fields because a deployment can serve search and fetch from different gateways. These fields are YAML-only, and an empty value counts as unset. Stored values of fields outside the schema, including `baseURL`, `apiKeyEnv`, and `apiKey`, load without schema errors and have no effect.
The [credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts) of `@deepseek-ai/dsh-llm-deepseek` read and validate API keys, check key presence, and release account tokens for the DeepSeek model routes, Messages search, and the Search and Fetch APIs, so every DeepSeek request reads the credential store the same way; the account model route, Messages search, and the Search and Fetch APIs also invalidate the token that an HTTP 401 rejects. Shipped profiles need no per-profile credential override, because the API-key path works wherever `DEEPSEEK_API_KEY` or the DeepSeek API-key route has a key.
### Defaults and stored selections
The [base composition](../../../../packages/bundle/base/cordis.patch.yml) sets `searchProvider: deepseek-search-api` and `fetchProvider: http`. Search defaults to the Search API because it returns sources without Harness starting an auxiliary model request. Fetch defaults to local HTTP fetch because it has no per-request API charge, applies Harness's own URL validation and resource limits, and reaches intranet and local addresses. The Fetch API remains one menu choice away.
A stored selection, whether in the settings document or in a deployment's own composition, wins over the shipped default, and an upgrade does not rewrite it. A selection is never replaced at execution: when the selected provider is missing, unavailable, or fails, the operation fails with its structured error and does not fall back to another search or fetch provider. A fallback would send the query and credential to an endpoint the user did not choose and change billing without notice.
### Legacy Messages search
The DeepSeek Messages search provider remains available as **DeepSeek (legacy)**. A stored `web.searchProvider: deepseek-official` (or `DSH_WEB_SEARCH_PROVIDER=deepseek-official`) selects it, so users whose gateways serve Messages search but not `/v1/tools` can retain their working provider.
The Messages provider owns and writes `web/deepseek-search-llm-request`. The declaration and payload retain compatibility with released Sessions; the Search and Fetch API package neither declares nor writes this auxiliary event.
## Alternatives considered
**Keep one switch for both APIs.** It lets Fetch API adoption need no configuration edit, but it ties fetch billing and target access policy to the search choice, and it cannot express a provider that another plugin registers.
**Filter the lists by availability, or add availability to each entry.** A filtered list hides a provider while its credential is missing, right when the user needs to choose and configure it. Availability also depends on the request's model route and credential state, so a listed boolean could disagree with execution. The archived [observation-surface decision](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md) allows only the smallest signal a consumer reads; the menus read registration only.
**Keep a fixed provider catalog in the Client.** The Client would show ids the Host does not register as normal choices and would never show a provider that a plugin registers at runtime.
**Carry the provider list in the change event.** A payload-free event leaves one read path for initial load, notifications, and reconnects, so the Client never has to order an event payload against a concurrent read.
**Keep display names in the Client or in a separate Host registry.** The registering plugin knows its provider's name; a separate map would let a provider and its label register or dispose independently.
**Fall back to another provider when the selected one fails.** A fallback hides gateway and credential misconfiguration, sends the query to a second service, and changes what the request costs without the user choosing it.
**Remove Messages search and let a stored `deepseek-official` select the Search API.** Gateways can support Messages search without Search API routes. Retaining the legacy provider preserves that choice instead of changing its protocol and billing.
**Default fetch to the Fetch API as well.** It adds a per-request charge to every fetch and delegates page retrieval and target access policy to the service, which fetches from its own side rather than from the user's machine.
**Choose a credential source per capability on the settings page**, from a menu listing the configured DeepSeek model providers plus a custom key and base URL, without fallback between sources. The visible setting would state the whole rule, but the configuration surface is too large for users to understand: each capability adds a credential menu that the user must keep in step with the model provider they chat with, and a signed-in user chatting on the account route gets no web tools until they change it. Following the conversation's credential needs no setting, and the YAML-only own fields cover endpoints that differ from the model provider.
**Share one custom key and base URL between Search and Fetch.** It cannot express a deployment that serves the two APIs from different gateways.
**Fail an account conversation without a sign-in instead of sending the API key.** It would keep the account route from ever billing the API key, but signed-out account conversations would lose web tools even when a DeepSeek API key is configured.
**Send the account token whenever the user is signed in, whatever the conversation's route.** It would bill web access to the account during conversations on the API-key route or on another provider, which the user did not choose for them.
## Consequences
Users without a stored search selection search through the Search API: their queries go to the Search endpoint the conversation's DeepSeek credential selects, cost a Search API request each, and send up to 128 characters of the latest human prompt as `ori_question` unless `sendOriginalQuestion` is off. Deployments whose gateway has no `/v1/tools` routes get search failures until they select Messages search or set `searchBaseURL` to a base that serves them. A stored `deepseek-official` selection uses Messages search. Users without a stored fetch selection fetch through local HTTP fetch. A signed-in user without an API key has DeepSeek web search only in conversations on the account route; the [upgrade guide](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.md) lists each affected setup.
A plugin provider, including one written in creator mode, appears in the matching menu as soon as it registers, under its `title`. The menus can offer a provider that cannot run for the current request; the failure appears when a search or fetch runs. Each registry change costs one forwarded event and one list read in each Client that observes it.
## Verification
[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) covers list contents and order, titles, events after registration and disposal, no event for a rejected duplicate, and contained listener failures. [deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) covers the independent Messages provider, and [credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) covers, for both capabilities, the account token on the account route and its invalidation after HTTP 401, the API-key fallback without a sign-in, the route key and endpoint in official, non-DeepSeek, and Session-less conversations, `DEEPSEEK_API_KEY` and the official base without that route, YAML-only own fields that never send the token, independent capabilities, readiness matching each operation, and ignored removed fields; [composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) boots the real model routes through the Loader. [remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) covers event forwarding, and [base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) pins the shipped defaults. The settings page's controller and card specs and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover menu contents, independent Search and Fetch selection, the absence of credential controls for the DeepSeek APIs, and the settings each provider keeps.
@@ -0,0 +1,85 @@
# Agent Note: Web 提供方选择——基于已注册提供方的独立下拉框
Status: implemented
[English](2026-10-29-web-provider-selection.md) | 中文
## 问题
Web 服务通过 `web.searchProvider` 和 `web.fetchProvider` 两个独立字段分别选择搜索与抓取提供方。用一个设置控件同时写入两者,会把抓取的计费和目标访问策略绑在搜索选择上;只提供已知 DeepSeek 选项的控件,也无法选用其他插件注册的提供方。基于已注册提供方的选择器需要 Host 报告这些提供方,而 [Web 能力 seam](2026-06-24-web-capability-seam.zh.md) 没有可供复用的可用性观察接口。Messages 搜索每次查询都会发起一次辅助模型请求,而 [Search API](../feature/2026-10-15-deepseek-search-fetch-api.zh.md) 能直接返回结构化来源。Search 与 Fetch API 还需要 DeepSeek 凭据;为它们单独设置凭据,会让每个用户都要把它与自己对话所用的模型提供方对齐。
## 决策
用户从 Host 已注册的提供方列表中分别选择搜索和抓取提供方。内置组合将搜索设为 Search API,将抓取设为本地 HTTP 抓取;DeepSeek Search 与 Fetch API 跟随会话的 DeepSeek 凭据。
### 已注册的提供方可观察,可用性不可观察
`WebSearchProvider` 和 `WebFetchProvider` 带有可选的 `title`(提供方的显示名称)和可选的多语言 `description`(说明该提供方适合什么、有什么代价或做不到什么)。`ctx.web.listProviders()` 返回 `{ search, fetch }`,二者均为按注册顺序排列的 `{ id, title?, description? }` 列表,`web` Remote namespace 将其暴露为 `web/listProviders`。每次注册和注销都会在注册表变更后触发无载荷的 `web/providers-changed` 事件;Web 服务会隔离监听器异常,因此观察者无法阻断注册表变更。[dsh-api-remotes](../../../../packages/api/remotes/README.zh.md) 挂载该 namespace 并转发该事件,Client 在收到通知和重连后重新读取列表。
列表包含 `available()` 返回 false 的提供方。选择器展示的是可固定选用的项,包括用户尚未配置凭据的提供方;而可用性取决于会话的模型路由、凭据状态等单次请求条件。因此可用性仍是执行期结果:`search()` 和 `fetch()` 在调用时解析所配置的提供方并抛出结构化 `WebError`,与 seam 记录一致。工具层仍从不枚举提供方。
### 分别选择
「网页搜索与获取」页由 Search 下拉框写入 `web.searchProvider`,由 Fetch 下拉框写入 `web.fetchProvider`。每个下拉框以 `title`(缺省时用 `id`)列出该能力的所有已注册提供方,因此插件提供方无需修改页面即可出现,且 Host 未注册的已选 id 仍会照常显示。官方提供方不注册 `title`,由页面用自己的语言字典命名,并按固定顺序排在最前:搜索依次为 Search API(`deepseek-search-api`)与 Messages 搜索(`deepseek-official`),抓取依次为本地 HTTP 抓取(`http`)和 Fetch API(`deepseek-fetch-api`)。其他提供方按 Host 注册顺序排列。选择部署组合的默认提供方会清除已保存的覆盖值,而非冗余保存一份副本。[设置页 README](../../../../packages/client/ui-settings-web-search/README.zh.md) 负责页面控件说明。
所有已注册的提供方都会列出;提供方没有可以把自己排除在下拉框之外的标志。本 Note 取代了 [Search 与 Fetch API 决策](../feature/2026-10-15-deepseek-search-fetch-api.zh.md)对「在设置页用下拉框列出所有已注册的提供方」的否决。独立的下拉框必须提供其他插件注册的提供方,而列出提供方所需的展示元数据只是可选的 `title` 和 `description`。说明放在注册信息里而不是页面词典里,这样官方与插件提供方都通过同一个字段说明自己,新提供方无需修改页面即可说明其取舍。被否决的通用编辑表单仍然成立:该页面只编辑 DeepSeek 提供方的设置,其他提供方保留各自的设置页。
Search 与 Fetch API 读取同一个 `web-deepseek-api` namespace,并各自解析自己的凭据和端点,见下一节。Messages 搜索从 `web-search-deepseek` 读取独立的端点和密钥引用。
### 凭据解析
Search 与 Fetch 跟随会话的 DeepSeek 凭据,设置页不为它们显示凭据控件。会话的路由是发起会话最近一次请求上下文的 `provider`,账号退出登录读取的也是它;`ready()` 使用正在准备的模型请求的路由,因此就绪判断与执行解析出相同的方案。
在 `deepseek-account` 路由上,若模型提供方目录列出了入口根部活动且有效的 `@deepseek-ai/dsh-llm-deepseek-account` Loader 条目,操作只发送 `ctx.deepseekAccount` 为该路由 origin 加 `/v1/tools` 下的请求端点给出的账号令牌;HTTP 401 会作废该令牌。其他所有操作,包括账号会话中没有登录或没有令牌的操作,都把 `deepseek-official` 的活动 `@deepseek-ai/dsh-llm-deepseek-api-key` 条目的 `apiKeyEnv` 下的密钥(用该包自己的解析函数解析,包括 `DEEPSEEK_BASE_URL`)发往该路由基础地址的 origin 加 `/v1/tools`;没有该条目时,把 `DEEPSEEK_API_KEY` 发往 `https://api.deepseek.com/v1/tools`。未登录的账号会话发送 API 密钥,网页工具因此在其中仍然可用。因此只有 `DEEPSEEK_API_KEY` 的用户(包括所有 headless、SDK 与 ACP 运行)默认即可搜索和抓取,通过模型提供方基础地址设置的网关或内部部署也在同一 origin 上提供 Web 访问。缺少密钥时,该能力未就绪,操作以 `WEB_PROVIDER_CREDENTIAL_MISSING` 失败,指引信息点名模型页面、该引用和账号登录。已挂载、但其解析函数拒绝其设置的路由条目不视为不存在:需要该路由的操作未就绪,并以 `WEB_PROVIDER_MISCONFIGURED` 失败,不读取凭据、不发送请求,因此网关设置出错时请求不会被改发到官方端点。
在 YAML 中设置了非空 `searchBaseURL` 或 `searchApiKeyEnv`(Fetch 为 `fetchBaseURL` 或 `fetchApiKeyEnv`)的能力,则始终把其 `*ApiKeyEnv`(未设置时为 `DEEPSEEK_API_KEY`)下的密钥发往其 `*BaseURL`(未设置时为官方基础地址),从不发送账号令牌,因此内网或第三方端点只收到为它选择的密钥。每个能力有自己的字段,因为一个部署可以由不同网关提供搜索和抓取。这些字段只能在 YAML 中设置,空值视为未设置。schema 之外字段的已保存值,包括 `baseURL`、`apiKeyEnv` 和 `apiKey`,可以加载而不产生 schema 错误,且不生效。
`@deepseek-ai/dsh-llm-deepseek` 的[凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)为 DeepSeek 模型路由、Messages 搜索以及 Search 与 Fetch API 读取并校验 API 密钥、检查密钥是否存在、取得账号令牌,因此每个 DeepSeek 请求都以同一方式读取凭据库;账号模型路由、Messages 搜索以及 Search 与 Fetch API 还会作废被 HTTP 401 拒绝的令牌。内置配置不需要按 profile 覆盖凭据,因为只要 `DEEPSEEK_API_KEY` 或 DeepSeek API 密钥路由有密钥,API 密钥路径就能工作。
### 默认值与已保存的选择
[base 组合](../../../../packages/bundle/base/cordis.patch.yml)设置 `searchProvider: deepseek-search-api` 和 `fetchProvider: http`。搜索默认使用 Search API,因为它返回来源时无需 Harness 发起辅助模型请求。抓取默认使用本地 HTTP 抓取,因为它没有按次的 API 费用,执行 Harness 自身的 URL 校验和资源限制,并且可以访问内网和本地地址。Fetch API 仍可在下拉框中直接选择。
已保存的选择——无论位于设置文档还是部署自身的组合中——优先于内置默认值,升级不会改写它。执行时从不替换选择:所选提供方缺失、不可用或失败时,操作以结构化错误失败,不会回退到其他搜索或抓取提供方。回退会把查询和凭据发往用户未选择的端点,并在不提示的情况下改变计费。
### 旧版 Messages 搜索
DeepSeek Messages 搜索提供方仍以 **DeepSeek(旧版)**提供。已保存的 `web.searchProvider: deepseek-official`(或 `DSH_WEB_SEARCH_PROVIDER=deepseek-official`)会选中它,因此网关支持 Messages 搜索但不提供 `/v1/tools` 的用户可以保留可用的提供方。
Messages 提供方负责声明并写入 `web/deepseek-search-llm-request`。声明和载荷保持与已发布会话兼容;Search 与 Fetch API 包既不声明也不写入这一辅助事件。
## 考虑过的替代方案
**保留一个同时切换两个 API 的开关。** 它让使用 Fetch API 无需修改配置,但把抓取的计费和目标访问策略绑在搜索选择上,也无法表达其他插件注册的提供方。
**按可用性过滤列表,或在每个条目中加入可用性。** 过滤后的列表恰好会在凭据缺失时隐藏提供方,而这正是用户需要选择并配置它的时候。可用性还取决于请求的模型路由和凭据状态,列表中的布尔值可能与实际执行结果不一致。已归档的[观察面决策](../../archived/simplification/2026-07-04-drop-unconsumed-web-observation-surface.md)只允许引入消费方实际读取的最小信号;下拉框只读取注册情况。
**在 Client 中维护固定的提供方目录。** Client 会把 Host 未注册的 id 当作正常选项展示,且永远无法展示插件在运行时注册的提供方。
**在变更事件中携带提供方列表。** 无载荷事件让初次加载、变更通知和断线重连共用同一条读取路径,Client 无需为事件载荷与并发读取结果排序。
**把显示名称放在 Client 或单独的 Host 注册表中。** 注册插件最清楚自身提供方的名称;拆成独立映射会让提供方与其标签各自独立注册或注销。
**所选提供方失败时回退到其他提供方。** 回退会掩盖网关和凭据的配置错误,将查询发往第二个服务,并在未经用户选择的情况下改变请求的费用。
**移除 Messages 搜索,让已保存的 `deepseek-official` 选中 Search API。** 网关可能支持 Messages 搜索,却没有 Search API 路由。保留旧版提供方可以保留这一选择,而不改变其协议和计费。
**抓取也默认使用 Fetch API。** 它会给每次抓取增加按次费用,并把页面抓取和目标访问策略交给服务端,由服务端而不是用户的机器发起抓取。
**在设置页为每个能力选择一种凭据来源**,下拉框列出已配置的 DeepSeek 模型提供方,外加自定义密钥和基础地址,来源之间不回退。这样可见的设置就能说明全部规则,但配置面过大,用户难以理解:每个能力都多出一个凭据下拉框,用户必须让它与自己对话所用的模型提供方保持一致;在账号路由上对话的已登录用户在修改设置前没有网页工具。跟随会话的凭据不需要任何设置,与模型提供方不同的端点由只能在 YAML 中设置的单独字段覆盖。
**Search 与 Fetch 共用一个自定义密钥和基础地址。** 它无法表达由不同网关分别提供两个 API 的部署。
**账号会话未登录时直接失败,而不是发送 API 密钥。** 这样账号路由永远不会消耗 API 密钥,但即使配置了 DeepSeek API 密钥,未登录的账号会话也会失去网页工具。
**只要用户已登录就发送账号令牌,不论会话的路由。** 这会让在 API 密钥路由或其他提供方上进行的会话把 Web 访问记到账号上,而用户并没有为这些会话这样选择。
## 影响
没有已保存搜索选择的用户通过 Search API 搜索:查询发往会话的 DeepSeek 凭据所选的 Search 端点,每次计为一次 Search API 请求,并且除非关闭 `sendOriginalQuestion`,还会把最近一条用户提示的至多 128 个字符作为 `ori_question` 发送。网关没有 `/v1/tools` 路由的部署会遇到搜索失败,直到选择 Messages 搜索,或把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `deepseek-official` 选择使用 Messages 搜索。没有已保存抓取选择的用户通过本地 HTTP 抓取。已登录但没有 API 密钥的用户只在账号路由上的会话中可以使用 DeepSeek 网页搜索;[升级指南](../../../../docs/upgrade-guide/v0.2.2-alpha.2/web-search-credential-modes/guide.zh.md)列出了每种受影响的配置。
插件提供方(包括在创作模式下编写的提供方)一经注册,就会以其 `title` 出现在对应的下拉框中。下拉框可能提供在当前请求中无法运行的提供方;失败会在执行搜索或抓取时出现。每次注册表变更会在每个观察它的 Client 中产生一次转发事件和一次列表读取。
## 验证
[web.spec.ts](../../../../packages/web/web/tests/web.spec.ts) 覆盖列表内容与顺序、标题、注册与撤销后的事件、重复注册被拒绝时不触发事件,以及监听器失败的隔离。[deepseek.spec.ts](../../../../packages/web/web-search-deepseek/tests/deepseek.spec.ts) 覆盖独立的 Messages 提供方,[credential-resolution.spec.ts](../../../../packages/web/web-deepseek-api/tests/credential-resolution.spec.ts) 针对两个能力覆盖账号路由上的账号令牌及其在 HTTP 401 后被作废、未登录时回退到 API 密钥、官方、非 DeepSeek 和会话之外的操作使用路由密钥与端点、没有该路由时使用 `DEEPSEEK_API_KEY` 与官方基础地址、从不发送令牌的 YAML 单独字段、两个能力相互独立、就绪判断与每次操作一致,以及被忽略的已移除字段;[composition.spec.ts](../../../../packages/web/web-deepseek-api/tests/composition.spec.ts) 通过 Loader 启动真实的模型路由。[remote-events.host.spec.ts](../../../../packages/api/remotes/tests/remote-events.host.spec.ts) 覆盖事件转发,[base.spec.ts](../../../../packages/bundle/base/tests/base.spec.ts) 固定内置默认值。设置页的 controller 与 card 测试以及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖下拉框内容、Search 与 Fetch 的分别选择、DeepSeek API 不显示凭据控件,以及各提供方保留的设置。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-08-31-mcp-manager-plugin.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 .agents/notes/implemented/feature/2026-08-31-mcp-manager-plugin.md
/agent-note-settings-driven-mcp-server-manager:
en: 56c40f7db118247c
zh: 3b3c596983cd48e1
/agent-note-settings-driven-mcp-server-manager/problem:
en: 7115cbaefaa11d2d
zh: 23aff8797a651a36
/agent-note-settings-driven-mcp-server-manager/decision:
en: 39cf3ae7e8a5799f
zh: 7a26503da73e238f
/agent-note-settings-driven-mcp-server-manager/alternatives-considered:
en: daad4acd963926cc
zh: 152f3d35a4fe3309
/agent-note-settings-driven-mcp-server-manager/testing:
en: cbfd30400093ade5
zh: 018ad32e25c6c7fc
/agent-note-settings-driven-mcp-server-manager/consequences:
en: 2a11ba309122335a
zh: 663670e2bf3c5a37
@@ -0,0 +1,63 @@
# Agent Note: Settings-driven MCP server manager
Status: implemented
English | [中文](2026-08-31-mcp-manager-plugin.zh.md)
## Problem
One MCP server was one `dsh-mcp-client` row in `cordis.yml`. That is right for composition-pinned servers and wrong for the product goal of managing servers from a Web surface: adding, editing, testing, enabling, disabling, deleting, and recovering servers at runtime, with connection status visible per server. The client plugin exposed no status beyond log lines, activation inherited an unbounded SDK startup wait, and there was no way to test a configuration without mounting it. A survey of six peer agent products (Claude Code, Codex, OpenCode, Kimi Code, DeepSeek-Reasonix, Pi) converged on the same shape: a config registry, a management facade, and a connection-state machine with a status-to-action mapping, secrets never round-tripping to the surface.
Installed application plugins also bring their own servers. Each must add its server without editing the user's configuration or depending on another application, and disabling or uninstalling the plugin must withdraw the server's tools.
## Decision
`packages/mcp/mcp-manager` is a service plugin (`ctx.mcpManager`) that owns the volatile `servers` configuration and reconciles a fleet of ordinary `dsh-mcp-client` child instances; `packages/mcp/mcp-client` owns connection supervision, status, probing, and per-attempt startup budgets.
**Configuration is volatile Config, and CRUD needs no new API.** Settings projects the manager entry’s Config under its Loader id; ConfigEditor owns profile persistence, inheritance, and revision checks. Settings edits retain inherited servers, and resetting an inherited entry restores its base value. Direct profile patches replace the complete `servers` map. Loader commits the reference before notifying the manager’s existing reconcile queue.
**Plugins register servers as effects.** `register(name, entry)` adds one server for the calling plugin's lifetime and returns the effect disposer; the entry takes every configured field except `enabled` and passes the same validation. The plugin manager's existing enable, disable, and uninstall operations therefore decide when a plugin's server exists, and the registration's disposal awaits the instance's unmount so the tools are gone when the plugin's disposal settles. Registered and configured names share one namespace: a registration refuses a configured name, and a settings write refuses a registered name. Registrations stay out of Settings.
**A flat entry schema, validated cross-field.** The entry is flat with a `transport` discriminant, and write-time validation enforces the per-transport requirements and rejections (missing `command`/`url`, cross-transport fields, malformed templates) at the write — a schema-valid-but-unserviceable entry is never stored, and refusals name the offending field where a union's shape error would not.
**Children are plugin instances, not library calls.** Each enabled entry mounts `dsh-mcp-client` under the manager's fiber via `ctx.plugin()`. That reuses the tested activation/disposal semantics, makes `serverName` reservations collide correctly with statically composed instances (surfaced as a `failed`/`load` status with the actionable message), and lets manager disposal unwind every instance and its tools through ordinary fiber teardown.
**Whole-entry identity; `enabled` controls mounting.** A changed entry rebuilds the child instance; disabling or deleting it disposes the child. Reconciliation and `restart` serialize on one operation tail. Probes run independently, and configuration changes cancel affected probes before queued cleanup waits for them. The manager retains a failed cleanup record and refuses a replacement until the Host restarts, preventing overlapping server lifetimes.
**Status belongs to the child Fiber.** The supervisor publishes a cell read through `connectionStatusOf(fiber)`. It survives reloads of that Fiber and remains readable after disposal, which detaches observers. Fiber identity distinguishes instances across registration scopes without identity tokens in status payloads. The manager aggregates cells into `list()` and the `mcp-manager/status` event, absorbing equal statuses and remaining silent after disposal. Removed entries with incomplete cleanup remain listed with `configured: false`. The first reconcile waits for the manager’s own Fiber activation because strict service reads require an active Fiber.
**Templates keep secrets out of configuration.** Stored env and header values remain readable; a value that must stay hidden belongs in a template. The manager passes raw templates into the child Fiber. Opt-in mcp-client expansion resolves `${credential:REF}` from the current provider, whose local implementation also reads the inherited environment, so there is no separate environment scheme. Missing values leave non-strict clients waiting for credentials; referenced credential notifications can recover them. A changed value replaces the connection only after cleanup succeeds, while a same-value notification preserves the connection and retry budget. Diagnostics, tool-call failures, and `isError` results keep upstream text with every resolved value replaced by `[redacted]`, so the model can act on a server's error without seeing a secret the instance sent.
**mcp-client budgets and probe.** `startupTimeoutMs` is optional for standalone clients; the manager explicitly supplies a 30-second default. `probeServer(ctx, config, options)` connects, reads server identity, lists tool names, and closes without reserving a name or registering tools. Its own budget and configurable listing caps are independent of runtime startup. Probe success and transport closure are separate results, so successful discovery cannot hide incomplete cleanup.
**The manager is its own Remote surface.** `McpServerManager` is a Typert Remote service: `watch` streams the complete server list on subscription and after every status or entry change, and `restart` and `probe` call the Host operations. Configuration writes remain on the Settings Remote, and the manager exposes no write method, so a browser cannot define a server command.
OAuth is deliberately out of scope: the credential-record and authorization seams plus the MCP SDK's `OAuthClientProvider` are the intended home, and a `needs-auth` status state is reserved for that change.
## Alternatives considered
**Manage each server as a separate Loader entry.** Rejected: the manager needs one server list with status, restart, and probe. Settings and ConfigEditor already edit its dictionary with inheritance, validation, and revision checks; the runtime child instances need no separate persisted entries.
**Call `startConnection()` as a library instead of mounting child plugins.** Rejected: it forks the lifecycle from statically composed instances, loses the reservation collision check, and re-implements activation/disposal quiescence the plugin already proves under test.
**Let plugins write their entry into the manager's Settings namespace.** Rejected: bundle patches replace the whole `servers` map, so two plugins cannot each ship an entry; a plugin must discover the manager's namespace and revision; a settings write returns before reconciliation, so the plugin restarts the entry to wait and mounts it twice; and uninstalling the plugin leaves an enabled entry whose tools stay live.
**Forward `mcp-manager/status` to clients instead of streaming lists.** Rejected: the Remote event carrier forwards only `emit` events, which do not contain listener failures, and a client would read `list()` after each event to see removals and authorization state.
**Broadcast per-instance status as a Cordis event from mcp-client.** Rejected: preset-scoped instances can share names, requiring identity tokens in event payloads. A Fiber-owned handle identifies the instance directly; the manager supplies the single aggregated event.
**Poll status from the management surface.** Rejected: the Remote plane already forwards Host events; peers that shipped polling (Reasonix) or lacked a status event (OpenCode v1) treat it as a defect to fix.
**A capability seam (Definition/Provider/Consumer) for "MCP management".** Rejected: one implementation and no independently evolving roles; a seam without a second provider is structure without an owner.
## Testing
mcp-client unit tests cover status transitions, independent startup and probe budgets, template binding and value redaction, same-Fiber reloads, final readable status, observer containment, cancellation, and separate close and cleanup outcomes. Manager tests use real Cordis, profile-backed Settings, and an in-memory credential provider to cover configuration changes, plugin registrations and their name collisions, credential rotation, probe cancellation, the `watch` stream, failed-cleanup retention, and deletion or disposal during pending work. Real composition tests verify inherited servers, profile patches, persisted settings edits, and write-time validation. Keyless e2e exercises real stdio and HTTP servers, including probes and crash recovery. Both packages meet the per-file 100% coverage gate.
## Consequences
- Management operations use Settings for configured add/edit/delete/enable/disable, registrations for plugin-owned servers, `list()`, `watch`, and one event for status, and `probe` for testing. Reconnect and `restart` recover connection failures; incomplete cleanup requires a Host restart.
- Liveness relies on transport closure and request failures; a Streamable HTTP server that stalls without closing is noticed when a request fails.
- A rebuilt server’s tools remain unavailable until the replacement completes its initial sync. Replacement waits for confirmed transport closure and successful local cleanup.
- The manager governs the global plane only; preset-scoped and static instances stay outside its list.
- `startupTimeoutMs` bounds startup attempts, while notification-driven re-sync requests still ride the SDK's request default — recorded as an mcp-client limitation.
@@ -0,0 +1,63 @@
# Agent Note: Settings-driven MCP server manager
Status: implemented
[English](2026-08-31-mcp-manager-plugin.md) | 中文
## 问题
一台 MCP 服务器就是 `cordis.yml` 里的一条 `dsh-mcp-client` 行。这对组合钉死的服务器是对的,但与产品目标相悖:要从 Web 界面在运行时添加、修改、测试、启用、停用、删除并恢复服务器,且每台服务器的连接状态可见。客户端插件除日志外没有任何状态出口,激活继承了无界的 SDK 启动等待,也没有任何"不挂载就测试一份配置"的途径。对六个同类 agent 产品(Claude Code、Codex、OpenCode、Kimi Code、DeepSeek-Reasonix、Pi)的调研收敛到同一形态:配置注册表、管理门面、带"状态到动作"映射的连接状态机,密钥绝不回读到界面。
已安装的应用插件也会带来自己的服务器。每个插件都必须在不编辑用户配置、不依赖其他应用的前提下加入服务器,停用或卸载插件时还必须撤回该服务器的工具。
## 决策
`packages/mcp/mcp-manager` 是一个服务插件(`ctx.mcpManager`),拥有 volatile `servers` 配置并对一支普通 `dsh-mcp-client` 子实例编队做对账;`packages/mcp/mcp-client` 拥有连接监督、状态、探测与逐次启动预算。
**配置由 volatile Config 提供,CRUD 不需要新 API。** Settings 按管理器条目的 Loader id 展示其 Config;ConfigEditor 拥有 profile 持久化、继承和 revision 检查。Settings 编辑保留继承的服务器,重置继承条目会恢复其基层值。直接编写 profile patch 会替换完整 `servers` 映射。Loader 先提交引用,再通知管理器现有的协调队列。
**插件以副作用注册服务器。** `register(name, entry)` 为调用方插件的生命周期加入一台服务器,并返回该副作用的释放函数;条目接受除 `enabled` 外的全部配置字段,并通过相同校验。因此插件管理器已有的启用、停用和卸载操作决定插件的服务器何时存在;注册的释放会等待实例卸载,插件的释放完成时工具已撤回。注册名与配置名共用一个命名空间:注册拒绝已配置的名字,设置写入拒绝已注册的名字。注册不进入 Settings。
**扁平条目 schema,跨字段校验。** 条目是带 `transport` 判别字段的扁平结构,写入期校验在写入处强制逐传输要求与拒绝(缺 `command`/`url`、跨传输字段、模板写错)——schema 合法但不可执行的条目永远不会被存下,且拒绝会点名出错字段,union 的形状错误做不到。
**子实例是插件实例,不是库调用。** 每个启用条目经 `ctx.plugin()` 在管理器 fiber 下挂载 `dsh-mcp-client`。这复用了经过测试的激活/销毁语义,让 `serverName` 预留与静态组合实例正确冲突(以带可操作消息的 `failed`/`load` 状态浮出),并让管理器销毁经普通 fiber 收卷解除每个实例及其工具。
**整条目决定实例;`enabled` 控制挂载。** 条目变化会重建子实例,停用或删除条目会销毁子实例。对账与 `restart` 在同一操作队列中串行执行。探测独立运行,配置变更先取消受影响的探测,再由队列中的清理操作等待它们结束。管理器保留清理失败记录,并在 Host 重启前拒绝替换,防止服务器生命周期重叠。
**状态属于子 Fiber。** 监督器发布经 `connectionStatusOf(fiber)` 读取的状态单元。同一 Fiber 重载时保留该单元,最终销毁时移除观察者,但单元仍可读取。Fiber 身份直接区分不同注册作用域中的实例,无需在状态载荷中加入身份令牌。管理器聚合为 `list()` 和 `mcp-manager/status` 事件,吸收相等状态并在销毁后停止发出事件。配置已删除但清理未完成的条目仍以 `configured: false` 列出。首次对账等待管理器自身 Fiber 激活,因为严格服务读取要求 Fiber 已激活。
**模板让密钥不进入配置。** 存储的 env 和 header 值仍可读,必须隐藏的值应使用模板。管理器把原始模板传入子 Fiber。mcp-client 显式启用模板展开后,从当前 provider 解析 `${credential:REF}`;其本地实现也读取继承的环境变量,因此没有单独的环境变量模板。缺失值使非严格客户端等待凭据,相关凭据通知可恢复连接。值变化时,连接只有在清理成功后才会替换;同值通知保留当前连接和重试预算。诊断、工具调用失败与 `isError` 结果保留上游文本,其中每个已解析的值都替换为 `[redacted]`,模型因此能依据服务器的错误行动,又看不到实例发送过的密钥。
**mcp-client 预算与探测。** 独立客户端的 `startupTimeoutMs` 可省略,管理器明确提供 30 秒默认值。`probeServer(ctx, config, options)` 连接、读取服务器身份、列出工具名并关闭,不预留名称,也不注册工具。探测有独立于运行期启动的预算和可配置的列表上限。探测成功与传输关闭分别报告,因此发现成功不会掩盖清理未完成。
**管理器自身即 Remote 面。** `McpServerManager` 是 Typert Remote 服务:`watch` 在订阅时以及每次状态或条目变化后推送完整服务器列表,`restart` 与 `probe` 调用宿主操作。配置写入仍走 Settings Remote,管理器不提供写入方法,因此浏览器无法定义服务器命令。
OAuth 刻意不在本次范围内:凭据记录与 authorization seam 加 MCP SDK 的 `OAuthClientProvider` 是预期归宿,`needs-auth` 状态位为那次变更保留。
## 曾考虑的替代方案
**把每台服务器作为独立 Loader 条目管理。** 否决:管理器需要包含状态、restart 与 probe 的统一服务器列表。Settings 与 ConfigEditor 已经为其字典提供继承、校验和 revision 检查;运行期子实例不需要独立持久化条目。
**把 `startConnection()` 当库调用而不挂子插件。** 否决:这会让生命周期与静态组合实例分叉,丢掉预留冲突检查,还要重造插件已在测试下证明的激活/销毁静默。
**让插件把条目写进管理器的 Settings 命名空间。** 否决:bundle patch 会替换整个 `servers` 映射,两个插件无法各自带一个条目;插件需要反查管理器的命名空间与 revision;设置写入在对账前返回,插件只能重启条目来等待,导致重复挂载;卸载插件后还会留下仍启用的条目,其工具继续生效。
**把 `mcp-manager/status` 转发给客户端,而不是推送列表。** 否决:Remote 事件载体只转发 `emit` 事件,而 `emit` 不隔离监听器失败;客户端还需要在每次事件后读取 `list()` 才能看到移除与授权状态。
**让 mcp-client 以 Cordis 事件广播逐实例状态。** 否决:preset 作用域实例可以重名,迫使事件载荷携带身份令牌。Fiber 所有的句柄直接识别实例,管理器提供唯一的聚合事件。
**让管理界面轮询状态。** 否决:Remote 面已转发宿主事件;发货了轮询的同类产品(Reasonix)或缺状态事件的(OpenCode v1)都把它当作待修缺陷。
**为「MCP 管理」开 capability seam(Definition/Provider/Consumer)。** 否决:只有一个实现、没有独立演化的角色;没有第二个 provider 的 seam 是无主的结构。
## 测试
mcp-client 单测覆盖状态迁移、独立的启动与探测预算、模板绑定与值替换、同一 Fiber 重载、销毁后可读状态、观察者异常遏制、取消,以及独立的关闭和清理结果。管理器测试使用真实 Cordis、profile 支持的 Settings 和内存 credential provider,覆盖配置变化、插件注册及其名字冲突、凭据轮换、探测取消、`watch` 流、清理失败保留,以及待完成工作期间的删除或销毁。真实组合测试验证继承服务器、profile patch、持久化设置编辑和写入期校验。无密钥 e2e 验证真实 stdio 和 HTTP 服务器,包括探测与崩溃恢复。两个包均满足逐文件 100% 覆盖率门禁。
## 后果
- 管理操作通过 Settings 对配置条目增/改/删/启/停,通过注册加入插件拥有的服务器,通过 `list()`、`watch` 和一条事件获取状态,通过 `probe` 测试。重连和 `restart` 恢复连接失败;清理未完成时需要重启 Host。
- 存活判断依赖传输关闭与请求失败;停滞但不关闭的 Streamable HTTP 服务器要等到请求失败时才会被发现。
- 被重建服务器的工具在替换连接完成首次同步前不可用。替换会等待传输确认关闭且本地清理成功。
- 管理器只治理全局面;preset 作用域与静态实例不在其列表内。
- `startupTimeoutMs` 只为启动尝试设界,通知驱动的重同步请求仍走 SDK 请求默认值——已记录为 mcp-client 的限制。
@@ -9,11 +9,11 @@
en: d1aad580a4db9872
zh: 1eb3bac59353291d
/agent-note-direct-deepseek-search-and-fetch-apis/decision:
en: e70efbb3aa9d53b6
zh: 1d25eae2461346df
en: 43298ccae4f7a6d4
zh: eb7ef46b6f290c15
/agent-note-direct-deepseek-search-and-fetch-apis/decision/protocol-selection-and-deployment:
en: ec653b59d62ed95f
zh: 0d675616059930ba
en: 90e5031d0d2fa9e4
zh: a9c0229b20555d9e
/agent-note-direct-deepseek-search-and-fetch-apis/decision/search-results-and-model-input:
en: ad7b3e4fbe528cf2
zh: 8ab8779bfdaaa4fa
@@ -21,17 +21,17 @@
en: 7566e400f9ab3a51
zh: 118b2e84d0030480
/agent-note-direct-deepseek-search-and-fetch-apis/decision/default-migration:
en: d5a8e36b545f1c04
zh: d741fd0789401c62
/agent-note-direct-deepseek-search-and-fetch-apis/decision/supplementary-messages-fix:
en: 738e32b488bbc1a1
zh: f33afe570f62599a
en: cb2f0910955b6730
zh: 7df057048acd7858
/agent-note-direct-deepseek-search-and-fetch-apis/decision/legacy-messages-search:
en: 0dcc97d8105fab60
zh: d80d40ae3a0c601d
/agent-note-direct-deepseek-search-and-fetch-apis/alternatives-considered:
en: 641cbfefc76f2f95
zh: e3e718473eace85f
en: 907e0b8424943d43
zh: f45e583e7cb4eb63
/agent-note-direct-deepseek-search-and-fetch-apis/consequences:
en: 124a35cad810c2fc
zh: 504da2a59c3873be
en: 7ec21c5b1124e5f8
zh: af769dbdb4face9f
/agent-note-direct-deepseek-search-and-fetch-apis/verification-and-remaining-api-adoption:
en: 4c84e7bd035b52a7
zh: 4e5687bda0a47706
en: 595a35ce727a7c82
zh: 10b2197663198015
@@ -10,15 +10,15 @@ DeepSeek web retrieval through Messages requires an auxiliary model request for
## Decision
The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools, and [web-search-deepseek](../../../../packages/web/web-search-deepseek/README.md) keeps Messages search. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields.
The [DeepSeek Search and Fetch guide](https://api-docs.deepseek.com/zh-cn/guides/search), checked on 2026-10-16, defines the upstream API. The [web-deepseek-api plugin](../../../../packages/web/web-deepseek-api/README.md) implements it behind the existing `web_search` and `web_fetch` tools. The [web capability decision](../architecture/2026-06-24-web-capability-seam.md) continues to own provider selection, tool responsibilities, and the fetch result fields, and the [web provider selection decision](../architecture/2026-10-29-web-provider-selection.md) owns how users choose each provider and which providers the shipped composition selects.
### Protocol selection and deployment
The APIs register as `deepseek-search-api` and `deepseek-fetch-api`; Messages search keeps `deepseek-official`. The setting **Use the new Web Search API** (**启用新版 Web 搜索 API**) on the Web search page writes `web.searchProvider` and `web.fetchProvider` together: on selects both APIs, and off restores Messages search with local HTTP fetch, the shipped selection. The switch defaults off and takes effect after saving, because the web service reads both selections for each operation. Messages remains the default until the Search API launch is announced, so existing users keep working search without configuration. Each operation resolves one settings snapshot, including its endpoint and credentials. Both APIs read one credential and one API base, because the guide serves them from one base and the switch selects them together.
The APIs register as `deepseek-search-api` and `deepseek-fetch-api`. Messages search remains separately registered as `deepseek-official`. The web service selects each provider through `web.searchProvider` and `web.fetchProvider`, which the Search and Fetch menus on the Web search and fetch page set independently; the shipped composition selects the Search API and local HTTP fetch, as the [selection decision](../architecture/2026-10-29-web-provider-selection.md) records. A saved selection takes effect on the next operation because the web service reads both selections per operation. Each operation resolves one settings snapshot, including its endpoint and credential. Both APIs read the `web-deepseek-api` namespace, and each capability resolves its own endpoint and credential.
The two protocols retain independent API bases. Messages keeps `https://api.deepseek.com/anthropic/v1` and appends `/messages`. Direct retrieval uses `web-deepseek-api.baseURL`, which defaults to the guide's public base `https://api.deepseek.com/v1/tools`, and appends `/search` or `/fetch`; the verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`. These prefixes are different and are never inferred from the hostname, so another deployment enters its complete base. The switch is already the explicit opt-in, so the documented public deployment serves as its default. A blank or unparseable base leaves both providers unavailable.
Direct retrieval appends `/search` or `/fetch` to an API base. A capability without its own YAML endpoint fields uses the origin of the conversation's DeepSeek model route base URL with `/v1/tools`, which for the public deployment is the guide's `https://api.deepseek.com/v1/tools`; the [selection decision](../architecture/2026-10-29-web-provider-selection.md) owns that derivation and the credential each operation sends. The verified development base is `http://api-dev.deepseek.com:8031/api/v1/tools`; its prefix differs and is never inferred from the hostname, so such a deployment sets the complete base in `searchBaseURL` or `fetchBaseURL`. An unparseable base leaves that capability's provider unavailable.
The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A Session on the DeepSeek account route sends its account token instead, as Messages search does; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users can enable direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments keep Messages search.
The guide specifies API-key Bearer authentication, and requests send only the documented authorization and JSON content type. A conversation on the `deepseek-account` route sends the account token instead; the public Search and Fetch routes accepted it on 2026-10-16, so account-only users have direct retrieval. An Anthropic-compatible gateway checked the same day served Messages web search and had no `/v1/tools` routes; such deployments can select Messages search or set the capability's own base URL to one that serves `/v1/tools`.
### Search results and model input
@@ -36,45 +36,41 @@ The HTTP provider retains its own URL validation and resource limits. Selecting
### Default migration
Changing the default to the Search API is a separate change after the launch is announced. API keys and account tokens both reach the public routes; deployments behind a gateway without `/v1/tools` routes need an explicit `web.searchProvider: deepseek-official` before the default changes, and a stored Messages selection keeps Messages.
The shipped composition selects `deepseek-search-api`; the [selection decision](../architecture/2026-10-29-web-provider-selection.md) owns that default and its no-fallback rule. API keys and account tokens both reach the public routes. Deployments behind a gateway without `/v1/tools` routes set `searchBaseURL` to a base that serves them. A stored `web.searchProvider: deepseek-official` selects Messages search, and any other stored selection keeps its provider.
### Supplementary Messages fix
### Legacy Messages search
The retained Messages path explicitly sends `thinking: { type: 'disabled' }` and records that same body before dispatch. This avoids reasoning work in an auxiliary request whose generated answer is discarded; it does not implement or control the new retrieval API. `tool_choice` remains omitted. The [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) owns compatibility for the optional logged field.
The independent `dsh-web-search-deepseek` provider sends Messages search requests when `deepseek-official` is selected, as the [selection decision](../architecture/2026-10-29-web-provider-selection.md) records. Its `web/deepseek-search-llm-request` Session event type, including the optional `thinking: { type: 'disabled' }` body field that the [persistence acknowledgement](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.md) covers, stays declared and written in `dsh-web-search-deepseek`, so released Sessions that contain the event still open. Messages search sends that field because Harness discards the generated answer and the auxiliary request needs no reasoning.
## Alternatives considered
**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request while retaining Messages as an explicit mode.
**Make the Search API the default now.** The launch is not announced, and some gateways expose only Messages; their users would lose working search.
**Keep Messages as the only search implementation.** It preserves auxiliary generation and response extraction even when callers need only sources. Direct Search removes that Harness model request.
**Reuse the Messages address or infer a route prefix.** The protocols and deployed prefixes differ. Separate bases preserve existing configurations and avoid sending a valid request to the wrong route.
**Require an explicit Search API base.** Enabling the switch is already an explicit choice, and an empty default would make every user copy the documented public address. Other deployments still override it.
**Require an explicit Search API base.** The Search API is the shipped search default, and an empty default would leave it unavailable until every user enters an address that the DeepSeek model route already determines. Other deployments still set their own base.
**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside `web-search-deepseek` cannot change fetch routing, so turning it on left `web_fetch` on HTTP unless the user also edited `web.fetchProvider`; it also tied the APIs' lifecycle to Messages search, which the default migration may retire. Separate registrations let the web service's existing selection express the switch, and each package can change or retire alone.
**Keep the APIs inside the Messages provider behind a provider-local switch.** A switch inside the Messages provider cannot change fetch routing, so `web_fetch` would stay on HTTP unless the user also edited `web.fetchProvider`; it also ties the APIs' lifecycle to Messages search. Separate registrations let the web service's existing selection choose each provider, without coupling either API to the Messages provider.
**Share one transport between Messages search and the new APIs.** Both resolve credentials through the same seams, `ctx.credentials` and `ctx.deepseekAccount`, so a shared module would save only the dispatch and failure-mapping code. It would make the Messages package depend on the new API package and tie their releases, while the default migration may retire Messages search. Each package keeps its own transport until then.
**Share one transport between Messages search and the new APIs.** A shared web transport would couple different request formats and failure mappings. Credential values instead come from the shared [`llm-deepseek` credential helpers](../../../../packages/llm/llm-deepseek/src/credential.ts); Messages search and the Search/Fetch API transports remain independent.
**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential, and the switch selects them together; separate packages would duplicate the settings a user edits once. Each operation lives in its own module, so either API can change without the other.
**Split Search and Fetch into separate packages.** The guide serves both APIs from one base with one credential; separate packages would duplicate settings that address one deployment. Independent selection does not require separate packages, because each API registers its own provider id. Each operation lives in its own module, so either API can change without the other.
**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. The page needs only the DeepSeek choice, and other providers keep their own settings pages.
**List every registered provider in a settings dropdown.** A generic provider form derives editors from Config schemas and needs host-side display metadata on the provider interfaces. This rejection stands for the generic form: the page edits only the DeepSeek providers' settings, and other providers keep their own settings pages. The [selection decision](../architecture/2026-10-29-web-provider-selection.md) supersedes it for the provider menus, which list every registered provider by its `title`.
**Let the model supply `ori_question` as a tool argument.** The API defines the field as the user's original question, which the latest human prompt already records. A tool argument would change the model-facing schema shared by every search provider and invite paraphrase.
**Keep HTTP fetch when the switch turns on.** The switch adopts the new DeepSeek retrieval APIs, and leaving fetch on HTTP made Fetch API adoption depend on a hand-edited `web.fetchProvider`. HTTP fetch, with its own access policy, remains the selection whenever the switch is off, and configuration can still select either fetch provider alone.
**Add an extraction tool or widen the fetch result.** The API retrieves one concrete web page as Markdown, which fits the existing fetch operation. Its success already implies a retrieved page, and the Markdown body carries the page heading, so widening the result would change every fetch consumer for little model-visible gain.
## Consequences
Retrieval no longer requires auxiliary model tokens in direct mode, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off.
Direct retrieval requires no auxiliary model tokens, but its results still consume the main model's context. One Messages web search through a gateway on 2026-10-16 consumed 8,154 input and 828 output tokens for a single server-side search. The guide prices Search and Fetch independently at CNY 5 per 1,000 successful requests with results; result count does not change a request's price. Multiple queries remain multiple requests. The stated Claude Code and Codex promotion does not establish a Harness exemption. Search API mode also sends up to 128 characters of the user's prompt to the search service with each query unless `sendOriginalQuestion` is off.
Search and Fetch each have an account-wide 5 QPS limit, independent of model concurrency and shared across that account's API keys. Fetch also has a target-domain limit. The tool accepts up to `searchMaxQueries` queries per call, 10 by default, and fails the batch if one query fails. The Search provider sends at most `searchRequestsPerSecond` requests, 5 by default, in any one-second window of one plugin instance, so the second five queries of a full batch start one second after the first. The pacing is not shared across processes, and the provider has no automatic retries or fallback; other processes or clients can exhaust the same account allowance.
## Verification and remaining API adoption
Provider tests cover request mapping, empty results, malformed responses, independent and default bases, account-token authentication, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the default base, and switching both selections. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day.
Provider tests cover request mapping, empty results, malformed responses, independent and derived bases, account-token authentication and its invalidation after HTTP 401, cancellation, and blank configuration. Settings tests and the [plugin configuration browser scenario](../../../../apps/web/tests/plugin-config.e2e.ts) cover staged saves, the result count, and independent Search and Fetch selection. The [web search replay](../../../../apps/web/tests/web-search-round.e2e.ts) and [Fetch Session replay](../../../../snapshots/session/deepseek-fetch/snapshot.yml) pin model-visible results. On 2026-10-16 the [real provider tests](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts) passed Search and Fetch against the public base, and a headless round on the shipped profile returned eight sources and fetched Markdown without an auxiliary request event. The account-token, gateway, and status observations above come from manual probes the same day.
The following capabilities remain outside the implemented integration:
@@ -10,15 +10,15 @@ Status: implemented
## 决策
2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它,[web-search-deepseek](../../../../packages/web/web-search-deepseek/README.zh.md) 保留 Messages 搜索。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段。
2026-10-16 核对的 [DeepSeek Search 与 Fetch 指南](https://api-docs.deepseek.com/zh-cn/guides/search)定义上游 API。[web-deepseek-api 插件](../../../../packages/web/web-deepseek-api/README.zh.md)通过现有 `web_search` 和 `web_fetch` 工具接入它。[Web 能力决策](../architecture/2026-06-24-web-capability-seam.zh.md)继续负责提供方选择、工具职责和抓取结果字段;[Web 提供方选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责用户如何选择各个提供方,以及内置组合选择哪些提供方。
### 协议选择与部署
两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索保留 `deepseek-official`。网页搜索页上的设置 **Use the new Web Search API**(**启用新版 Web 搜索 API**)同时写入 `web.searchProvider` 和 `web.fetchProvider`:开启时选择两个 API,关闭时恢复内置选择,即 Messages 搜索与本地 HTTP 抓取。开关默认关闭,保存后生效,因为 Web 服务在每次操作时读取两项选择。在 Search API 正式发布公告之前,Messages 仍是默认值,已有用户无需配置即可继续使用搜索。每次操作解析一份设置快照,包含端点和凭据。两个 API 读取同一份凭据和同一个 API 基础地址,因为指南从同一基础地址提供它们,开关也同时选择它们。
两个 API 注册为 `deepseek-search-api` 和 `deepseek-fetch-api`;Messages 搜索仍独立注册为 `deepseek-official`。Web 服务通过 `web.searchProvider` 和 `web.fetchProvider` 选择各个提供方,「网页搜索与获取」页上的 Search 和 Fetch 下拉框分别设置这两项;内置组合选择 Search API 与本地 HTTP 抓取,见[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)。已保存的选择从下一次操作起生效,因为 Web 服务在每次操作时读取两项选择。每次操作解析一份包含端点和凭据的设置快照。两个 API 读取 `web-deepseek-api` namespace,每个能力各自解析端点与凭据。
两种协议分别保存 API 基础地址。Messages 保留 `https://api.deepseek.com/anthropic/v1`,追加 `/messages`。直接检索使用 `web-deepseek-api.baseURL`,默认值为指南中的公网地址 `https://api.deepseek.com/v1/tools`,追加 `/search` 或 `/fetch`;已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`。两者前缀不同,程序不会根据主机名推断,其他部署需填写完整基础地址。开关本身已是显式启用,因此以文档中的公网部署作为默认值。基础地址为空白或无法解析时,两个提供方都不可用。
直接检索在 API 基础地址后追加 `/search` 或 `/fetch`。没有在 YAML 中单独设置端点字段的能力,使用会话的 DeepSeek 模型路由基础地址的 origin 加 `/v1/tools`,公网部署即指南中的 `https://api.deepseek.com/v1/tools`;该推导及每次操作发送的凭据由[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责。已验证的开发地址为 `http://api-dev.deepseek.com:8031/api/v1/tools`;其前缀不同,程序不会根据主机名推断,此类部署需在 `searchBaseURL` 或 `fetchBaseURL` 中填写完整基础地址。基础地址无法解析时,该能力的提供方不可用。
指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。DeepSeek 账号路由上的会话改为发送其账号 token,与 Messages 搜索一致;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也可以启用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署继续使用 Messages 搜索。
指南规定使用 API 密钥进行 Bearer 鉴权,请求只发送文档规定的鉴权与 JSON 内容类型请求头。`deepseek-account` 路由上的会话改为发送账号 token;2026-10-16 公网 Search 与 Fetch 路由已接受该 token,因此只登录账号的用户也能使用直接检索。同日检查的一个 Anthropic 兼容网关提供 Messages 网页搜索,但没有 `/v1/tools` 路由;此类部署可以选择 Messages 搜索,或把该能力的单独基础地址设为提供 `/v1/tools` 的地址。
### 搜索结果与模型输入
@@ -36,45 +36,41 @@ HTTP 提供方继续负责自身的 URL 校验和资源限制。选择 DeepSeek
### 默认值迁移
将默认值改为 Search API 是发布公告之后的另一项变更。API 密钥和账号 token 都能访问公网路由;位于没有 `/v1/tools` 路由的网关之后的部署,需要在默认值变更前显式设置 `web.searchProvider: deepseek-official`,已保存的 Messages 选择仍使用 Messages。
内置组合选择 `deepseek-search-api`;[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)负责这项默认值及其不回退规则。API 密钥和账号 token 都能访问公网路由。位于没有 `/v1/tools` 路由的网关之后的部署,需把 `searchBaseURL` 设为提供这些路由的基础地址。已保存的 `web.searchProvider: deepseek-official` 会选中 Messages 搜索,其他已保存的选择保持其提供方。
### 附带的 Messages 修复
### 旧版 Messages 搜索
保留的 Messages 路径显式发送 `thinking: { type: 'disabled' }`,并在发送前记录同一请求体。这避免了辅助请求在生成回答被丢弃的情况下执行思考;它不实现或控制新的检索 API。`tool_choice` 仍不传递。[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)负责日志中可选字段的兼容性。
选中 `deepseek-official` 时,独立的 `dsh-web-search-deepseek` 提供方发送 Messages 搜索请求,见[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)。它的 `web/deepseek-search-llm-request` 会话事件类型(包括[持久化确认记录](../../../../docs/persistence-changes/2026-10-13-deepseek-search-thinking.zh.md)覆盖的可选请求体字段 `thinking: { type: 'disabled' }`)仍由 `dsh-web-search-deepseek` 声明并写入,因此包含该事件的已发布会话仍能打开。Messages 搜索发送该字段,是因为 Harness 丢弃生成的回答,辅助请求无需推理。
## 考虑过的替代方案
**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求,同时保留 Messages 供显式选择。
**立即将 Search API 设为默认值。** 尚未发布公告,部分网关也只提供 Messages;这些用户会失去可用的搜索。
**仅保留 Messages 搜索实现。** 即使调用方只需要来源,这也保留了辅助生成和响应提取。直接 Search 去掉这次 Harness 模型请求。
**复用 Messages 地址或推断路由前缀。** 协议与部署前缀不同。分别保存基础地址可保留现有配置,避免把有效请求发往错误路由。
**要求显式填写 Search API 基础地址。** 开启开关已是显式选择,空默认值会迫使每位用户复制文档中的公网地址。其他部署仍可覆盖它。
**要求显式填写 Search API 基础地址。** Search API 是内置的搜索默认值,空默认值会让它在每位用户填写一个 DeepSeek 模型路由已经确定的地址之前都不可用。其他部署仍可设置自己的基础地址。
**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** `web-search-deepseek` 内的开关无法改变抓取路由,因此开启后除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍走 HTTP;它还把两个 API 的生命周期绑在 Messages 搜索上,而默认值迁移后 Messages 搜索可能被移除。独立注册让 Web 服务现有的选择来表达这个开关,每个包都可以单独修改或移除。
**把两个 API 放在 Messages 提供方内部,由提供方自己的开关切换。** Messages 提供方内的开关无法改变抓取路由,因此除非用户另外修改 `web.fetchProvider`,`web_fetch` 仍会走 HTTP;它还会把两个 API 的生命周期绑在 Messages 搜索上。独立注册让 Web 服务通过现有的提供方选择机制选择各个提供方,不会把任一 API 与 Messages 提供方绑定。
**让 Messages 搜索与新 API 共用一套传输层。** 两者都通过同样的 seam,即 `ctx.credentials` 和 `ctx.deepseekAccount` 解析凭据,共享模块只能省下请求派发与失败映射代码。它会让 Messages 包依赖新 API 包、两者必须一起发版,而默认值迁移后 Messages 搜索可能被移除。在此之前,两个包各自保留传输层。
**让 Messages 搜索与新 API 共用一套传输层。** 共享 Web 传输层会耦合不同的请求格式和失败映射。凭据值改为通过共享的 [`llm-deepseek` 凭据函数](../../../../packages/llm/llm-deepseek/src/credential.ts)获取;Messages 搜索与 Search/Fetch API 传输层保持独立。
**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API,开关也同时选择它们;拆成两个包会让用户只需编辑一次的设置重复出现。每种操作位于独立模块,任一 API 都可以单独修改。
**把 Search 和 Fetch 拆成两个包。** 指南从同一基础地址、以同一份凭据提供两个 API;拆成两个包会让指向同一部署的设置重复出现。分别选择不需要拆包,因为每个 API 注册自己的提供方 id。每种操作位于独立模块,任一 API 都可以单独修改。
**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。该页面只需要 DeepSeek 的选择,其他提供方保留各自的设置页。
**在设置页用下拉框列出所有已注册的提供方。** 通用提供方表单从 Config schema 推导编辑控件,还需要在提供方接口上增加宿主侧展示元数据。这一否决对通用表单仍然成立:该页面只编辑 DeepSeek 提供方的设置,其他提供方保留各自的设置页。对于提供方下拉框,[选择决策](../architecture/2026-10-29-web-provider-selection.zh.md)取代了这一否决,下拉框按 `title` 列出所有已注册的提供方。
**让模型以工具参数提供 `ori_question`。** API 将该字段定义为用户的原始问题,而最近一条用户提示已记录了这段文本。工具参数会改变所有搜索提供方共用的面向模型的 schema,并引入改写。
**开启开关时保留 HTTP 抓取。** 该开关用于启用 DeepSeek 新的检索 API,保留 HTTP 抓取会让使用 Fetch API 依赖手动修改 `web.fetchProvider`。开关关闭时,抓取仍选择带有自身访问策略的 HTTP 提供方,配置中也仍可单独选择任一抓取提供方。
**新增提取工具或扩展抓取结果。** API 以 Markdown 获取一个具体网页,符合现有抓取操作。其成功本身即表示已获取页面,Markdown 正文也带有页面标题,因此扩展结果会改动每个抓取消费方,而模型可见的收益很小。
## 影响
直接模式中的检索不再需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。
直接检索不需要辅助模型 token,但结果仍消耗主模型上下文。2026-10-16 通过网关进行的一次 Messages 网页搜索,为一次服务端搜索消耗了 8,154 个输入 token 和 828 个输出 token。指南对 Search 和 Fetch 分别按每 1,000 次成功且有结果的请求收取 5 元;结果条数不影响单次价格。多个查询仍对应多次请求。文档中的 Claude Code 与 Codex 优惠不能作为 Harness 免收费用的依据。除非关闭 `sendOriginalQuestion`,Search API 模式还会随每个查询把用户提示的至多 128 个字符发送给搜索服务。
Search 和 Fetch 各有账号级 5 QPS 限制,独立于模型并发,同一账号的 API 密钥共享额度。Fetch 还有目标域名限制。工具每次调用最多接受 `searchMaxQueries` 个查询,默认 10 个;任一查询失败会使整批失败。Search 提供方在单个插件实例的任意一秒窗口内最多发送 `searchRequestsPerSecond` 个请求,默认 5 个,因此满批次的后五个查询比前五个晚一秒开始。该限速不跨进程共享,提供方也没有自动重试或回退;其他进程或客户端也会消耗同一账号的额度。
## 验证与尚未接入的 API 能力
提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与默认地址、账号 token 鉴权、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、默认地址和同时切换两项选择。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。
提供方测试覆盖请求映射、空结果、格式错误的响应、独立地址与推导地址、账号 token 鉴权及其在 HTTP 401 后被作废、取消与空白配置。设置测试及[插件配置浏览器场景](../../../../apps/web/tests/plugin-config.e2e.ts)覆盖暂存后保存、结果数,以及分别选择 Search 与 Fetch。[网页搜索回放](../../../../apps/web/tests/web-search-round.e2e.ts)与 [Fetch Session 回放](../../../../snapshots/session/deepseek-fetch/snapshot.yml)固定模型可见结果。2026-10-16,[真实提供方测试](../../../../packages/web/web-deepseek-api/tests/web-deepseek-api.e2e.ts)在公网地址通过了 Search 与 Fetch 验证;在内置配置上运行的一轮 headless 对话返回了八个来源并抓取到 Markdown,且没有辅助请求事件。上文的账号 token、网关和状态码观察来自同日的手动探测。
以下能力尚未包含在已实现的接入中:
+6
View File
@@ -73,6 +73,12 @@ jobs:
- name: Run blocking Windows builds
shell: pwsh
run: pnpm run check:ci:windows-blocking
- name: Verify Desktop updater dependency versions
shell: pwsh
run: |
node --test apps/desktop/tests/updates/electron-updater/versions.test.mjs
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
node apps/desktop/tests/updates/electron-updater/check-versions.mjs
- name: Parse Windows runner supervision scripts
shell: pwsh
run: |
+2 -2
View File
@@ -15,9 +15,9 @@ on:
default: test
required: true
update_channel:
description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道'
description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定'
type: choice
options: [nightly, latest]
options: [nightly, stable]
default: nightly
required: true
version_mode:
+2 -2
View File
@@ -15,9 +15,9 @@ on:
default: test
required: true
update_channel:
description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道'
description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定'
type: choice
options: [nightly, latest]
options: [nightly, stable]
default: nightly
required: true
version_mode:
+2 -2
View File
@@ -15,9 +15,9 @@ on:
default: test
required: true
update_channel:
description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道'
description: '升级通道:nightly 或 stable(须为稳定版本);默认通道由此打包输入确定'
type: choice
options: [nightly, latest]
options: [nightly, stable]
default: nightly
required: true
version_mode:
+3
View File
@@ -0,0 +1,3 @@
- id: agent-team
config:
defaultEnabled: true
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
"version": "0.2.2-alpha.2",
"version": "0.2.2-alpha.3",
"publishConfig": {
"access": "public"
},
+5 -5
View File
@@ -3,7 +3,7 @@
# After editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
/dsh-cli-behavior-reference:
en: 5a4f43756dae900e
en: abffba1bcc44698c
zh: d58c479481cfa5fe
/dsh-cli-behavior-reference/profile-boot:
en: b474a6fc3c98837d
@@ -24,8 +24,8 @@
en: 149c5571acd51baf
zh: 300e3c2425273a92
/dsh-cli-behavior-reference/shared-deployment-behavior:
en: f40edcf903a51f3f
zh: 8a9a915451047c8b
en: f02219118dcec531
zh: fe2977628d7340da
/dsh-cli-behavior-reference/source-execution:
en: 05c2b23790f7b296
zh: 51f809020fb230a1
en: 0fd6c72ba9a7eb3b
zh: 011c0ac4008ee480
+4 -2
View File
@@ -6,6 +6,8 @@ This reference defines the profile, plugin-management, and config-dump command m
The [generated command help](../../../docs/cli-help.md) contains complete launcher, plugin-management, and shipped-profile help. Run `pnpm run gen-cli-help` to refresh it; `pnpm run verify-cli-help` checks it against the current CLI.
<a id="profile-boot"></a>
## Profile boot
`dsh <name>` abbreviates `dsh --profile <name>` and boots the profile at `$DSH_HOME/profiles/<name>`. The shorthand name must immediately follow `dsh`; `plugin` remains the plugin-management command, so boot a profile with that name using `dsh --profile plugin`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch <path>` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. The final YAML composition controls whether `dsh-hmr` watches configuration; without HMR, changes require restart. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit.
@@ -134,7 +136,7 @@ Web, headless, and SDK inherit native tool execution from the base bundle; use a
## Shared deployment behavior
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.
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. The DeepSeek Search and Fetch APIs follow the session's DeepSeek credentials; custom endpoints and keys can only be set in YAML through `web-deepseek-api.searchBaseURL` / `searchApiKeyEnv` and `fetchBaseURL` / `fetchApiKeyEnv` ([authentication](../../../packages/web/web-deepseek-api/README.md#authentication)). Legacy Messages search reads `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.
@@ -143,4 +145,4 @@ Install external plugin bundles through `dsh plugin --profile <name> add <packag
<a id="source-execution"></a>
## Source execution
From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh <args...>`. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment, and `runProfile` resolves the outbound proxy from that snapshot before any entry mounts, so `HTTP_PROXY`/`HTTPS_PROXY` (and a proxy declared in a `.env` layer) apply without any further flag. The source entry explicitly selects linked profile resolution so tsx workspace imports and profile plugins share module identities. The installed form launches the built `apps/cli/lib/bin.js` with runtime profile resolution without rebuilding the repository.
From the repository root, run `pnpm run build` separately after a fresh checkout and whenever artifacts need updating, then use `pnpm dsh <args...>`. The `package.json` script launches `apps/cli/src/bin.ts` with `node --import tsx/esm` without building and forwards every argument. Missing Typert host artifacts fail profile boot through module-resolution errors without a build instruction. Once those host artifacts exist, missing frontend or client-plugin bundles fail at startup with an instruction to run `pnpm run build`. The launcher does not check freshness, so existing stale bundles can run older browser code until rebuilt. The process inherits the launch environment, and `runProfile` resolves the outbound proxy from that snapshot before any entry mounts, so `HTTP_PROXY`/`HTTPS_PROXY` (and a proxy declared in a `.env` layer) apply without any further flag. The source entry explicitly selects linked profile resolution so tsx workspace imports and profile plugins share module identities. On Windows, the installed command expands 8.3 paths before loading profile dependencies. The installed form launches the built `apps/cli/lib/bin.js` with runtime profile resolution without rebuilding the repository.
+2 -2
View File
@@ -136,7 +136,7 @@ Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC ov
## 共享部署行为
基础组合包挂载原生 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 适配器、settings 与凭据提供方、稳定的 `web_search` 和 `web_fetch`、HTTP fetch 提供方,默认开启的 DeepSeek 会话日志上传,以及面向所有用户的反馈门控 OTel 上传。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。DeepSeek Search 与 Fetch API 跟随会话的 DeepSeek 凭据;自定义 endpoint 和密钥只能在 YAML 中通过 `web-deepseek-api.searchBaseURL` / `searchApiKeyEnv` 和 `fetchBaseURL` / `fetchApiKeyEnv` 设置([鉴权](../../../packages/web/web-deepseek-api/README.zh.md#authentication))。旧版 Messages 搜索读取 `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 贡献。两条路径都不改变模型输入,但导出可能包含消息文本、工具参数和结果,以及工作区路径。
@@ -145,4 +145,4 @@ Web、headless 与 SDK 继承 base bundle 的原生工具执行;使用 [PTC ov
<a id="source-execution"></a>
## 源码执行
请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh <args...>`。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境,且 `runProfile` 会在任何 entry 挂载之前从该快照解析出站代理,因此 `HTTP_PROXY`/`HTTPS_PROXY`(以及写在 `.env` 层中的代理)无需任何额外开关即可生效。源码入口显式选择链接式 profile 解析,使 tsx workspace 导入与 profile 插件共享模块身份。安装形式会使用 runtime profile 解析直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。
请在仓库根目录中,于全新 checkout 之后及产物需要更新时单独运行 `pnpm run build`,然后使用 `pnpm dsh <args...>`。`package.json` 中的脚本不会构建,而是通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。Typert Host 产物缺失时,profile 启动会因不含构建指引的模块解析错误而失败。这些 Host 产物存在后,如果前端或 Client plugin 组合包缺失,启动会失败并提示运行 `pnpm run build`。启动器不会检查产物是否为最新,因此已有的陈旧组合包可能继续运行旧版浏览器代码,直至重新构建。该进程会继承启动环境,且 `runProfile` 会在任何 entry 挂载之前从该快照解析出站代理,因此 `HTTP_PROXY`/`HTTPS_PROXY`(以及写在 `.env` 层中的代理)无需任何额外开关即可生效。源码入口显式选择链接式 profile 解析,使 tsx workspace 导入与 profile 插件共享模块身份。在 Windows 上,安装版命令在加载 profile 依赖前展开 8.3 短路径。安装形式会使用 runtime profile 解析直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。
+28
View File
@@ -0,0 +1,28 @@
#!/usr/bin/env node
/**
* Installed CLI entry for disk launchers and embedded runtimes.
* @module @deepseek-ai/dsh/installed-bin
*/
import { realpathSync } from 'node:fs'
import { pathToFileURL } from 'node:url'
import type { RunCliOptions } from './bin.ts'
/**
* Run the CLI in the importing carrier's module tree.
* @param options - Installation-owned runtime options.
* @returns Settlement of the selected command.
*/
export async function runCli(options?: RunCliOptions): Promise<void> {
const cli = await import('./bin.ts')
await cli.runCli(options)
}
if (import.meta.main) {
// Windows ESM can retain an 8.3 spelling while profile resolution expands it.
const entry = process.platform === 'win32' ? pathToFileURL(realpathSync.native(import.meta.filename)).href : import.meta.url
if (entry === import.meta.url) await runCli()
else {
const canonical = await import(entry) as typeof import('./installed-bin.ts')
await canonical.runCli()
}
}
+14
View File
@@ -344,6 +344,20 @@ function startStartupProfile(fixture: StartupFixture, args: readonly string[]) {
describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => {
testProfileResolution('lib')
it.runIf(process.platform === 'win32')('resolves the installed entry before loading CLI dependencies', async () => {
const home = mkdtempSync(join(tmpdir(), 'dsh-cli-alias-'))
try {
const alias = join(home, 'launcher')
symlinkSync(join(repoRoot, 'apps/cli/lib'), alias, 'junction')
const { stdout } = await execa(process.execPath, [
'--preserve-symlinks-main', join(alias, 'bin.js'), '--version',
], { timeout: SPAWN_TIMEOUT_MS, env: { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1' } })
expect(stdout).toBe(cliVersion)
} finally {
rmSync(home, { recursive: true, force: true })
}
}, SPAWN_TIMEOUT_MS + 5_000)
it('requires a profile and rejects removed flags', async () => {
const bare = await runBuiltBin()
expect(bare.code).toBe(1)
+14 -1
View File
@@ -1,4 +1,4 @@
/** MCP resource ownership across the resolved shipped profile templates. */
/** MCP resource and server-manager ownership across the resolved shipped profile templates. */
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
@@ -41,4 +41,17 @@ describe('shipped MCP resource composition', () => {
rmSync(home, { recursive: true, force: true })
}
})
it.each(Object.keys(PROFILE_TEMPLATES))('%s mounts the server manager only with the Web application', (name) => {
const home = mkdtempSync(join(tmpdir(), 'dsh-profile-mcp-'))
try {
const profile = loadProfile('dsh', name, installAnchor, home)
const rows = composeEntries([...profile.layers.map(layer => layer.patches), profile.patches])
expect(rows.filter(row => row.name === '@deepseek-ai/dsh-mcp-manager')).toEqual(name === 'web'
? [{ id: 'mcp-manager', name: '@deepseek-ai/dsh-mcp-manager', inject: ['credentials'] }]
: [])
} finally {
rmSync(home, { recursive: true, force: true })
}
})
})
@@ -42,8 +42,14 @@
mode: !!js process.env.DSH_PERMISSION_MODE ?? 'workspace-write'
workspaceRoot: !!js process.cwd()
# A search key reference no runner sets: the turn runs as a deployment without
# web search credentials, whatever the launching environment exports.
# Own key references no runner sets on the DeepSeek Search and Fetch APIs: the
# turn runs as a deployment without web search credentials, whatever
# DEEPSEEK_API_KEY the launching environment exports.
- id: web-deepseek-api
config:
searchApiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY
fetchApiKeyEnv: DSH_KEYLESS_SMOKE_FETCH_KEY
- id: web-search-deepseek
config:
apiKeyEnv: DSH_KEYLESS_SMOKE_SEARCH_KEY
@@ -61,7 +61,7 @@ describe('headless-agent keyless smoke', () => {
expect(String(result?.['output'])).toContain('CLI_TOOL_ROUND_TRIP')
expect(persistedHeader).toMatchObject({ type: 'session' })
expect(persistedToolNames).toEqual(expect.arrayContaining(['read', 'write', 'edit', 'web_fetch']))
// Search has no key in this composition, so the model request does not offer it.
// Neither DeepSeek search provider has a key in this composition, so the model request does not offer search.
expect(persistedToolNames).not.toContain('web_search')
expect(persistedToolNames).not.toContain('str_replace_editor')
}, PRODUCTION_PROFILE_TEST_TIMEOUT_MS)
+1 -1
View File
@@ -5,7 +5,7 @@ import { defineConfig } from 'tsdown'
* Declarations come from `tsc -b` (dts: false), matching every package.
*/
export default defineConfig({
entry: ['lib/types/bin.js', 'lib/types/profile-boot.js'],
entry: { bin: 'lib/types/installed-bin.js', 'profile-boot': 'lib/types/profile-boot.js' },
outDir: 'lib',
format: ['esm'],
platform: 'node',
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop-host",
"description": "Private Node-mode host process for the Electron desktop application",
"version": "0.2.2-alpha.2",
"version": "0.2.2-alpha.3",
"private": true,
"license": "MIT",
"type": "module",
+2 -2
View File
@@ -60,8 +60,8 @@
en: 67bf0a18faa7aa76
zh: 782e14ce62bb939f
/deepseek-harness-desktop/updates/local-desktop-settings:
en: 2e57457c44c85b5f
zh: 5b8510a3dc4c6c9d
en: 01461be5b81e8609
zh: 58e818df821a3aa2
/deepseek-harness-desktop/updates/mandatory-update-policy:
en: 182a9c6705213e13
zh: 681762f83490f4c6
+11 -1
View File
@@ -302,7 +302,17 @@ On first launch, including after an upgrade, Desktop creates this document if it
}
```
Existing documents are never overwritten; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-uninstall-rules).
Startup loading never overwrites existing documents; an omitted `updates` object or field resolves to `false`. Set the field to `true` to permit test authentication dialogs, then fully quit and restart the application. Set it back to `false` and restart to suppress them again. Creation, read, or validation failures use the default `false` without blocking startup. Desktop attempts to warn through the Electron console with the file path; unavailable logging does not block startup. Correct the file and restart to apply an explicit setting. Unknown fields are preserved. Installation updates retain this user-data file; Windows uninstallation removes it under the [uninstall rules](#windows-uninstall-rules).
In Desktop **Settings → General → Update channel**, **Stable** selects `stable` and **Development** selects `nightly`. The main process saves `updates.channel` in this same file and applies the choice immediately through IPC; no Host settings service is involved. An omitted channel uses the build-injected package default. External edits and deletion are read only on the next launch; no file watcher runs. The authentication-popup setting remains startup-only. UI writes retain unrelated fields from the latest valid document; external editors must not write concurrently with a UI save.
During a UI switch, the selector shows the pending choice with a loading indicator. Failure restores the actual runtime selection and shows a short toast; ordinary save failures keep the current channel and update target accessible. Invalid startup settings or failure to retire the previous update suspends update operations; without a mandatory policy block, the application remains usable. Startup settings errors show a non-blocking ten-second toast with a details action. The update indicator retains the suspension reason independently of download progress; open it for recovery guidance and a button to reveal the settings file. Fix the file, then choose **Reload settings** in the error details or restart; this explicit reload applies only update settings and never rewrites the file. The reload button shows a busy indicator for at least half a second and rejects repeated clicks. Invalid files keep updates paused. Selecting a valid channel in Settings can also recover an invalid channel; malformed JSON must be repaired first. A known mandatory-update block remains enforced; its configuration-error action opens the same file-location and reload dialog. Successful channel changes trigger a fresh mandatory-policy check, waiting for an older channel’s in-flight check if necessary. Successful switching retires pending checks, downloads and installation eligibility before checking the new feed, including when both feeds advertise the same version. Switching never downgrades or installs automatically. Installation preparation rejects UI switches; once `quitAndInstall()` hands off, file edits cannot cancel that installation.
The shared settings storage distinguishes missing files, read failures, malformed JSON, invalid document structure and invalid individual fields. Popup and channel fields are validated independently with Zod; an invalid popup field does not invalidate a valid channel, and vice versa. Reads never create files; startup initialization is explicit and shares the writer lock with saves. A save preserves unknown and unrelated invalid fields. Write diagnostics distinguish no write, uncertain completion and a committed write followed by lock-cleanup failure; the last case is not a rollback.
Once installation eligibility is revoked, obsolete-file removal failures do not block switching. They are flushed to `update-cleanup.jsonl` in Electron’s logs directory with the time, operation, path and filesystem error; inability to write that log falls back to stderr. Later download failures are reported independently and are not attributed to an earlier cleanup warning. Structured filesystem errors distinguish insufficient space, denied writes and busy files; the error dialog retains the current error code and supplied file paths in its details. Errors without structured evidence keep the generic download-failure guidance.
Updater adapter compatibility and platform version records are documented in [Electron updater compatibility](tests/updates/electron-updater/README.md).
<a id="mandatory-update-policy"></a>
+11 -1
View File
@@ -302,7 +302,17 @@ Electron 主进程在启动时读取一次 `app.getPath('userData')/desktop/sett
}
```
已有文件不会被覆盖;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-uninstall-rules)删除它。
启动读取不会覆盖已有文件;缺少 `updates` 对象或该字段时均按 `false` 处理。将字段设为 `true` 后完全退出并重启应用,即可允许测试鉴权弹窗。改回 `false` 并重启即可再次禁止。创建、读取或校验失败时使用默认值 `false`,不阻止启动。Desktop 尝试在 Electron 控制台记录包含文件路径的警告;日志不可用也不阻止启动。修正文件并重启后才会应用显式设置。未知字段会保留。安装更新保留此用户数据文件;Windows 卸载按[卸载规则](#windows-uninstall-rules)删除它。
在桌面版**设置 → 通用设置 → 更新渠道**中,**正式版**选择 `stable`,**开发版**选择 `nightly`。主进程在同一文件中保存 `updates.channel`,并通过 IPC 立即应用选择,不依赖 Host 设置服务。省略 channel 时使用打包注入的包内默认值。外部编辑和删除仅在下次启动读取,不运行文件监听器。鉴权弹窗设置仍须重启生效。UI 写入保留最新有效文件中的其他字段;外部编辑器不能与 UI 保存同时写入。
UI 切换期间,选择器显示待应用选项和加载指示;失败时恢复实际运行渠道,并显示简短 toast。普通保存失败不会阻止使用当前渠道及旧更新目标。启动配置无效或无法撤销旧更新安装资格会暂停更新操作;没有强更策略阻塞时,主应用仍可使用。启动配置错误会显示持续十秒、可查看详情的非阻塞提示。更新入口独立于下载进度保留暂停原因;打开后可查看恢复指引、配置路径并在系统文件管理器中定位文件。修正文件后,可在错误详情中选择**重新读取配置**或重启;显式重读只应用更新配置,不改写文件。重读按钮至少显示半秒加载指示,期间不接受重复点击;文件仍无效时继续暂停更新。在设置中选择有效渠道也可恢复无效渠道;JSON 损坏须先修复文件。已知强更阻塞继续生效;强更页面的配置异常入口可打开同一文件定位和重读对话框。渠道切换成功后立即重新检查强更策略;如旧渠道的检查仍在进行,则等待其完成后检查新渠道。切换成功时先收尾检查、下载并撤销安装资格,再检查新渠道;两个渠道版本号相同也遵循该规则。切换不降级,也不自动安装。安装准备期间拒绝 UI 切换;调用 `quitAndInstall()` 交接后,编辑文件不能取消本次安装。
共享设置存储区分文件缺失、读取失败、JSON 损坏、文档结构无效和单项字段无效。鉴权开关与渠道字段分别通过 Zod 校验;鉴权开关无效不会使有效渠道失效,反之亦然。读取不创建文件;启动初始化是显式操作,并与保存共用写锁。保存保留未知字段和无关的无效字段。写入诊断区分未写入、完成情况不确定,以及写入已提交但释放锁失败;最后一种情况不表示文件已回滚。
撤销安装资格后,旧文件删除失败不阻止渠道切换。失败时间、操作、路径及文件系统错误会刷新写入 Electron 日志目录的 `update-cleanup.jsonl`;日志本身无法写入时回退到标准错误输出。后续下载失败独立报告,不归因于之前的清理警告。有结构化文件系统错误时,区分空间不足、无法写入和文件占用;错误弹窗详情保留当次错误码及已提供的文件路径。没有结构化依据时保留通用下载失败提示。
更新适配层兼容性和平台版本记录见 [Electron updater 兼容性](tests/updates/electron-updater/README.zh.md)。
<a id="mandatory-update-policy"></a>
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh-desktop",
"description": "Electron desktop shell for a bundled dsh runtime and external plugins",
"version": "0.2.2-alpha.2",
"version": "0.2.2-alpha.3",
"private": true,
"license": "MIT",
"author": {
@@ -15,6 +15,7 @@
"bundle": "tsdown --config-loader native",
"dev": "tsx scripts/dev.ts",
"start": "tsx scripts/dev.ts --skip-build",
"test:updates:channels": "pnpm run build && node scripts/test-update-channels.mjs",
"test:updates:local": "pnpm run build && node scripts/test-local-updater.mjs",
"prepare:runtime": "tsx scripts/prepare-runtime.ts",
"render:tray-icon": "tsx scripts/render-tray-icon.ts",
+2 -2
View File
@@ -18,8 +18,8 @@
en: 0b35c7471c09b27a
zh: cc4a382b99fc3210
/desktop-packaging/release-versions:
en: 3f38ab26ee195b39
zh: f00eddafa8f3b9ca
en: 8cc17b1e5ae3e422
zh: 7de3e4f1e76be3e3
/desktop-packaging/configuration-and-local-builds:
en: 5b4f0f80a117f202
zh: ff05be196c181087
+1 -1
View File
@@ -63,7 +63,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609
A production release publishes the product version and takes no `--build-version`. Uploading one tags the packaged commit as `desktop-v<version>` once the artifacts are public; a build from a modified checkout is not tagged, and a tagging failure prints the command to run by hand rather than failing a completed upload. Test and local builds are deliberately left untagged, and every artifact records `dshBuildCommit` and `dshBuildDirty` in its manifest so a build handed over directly remains traceable.
The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `latest`) independently of version and deployment. Automatic updates remain on that channel even for a stable version; manually installing the other channel switches it. Latest packages reject prerelease versions. A stable product can produce a nightly `-test.<date>.<sequence>` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish).
The installer embeds `DSH_DESKTOP_UPDATE_CHANNEL` (`nightly` by default, or `stable`) independently of version and deployment. This value supplies the default channel; the saved selection in [Desktop settings](../README.md#local-desktop-settings) takes precedence, including after installing another package. Stable-channel packages reject prerelease versions. A stable product can produce a nightly `-test.<date>.<sequence>` build, but that version ranks below the stable base and cannot upgrade an already installed higher version. Keep automatic downgrade disabled; stable corrective releases require a product version bump. CI version selection and publication are documented in [CI publication](../publishing/ci/README.md#package-and-publish).
<a id="configuration-and-local-builds"></a>
+1 -1
View File
@@ -63,7 +63,7 @@ pnpm --dir apps/desktop run package:win:x64 --build-version 0.1.6-alpha.1.202609
production 发布使用产品版本本身,不传 `--build-version`。其上传成功后会把打包所用 commit 打成 `desktop-v<版本>` 标签;来自有改动工作区的构建不打标签,打标签失败也只打印手工命令,不会让已完成的上传变成失败。test 与本地构建有意不留标签,而所有产物的清单都记录 `dshBuildCommit` 与 `dshBuildDirty`,直接分发的构建同样可溯源。
安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `latest`)。自动更新始终保留该通道,即使安装的是稳定版本;手动安装另一通道的包才会切换。latest 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test.<date>.<sequence>` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。
安装包独立于版本和发布环境内置 `DSH_DESKTOP_UPDATE_CHANNEL`(默认 `nightly`,也可选择 `stable`)。该值提供默认渠道;[桌面设置](../README.zh.md#local-desktop-settings)中保存的选择优先,包括安装另一个包之后。stable 包拒绝预发布版本。稳定产品可生成 nightly 的 `-test.<date>.<sequence>` 构建,但该版本低于稳定基准,不能升级已安装的更高版本。保持自动降级关闭;稳定修复版需要提升产品版本。CI 版本选择与发布见 [CI 发布](../publishing/ci/README.zh.md#package-and-publish)。
<a id="configuration-and-local-builds"></a>
+2 -2
View File
@@ -21,8 +21,8 @@
en: eed7c4ed0d4e2758
zh: 026fe6076e11018e
/desktop-publication/publish-installers-and-update-feeds:
en: 3c3e3a09573c4386
zh: c851ae6a94f2284a
en: 4723059d6f16b25b
zh: 54e5afad8fff8eb6
/desktop-publication/fixed-installer-downloads:
en: ce8296f8f1ee881e
zh: 749ab63bd6ed4248
+2 -2
View File
@@ -59,9 +59,9 @@ pnpm run package:desktop:mac:arm64
pnpm run upload:mac:arm64
```
The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Both test and production use the fixed Nightly channel; deployment selection does not enable channel switching.
The target dotenv file for internal-test packaging explicitly sets `DSH_DESKTOP_AUTO_UPDATE_ENV=test` and `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`; uploads use `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` and separate test credentials. Deployment and update channel are independent; see [package channel selection](../packaging/README.md#release-versions).
Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Every release publishes `nightly.yml` or `nightly-mac.yml`; stable releases also publish `latest.yml` or `latest-mac.yml` pointing at the same artifacts. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification.
Early internal-test packages use the `test` deployment. Select `production` explicitly only for a production release; changing upload credentials does not retarget an existing package. Packaging requires no COS credentials, disables electron-builder publishing, strips COS credentials from child processes, and records completion only after signing and notarization succeed. Upload validates this record, deployment, target, shared version, filenames, sizes, and SHA-512 before reading credentials. Packages and blockmaps upload before YAML; historical objects are retained. Each package publishes only its selected channel: `nightly.yml` / `nightly-mac.yml` or `stable.yml` / `stable-mac.yml`. Published YAML uses absolute binary URLs. The uploader leaves Cache-Control unset, including the empty header the COS SDK would otherwise add: deployment infrastructure owns cache policy, with uncached feeds and separately configurable binary caching. Serialize publication per target and verify public artifacts and feed contents before release qualification.
Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the installer. The blockmap is uploaded before channel YAML; NSIS installer metadata does not require the embedded `blockMapSize` used by the separate web-installer format. File-plan tests use the pinned builder's blockmap generator, not a hand-authored embedded-map field.
+2 -2
View File
@@ -59,9 +59,9 @@ pnpm run package:desktop:mac:arm64
pnpm run upload:mac:arm64
```
内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。test 和 production 都使用固定 Nightly 通道,部署选择不提供通道切换。
内测打包在目标 `.env` 中显式设置 `DSH_DESKTOP_AUTO_UPDATE_ENV=test` 和 `DOWNLOAD_TEST_ORIGIN=https://download-test.deepseek.com`;上传使用 `DOWNLOAD_TEST_COS_BUCKET=bj-toc-download-test-1320056602` 及独立测试凭据。部署与更新渠道相互独立;参见[安装包渠道选择](../packaging/README.zh.md#release-versions)。
前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个版本发布 `nightly.yml` 或 `nightly-mac.yml`;稳定版本还发布指向相同产物的 `latest.yml` 或 `latest-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。
前期内测包使用 `test` 部署。只有正式发布才显式选择 `production`;更换上传凭据不会改变已有安装包的更新目标。打包不需要 COS 凭据,会禁用 electron-builder 发布、移除子进程的 COS 凭据,并且仅在签名与公证成功后记录完成状态。上传在读取凭据前验证该记录、部署、目标、共同版本号、文件名、大小与 SHA-512。安装包和 blockmap 先于 YAML 上传;历史对象继续保留。每个包仅发布自身选定渠道的清单:`nightly.yml` / `nightly-mac.yml` 或 `stable.yml` / `stable-mac.yml`。发布的 YAML 使用安装包绝对 URL。上传器不设置 Cache-Control,包括 COS SDK 否则会添加的空头部:缓存策略由部署基础设施负责,清单不缓存,安装包缓存单独配置。同一目标应串行发布,并在发布验收前验证公网产物与清单内容。
Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blockmap 先于通道 YAML 上传;NSIS 安装包元数据不要求另一种 web-installer 格式使用的内嵌 `blockMapSize`。文件清单测试使用固定版本构建器的 blockmap 生成器,而不是手工编造内嵌映射字段。
+4 -4
View File
@@ -24,11 +24,11 @@
en: 0f43f2e193e95043
zh: 1c1efb062c06f7b8
/desktop-ci-publication/package-and-publish:
en: f7c12d343891e547
zh: da596ee1366c2046
en: ee8d79ac75482502
zh: 1d986a76860ed262
/desktop-ci-publication/recovery-and-records:
en: b62a7484929f9941
zh: c9dd06c77098e6ef
en: 4ff6f0673d057ca8
zh: 7171d060dd86db9a
/desktop-ci-publication/restore-a-channel:
en: d82e2ccf70cce397
zh: d93dcb77cc4b463e
+2 -2
View File
@@ -70,7 +70,7 @@ Environment approval gates this workflow, not arbitrary code running under the s
For publication to either test or production, choose Windows `signing=signed` or leave macOS **Skip notarization** unchecked. Unsigned and unnotarized packages are only for internal debugging; they cannot publish or provide automatic updates. Before installing dependencies, the hosted routing job rejects debugging artifacts identified by the selected run and attempt. Missing artifact metadata does not establish eligibility: the original machine still validates its retained manifest and signatures. A debugging-package rejection requires a new signed/notarized package and its run ID / attempt; retrying publication cannot qualify the existing bytes.
Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A latest package requires a stable version, so test/latest needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits.
Select the platform's `Package Desktop` workflow, preset, source, channel and version mode. `source_ref` accepts repository branch names (for example `feature/desktop-update`) or `dsh-v*` tags for test; production accepts only `master` or `dsh-v*` tags. Names matching `dsh-v` followed by a digit and letters, digits, dots or hyphens are treated as tags, even if a branch has that name. Tags must match the selected product version. We recommend dispatching the packaging workflow from `master` and enter the source separately; raw commit SHAs, PR refs and fork repositories are not supported. The selected source must contain the CI adapters, so older sources fail before dependency installation. Only select trusted branches: their build code runs on the signing machine. This is an operating convention, not enforced workflow-origin or credential isolation. Publication uses the recorded commit and requires it to remain in the selected branch history; normal branch advances are allowed, deletion or a diverging force-push rejects. Tag publication requires the tag to still resolve to the recorded commit. Dispatch the publisher from `master`; its hosted routing job rejects other refs before scheduling the original machine. The packaged source commit is recorded separately. `version_mode=default` selects product for production and auto for test; product preserves the selected checkout version, auto appends date/run/attempt, and manual requires `build_version`. A stable-channel package requires a stable version, so test/stable needs product or a stable manual version. The optional `test_release_id` is the test URL prefix: blank retains the machine default, successive upgrade tests on that platform need the same value, and defaults should be rotated periodically. It is not a GitHub run ID. See [packaging configuration](../../packaging/README.md#configuration-and-local-builds) for its validation and rotation limits.
Packaging only retains files. After success, open **Publish Desktop · COS → Run workflow** on `master`, enter `package_run_id` from the packaging run URL, and optionally enter `package_run_attempt`. Blank selects the latest attempt, which must have succeeded; it never falls back to an older success. An explicit attempt selects that exact successful build. Windows must be signed; macOS must finish notarization and stapling. GitHub job evidence selects the original runner; its local manifest supplies the preset, version, source and channel before approval. No installer or request artifact is downloaded. Retained packages with the old `publishRequested=false` flag remain eligible; missing or changed local files stop publication.
@@ -80,7 +80,7 @@ Before installing dependencies on the original runner, preparation and the appro
## Recovery and records
After resolving a failure, re-run the failed publishing job to keep its prepared package attempt, or start a new publication with the explicit original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, latest writes `latest.yml` / `latest-mac.yml`. Latest binaries use a `latest/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit.
After resolving a failure, re-run the failed publishing job to keep its prepared package attempt, or start a new publication with the explicit original package run and attempt. Re-running all jobs with a blank attempt resolves the latest package attempt again and requires a new approval. Do not rebuild solely to retry an upload. Missing objects are uploaded, identical objects are skipped, and conflicting same-name bytes stop the operation. Unknown write outcomes are reconciled by reading remote bytes. A newer channel version prevents a stale upload from replacing it. Each package updates only its embedded channel: nightly writes `nightly.yml` / `nightly-mac.yml`, stable writes `stable.yml` / `stable-mac.yml`. Stable-channel binaries use a `stable/` subdirectory under the target binary prefix, separate from existing nightly objects. Publishing the same source/version to both channels requires two packages; an existing release tag is reused only for the same source commit.
The sibling `upload-records/` directory retains an operation identity, newline-delimited events and terminal result. Feed-write intents retain both previous and requested YAML. Failures record their stage, safe error classification and diagnostic; COS failures also identify `GetObject` or `PutObject`, plus a validated HTTP status and request ID when available. Access-denied reports name the permission to inspect. Raw provider responses and credentials are excluded. Audit initialization becomes visible only after its files are complete; abandoned `.initializing-*` directories are not scanned for completed publication. Writes and readback are recorded before proceeding; missing terminal results mean interruption, not success. Actions also keeps its operation record as a diagnostic artifact for 30 days. These local records are not tamper-proof. COS readback verifies storage bytes, not public CDN behavior; public download acceptance is separate.
+2 -2
View File
@@ -70,7 +70,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
发布到 test 或 production 都必须在 Windows 选择 `signing=signed`,或在 macOS 不勾选**跳过公证**。无签名、无公证的包仅限内部调试,不能发布或用于自动更新。托管路由 job 会在安装依赖前,依据所选 run 和 attempt 对应的 artifact 名称拒绝已标明的调试包。缺少 artifact 元数据不代表允许发布:原机器仍会验证留存清单和签名。遇到调试包拒绝提示时,需重新完成签名或公证,再使用新包的 run ID / attempt 发布;重跑发布不能使现有产物获得发布资格。
选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。latest 包要求稳定版本,因此 test/latest 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。
选择对应平台的 `Package Desktop` 工作流、preset、源码、通道和版本模式。test 的 `source_ref` 接受仓库内分支名(如 `feature/desktop-update`)或 `dsh-v*` tag;production 仅接受 `master` 或 `dsh-v*` tag。符合 `dsh-v` 后接数字、其余为字母、数字、点或连字符的名称按 tag 处理,即使存在同名分支。tag 必须与所选产品版本一致。建议从 `master` 启动打包工作流,再单独填写打包源码;不支持直接填写 commit SHA、PR ref 或 fork 仓库。源码必须包含 CI 适配脚本,旧源码会在安装依赖前失败。仅选择可信分支:其构建代码将在签名机器上执行。这是操作约定,不提供工作流来源强制限制或凭据隔离。发布使用记录的 commit,并要求该提交仍在所选分支历史中;正常追加提交不影响发布,删除分支或导致历史分叉的强推会拒绝。tag 发布要求 tag 仍指向记录的提交。从 `master` 发起发布;托管路由 job 会在派发到原机器前拒绝其他 ref。实际打包源码提交单独记录。`version_mode=default` 对 production 选择 product,对 test 选择 auto;product 保留所选源码版本,auto 追加日期/run/attempt,manual 要求填写 `build_version`。stable 包要求稳定版本,因此 test/stable 需选择 product 或稳定的手填版本。可选 `test_release_id` 是测试 URL 前缀:留空沿用机器默认值,同一平台连续升级测试需保持相同值,并应定期轮换默认值。它不是 GitHub run ID。格式校验和轮换限制见[打包配置](../../packaging/README.zh.md#configuration-and-local-builds)。
打包只保留文件。成功后,在 `master` 上打开 **Publish Desktop · COS → Run workflow**,填写打包运行 URL 中的 `package_run_id`,并可填写 `package_run_attempt`。留空选择最新尝试,且该尝试必须成功,不会回退到较早的成功尝试;显式填写则选择对应的成功构建。Windows 必须完成签名,macOS 必须完成公证和 stapling。GitHub job 证据确定原 runner,本机清单在审批前提供 preset、版本、源码和通道。不会下载安装包或请求 artifact。旧清单的 `publishRequested=false` 不影响发布资格;本机文件缺失或变化会停止发布。
@@ -80,7 +80,7 @@ Environment 审批限制此工作流,不限制同一机器账户运行的任
## 恢复与记录
解决故障后重跑失败的发布 job,以保持准备阶段选定的打包尝试;也可显式填写原打包运行和尝试,发起新的发布。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,latest 写入 `latest.yml` / `latest-mac.yml`。latest 二进制位于目标二进制前缀下的 `latest/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。
解决故障后重跑失败的发布 job,以保持准备阶段选定的打包尝试;也可显式填写原打包运行和尝试,发起新的发布。尝试留空时重跑所有 job 会重新解析最新打包尝试,并需要重新审批。不要仅为重试上传而重新打包。缺失对象会上传,相同对象跳过,同名但字节不同的对象会停止操作。写入结果不明时通过读取远端字节核对。频道已有更高版本时,旧上传不能替换它。每个包只更新自身内置通道:nightly 写入 `nightly.yml` / `nightly-mac.yml`,stable 写入 `stable.yml` / `stable-mac.yml`。stable 二进制位于目标二进制前缀下的 `stable/` 子目录,与已有 nightly 对象分离。同一源码和版本发布到两个通道需要分别打包;已有发布 tag 仅在源码提交相同时复用。
同级 `upload-records/` 目录保留操作标识、逐行事件和最终结果。频道写入意图同时记录先前和目标 YAML。失败记录阶段、安全错误分类与诊断;COS 失败还标明 `GetObject` 或 `PutObject`,并在可用时记录经过校验的 HTTP 状态码和 request ID。访问拒绝提示会指出需检查的权限。不保存原始服务响应或凭据。审计初始化在文件完整后才可见;遗留的 `.initializing-*` 目录不参与已完成发布的扫描。写入和回读在继续操作前落盘;没有最终结果代表中断,不代表成功。Actions 还将操作记录保存为诊断 artifact,保留 30 天。这些本地记录不防篡改。COS 回读验证存储字节,不代表公开 CDN 行为;公开下载需单独验收。
+6 -3
View File
@@ -14,6 +14,7 @@ function render(view) {
const { locale: { id, messages }, policy, update, confirmation, navigation } = view
const confirming = confirmation !== undefined
const failed = update.phase === 'error'
const configurationError = failed && update.failedOperation === 'configuration'
const ready = update.phase === 'ready' || (failed && update.failedOperation === 'install')
const downloadable = update.phase === 'available' || (failed && update.failedOperation === 'download')
const authenticationRequired = policy.error === 'authentication-required'
@@ -45,13 +46,14 @@ function render(view) {
detail = view.restart === 'stopping-tasks' ? messages.mandatoryStopping : messages.mandatoryRestarting
status = ''; primary = ''; action = ''
}
const error = localError ?? view.error ?? (authenticationRequired ? messages.policyLoginRequired : failed
const error = localError ?? view.error ?? (configurationError ? messages.updateConfigurationRead
: authenticationRequired ? messages.policyLoginRequired : failed
? update.failedOperation === 'download' ? messages.mandatoryDownloadFailed
: update.failedOperation === 'install'
? preparationMessages[update.preparationFailure] ?? messages.mandatoryInstallFailed
: messages.mandatoryUnavailable
: update.phase === 'idle' && !policy.checking ? messages.mandatoryNoRelease : undefined)
const technicalDetails = failed ? update.technicalDetails ?? update.message ?? '' : ''
const technicalDetails = failed && !configurationError ? update.technicalDetails ?? update.message ?? '' : ''
document.documentElement.lang = id
document.title = messages.mandatoryTitle
element('title').textContent = title
@@ -73,7 +75,8 @@ function render(view) {
element('update').disabled = busy && !confirming
element('later').textContent = messages.updateLater
element('later').hidden = !confirmation?.active
element('refresh').textContent = authenticationRequired ? messages.policyLogin : messages.mandatoryRefresh
element('refresh').textContent = configurationError ? messages.updateConfigurationDetails
: authenticationRequired ? messages.policyLogin : messages.mandatoryRefresh
element('refresh').hidden = confirming || view.restart !== undefined || (!authenticationRequired && (!!primary || !fallback))
element('refresh').disabled = busy || policy.checking
element('page').textContent = navigation ? messages.mandatoryReopen : messages.mandatoryPage
+5
View File
@@ -35,3 +35,8 @@ body.update-dialog.visible { opacity: 1; }
.primary:hover:not(:disabled) { background: var(--dsw-alias-button-primary-hover); }
.secondary:hover:not(:disabled), .close:hover { background: var(--dsw-alias-interactive-bg-hover); }
}
button[aria-busy="true"] { opacity: 1; display: flex; align-items: center; justify-content: center; gap: 8px; }
button[aria-busy="true"]::before { content: ""; width: 14px; height: 14px; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: reload-spin 800ms linear infinite; }
@keyframes reload-spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { button[aria-busy="true"]::before { animation: none; } }
+6 -2
View File
@@ -3,7 +3,7 @@ const api = window.dshUpdateDialog
let view
let responding = false
function respond(index) {
if (responding || view === undefined) return
if (responding || view === undefined || view.busyButton !== undefined) return
responding = true
void api.respond(view.revision, index).catch(() => { responding = false })
}
@@ -11,7 +11,8 @@ document.getElementById('close').addEventListener('click', () => { respond(view.
document.addEventListener('keydown', event => {
if (event.key === 'Escape' && view !== undefined) { event.preventDefault(); respond(view.cancelId) }
if (event.key !== 'Tab') return
const controls = [...document.querySelectorAll('button, details:not([hidden]) > summary, details[open]:not([hidden]) > pre')]
const controls = [...document.querySelectorAll('button:not(:disabled), details:not([hidden]) > summary, details[open]:not([hidden]) > pre')]
if (controls.length === 0) { event.preventDefault(); return }
const current = controls.indexOf(document.activeElement)
const next = current < 0 ? (event.shiftKey ? controls.length - 1 : 0)
: (current + (event.shiftKey ? -1 : 1) + controls.length) % controls.length
@@ -32,6 +33,7 @@ function render(state) {
document.getElementById('title').textContent = state.message
document.getElementById('detail').textContent = state.detail
document.getElementById('detail').hidden = state.detail === ''
document.getElementById('close').disabled = state.busyButton !== undefined
document.getElementById('close').setAttribute('aria-label', state.closeLabel)
document.getElementById('technical-details').hidden = state.technicalDetails === ''
document.getElementById('technical-details-label').textContent = state.technicalDetailsLabel
@@ -42,6 +44,8 @@ function render(state) {
const button = document.createElement('button')
button.type = 'button'
button.textContent = label
button.disabled = state.busyButton !== undefined
if (state.busyButton === index) button.setAttribute('aria-busy', 'true')
button.className = index === 0 ? 'primary' : 'secondary'
button.addEventListener('click', () => { respond(index) })
document.getElementById('actions').append(button)
@@ -17,7 +17,7 @@ export function desktopLatestInstallerFilename(target: DesktopAutoUpdateTarget):
/** Public updater URL and object prefixes for one release target. */
export interface DesktopAutoUpdateConfig {
readonly environment: DesktopAutoUpdateEnvironment
readonly channel: 'nightly' | 'latest'
readonly channel: 'nightly' | 'stable'
readonly target: DesktopAutoUpdateTarget
readonly origin: string
readonly publicUrl: string
@@ -68,7 +68,7 @@ export function desktopBuildRecordFilename(target: DesktopAutoUpdateTarget): str
export function desktopUpdateMetadataFilename(
version: string,
platform: NodeJS.Platform,
channel?: 'nightly' | 'latest',
channel?: 'nightly' | 'stable',
): string
/**
@@ -104,7 +104,7 @@ export function resolveDesktopUploadConfig(
* @param env Packaging settings.
* @returns Validated channel.
*/
export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest'
export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'stable'
/**
* Read a required nonempty deployment setting.
@@ -8,11 +8,11 @@ export const DESKTOP_AUTO_UPDATE_ENV = 'DSH_DESKTOP_AUTO_UPDATE_ENV'
/**
* Resolve the channel embedded in the installer; omitted local settings retain nightly.
* @param {NodeJS.ProcessEnv} env Packaging or upload settings.
* @returns {'nightly' | 'latest'} Validated channel.
* @returns {'nightly' | 'stable'} Validated channel.
*/
export function resolveDesktopUpdateChannel(env) {
const channel = env.DSH_DESKTOP_UPDATE_CHANNEL || 'nightly'
if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop auto-update: invalid update channel')
if (channel !== 'nightly' && channel !== 'stable') throw new Error('desktop auto-update: invalid update channel')
return channel
}
@@ -90,7 +90,7 @@ export function desktopBuildRecordFilename(target) {
* Return the electron-builder channel metadata filename for an application version.
* @param {string} version - Desktop semantic version.
* @param {NodeJS.Platform} platform - Target platform.
* @param {'nightly' | 'latest'} channel - Selected channel; omitted means nightly.
* @param {'nightly' | 'stable'} channel - Selected channel; omitted means nightly.
* @returns {string} Channel metadata filename emitted for the target.
*/
export function desktopUpdateMetadataFilename(version, platform, channel = 'nightly') {
@@ -148,7 +148,7 @@ function httpsOrigin(value, name) {
* @param {NodeJS.ProcessEnv} env - Packaging or upload environment.
* @param {NodeJS.Platform} platform - Target Node.js platform.
* @param {string} arch - Target Node.js architecture.
* @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration.
* @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'stable', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string }} Resolved updater configuration.
* @throws {Error} When the test deployment lacks a valid HTTPS origin or a 32-character lowercase hexadecimal release ID.
*/
export function resolveDesktopAutoUpdateConfig(env, platform, arch) {
@@ -177,7 +177,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) {
target,
origin,
keyPrefix,
binaryKeyPrefix: `${releasePrefix}/bin/${target}${channel === 'latest' ? '/latest' : ''}`,
binaryKeyPrefix: `${releasePrefix}/bin/${target}${channel === 'stable' ? '/stable' : ''}`,
publicUrl: `${origin}/${keyPrefix}/`,
}
}
@@ -187,7 +187,7 @@ export function resolveDesktopAutoUpdateConfig(env, platform, arch) {
* @param {NodeJS.ProcessEnv} env - Upload environment.
* @param {NodeJS.Platform} platform - Target Node.js platform.
* @param {string} arch - Target Node.js architecture.
* @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'latest', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration.
* @returns {{ environment: 'test' | 'production', channel: 'nightly' | 'stable', target: 'mac-arm64' | 'mac-x64' | 'win-x64', origin: string, publicUrl: string, keyPrefix: string, binaryKeyPrefix: string, bucket: string, secretIdEnvName: string, secretKeyEnvName: string }} Resolved upload configuration.
* @throws {Error} When the selected deployment lacks a bucket or valid updater configuration.
*/
export function resolveDesktopUploadConfig(env, platform, arch) {
+1 -1
View File
@@ -24,7 +24,7 @@ export function desktopCiVersion(environment, productVersion, now = new Date())
}
candidate = `${desktopBuildVersionPrefix(productVersion)}${date}.${environment.GITHUB_RUN_NUMBER}.${environment.GITHUB_RUN_ATTEMPT}`
}
if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'latest' && candidate.includes('-')) throw new Error('Latest channel requires a stable version; select product or manual stable version')
if (environment.DSH_DESKTOP_UPDATE_CHANNEL === 'stable' && candidate.includes('-')) throw new Error('Stable channel requires a stable version; select product or manual stable version')
try {
const resolved = validateDesktopBuildVersion(candidate, productVersion)
return resolved
@@ -12,7 +12,7 @@ const runSchema = z.object({
const requestSchema = z.object({ source: z.object({
repository: z.string(), runId: z.string(), attempt: z.string(), commit: z.string().regex(/^[a-f0-9]{40}$/u),
workflowCommit: z.string(), sourceRef: z.string().min(1),
channel: z.enum(['nightly', 'latest']),
channel: z.enum(['nightly', 'stable']),
runnerName: z.string(), target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']),
qualification: z.enum(['signed', 'notarized', 'unsigned', 'unnotarized']),
}).refine(source => source.preset === 'test' || /^(?:master|dsh-v[0-9][0-9A-Za-z.-]*)$/u.test(source.sourceRef),
@@ -25,7 +25,7 @@ export interface DesktopPublishRoute {
readonly attempt: string
readonly commit: string
readonly sourceRef: string
readonly channel: 'nightly' | 'latest'
readonly channel: 'nightly' | 'stable'
readonly runnerName: string
readonly runnerLabel: string
readonly target: 'mac-arm64' | 'mac-x64' | 'win-x64'
@@ -14,7 +14,7 @@ const sourceSchema = z.strictObject({
commit: z.string().regex(/^[a-f0-9]{40}$/u),
workflowCommit: z.string().regex(/^[a-f0-9]{40}$/u),
sourceRef: z.string().min(1),
channel: z.enum(['nightly', 'latest']),
channel: z.enum(['nightly', 'stable']),
runnerName: z.string().min(1),
// Storage accepts every Desktop target; CI routing separately limits currently provisioned platforms.
target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']),
+1 -1
View File
@@ -219,7 +219,7 @@ export async function createDesktopUploadPlan(
throw new Error(`desktop upload: ${targetName} package completion record for ${buildVersion} does not match the ${update.environment} update destination`)
}
if (update.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop upload: latest requires a stable version')
if (update.channel === 'stable' && buildVersion.includes('-')) throw new Error('desktop upload: stable requires a stable version')
const metadataFilename = desktopUpdateMetadataFilename(buildVersion, target.platform, update.channel)
const metadataPath = join(artifactsRoot, metadataFilename)
let metadataValue: unknown
@@ -105,7 +105,7 @@ export function createElectronBuilderConfig(
// the artifact names, the update feed, and the installed app.getVersion() the updater compares against.
const productVersion = JSON.parse(readFileSync(fileURLToPath(new URL('../package.json', import.meta.url)), 'utf8')).version
const buildVersion = resolveDesktopBuildVersion(env, productVersion)
if (update?.channel === 'latest' && buildVersion.includes('-')) throw new Error('desktop package: latest requires a stable version')
if (update?.channel === 'stable' && buildVersion.includes('-')) throw new Error('desktop package: stable requires a stable version')
const packaged = resolveDesktopBuildCommit(env)
return {
appId,
@@ -1,14 +1,14 @@
/** Resolved fields required to embed a macOS updater feed. */
export interface MacOSAppUpdateFeed {
readonly publicUrl: string
readonly channel?: 'nightly' | 'latest'
readonly channel?: 'nightly' | 'stable'
}
/** Packaged electron-updater configuration for macOS. */
export interface MacOSAppUpdateConfig {
readonly provider: 'generic'
readonly url: string
readonly channel: 'nightly' | 'latest'
readonly channel: 'nightly' | 'stable'
readonly updaterCacheDirName: string
}
@@ -24,24 +24,24 @@ function nonEmptyString(value, label) {
/**
* Resolve the one generic macOS feed from the final electron-builder configuration.
* @param {unknown} publish - Final electron-builder publish setting.
* @returns {{ publicUrl: string, channel?: 'nightly' | 'latest' }} Resolved feed used by the packaged App.
* @returns {{ publicUrl: string, channel?: 'nightly' | 'stable' }} Resolved feed used by the packaged App.
*/
export function resolveMacOSAppUpdateFeed(publish) {
if (!Array.isArray(publish) || publish.length !== 1) {
throw new Error('desktop macOS update config: publish must contain exactly one provider')
}
const provider = object(publish[0], 'publish provider')
if (provider.provider !== 'generic' || !['nightly', 'latest'].includes(provider.channel)) {
throw new Error('desktop macOS update config: publish provider must be generic with nightly or latest channel')
if (provider.provider !== 'generic' || !['nightly', 'stable'].includes(provider.channel)) {
throw new Error('desktop macOS update config: publish provider must be generic with nightly or stable channel')
}
return { publicUrl: nonEmptyString(provider.url, 'publish provider URL'), channel: resolveDesktopUpdateChannel({ DSH_DESKTOP_UPDATE_CHANNEL: provider.channel }) }
}
/**
* Create the electron-updater configuration embedded before code signing.
* @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed.
* @param {{ publicUrl: string, channel?: 'nightly' | 'stable' }} update - Resolved update feed.
* @param {string} updaterCacheDirName - electron-builder application cache directory.
* @returns {{ provider: 'generic', url: string, channel: 'nightly' | 'latest', updaterCacheDirName: string }} Packaged updater fields.
* @returns {{ provider: 'generic', url: string, channel: 'nightly' | 'stable', updaterCacheDirName: string }} Packaged updater fields.
*/
export function createMacOSAppUpdateConfig(update, updaterCacheDirName) {
return {
@@ -55,7 +55,7 @@ export function createMacOSAppUpdateConfig(update, updaterCacheDirName) {
/**
* Write the updater configuration into an assembled App before signing.
* @param {string} resourcesDir - App Contents/Resources directory.
* @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Resolved update feed.
* @param {{ publicUrl: string, channel?: 'nightly' | 'stable' }} update - Resolved update feed.
* @param {string} updaterCacheDirName - electron-builder application cache directory.
* @returns {Promise<void>} Resolves after the configuration is durable.
*/
@@ -67,7 +67,7 @@ export async function writeMacOSAppUpdateConfig(resourcesDir, update, updaterCac
/**
* Verify the updater configuration inside an assembled macOS App.
* @param {string} appPath - Application bundle path.
* @param {{ publicUrl: string, channel?: 'nightly' | 'latest' }} update - Expected update feed.
* @param {{ publicUrl: string, channel?: 'nightly' | 'stable' }} update - Expected update feed.
* @param {string | undefined} updaterCacheDirName - Exact cache directory when known.
* @returns {Promise<void>} Resolves when the packaged configuration matches the release destination.
*/
+1 -1
View File
@@ -17,7 +17,7 @@ import { publishDesktopTag } from './desktop-publish-tag.ts'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(),
sourceRef: z.string(), channel: z.enum(['nightly', 'latest']),
sourceRef: z.string(), channel: z.enum(['nightly', 'stable']),
target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']) })
/**
@@ -0,0 +1,29 @@
/** Qualify built channel transitions with Electron and the pinned updater, without installing. */
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { spawn } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import electron from 'electron'
import { recordVerifiedVersions } from '../tests/updates/electron-updater/versions.mjs'
if (!['win32', 'darwin'].includes(process.platform)) throw new Error('Qualification requires Windows or macOS')
if (process.argv.slice(2).some(arg => arg !== '--record')) throw new Error('Usage: test-update-channels.mjs [--record]')
const root = await mkdtemp(join(tmpdir(), 'desktop-channel-'))
const environment = { ...process.env, DSH_CHANNEL_TEST_ROOT: root }
delete environment.ELECTRON_RUN_AS_NODE
const child = spawn(electron, [fileURLToPath(new URL('../tests/updates/electron-updater/qualification.mjs', import.meta.url))], {
env: environment, stdio: 'inherit', windowsHide: true,
})
let timedOut = false
const deadline = setTimeout(() => { timedOut = true; child.kill() }, 30_000)
try {
const code = await new Promise((resolve, reject) => {
child.once('error', reject)
child.once('close', (code, signal) => signal ? reject(new Error(`Channel qualification ended: timedOut=${timedOut}, signal=${signal}`)) : resolve(code))
})
if (timedOut) throw new Error('Channel qualification timed out after 30000 ms')
if (code !== 0) throw new Error(`Channel qualification failed: ${code}`)
if (process.argv.includes('--record')) await recordVerifiedVersions()
} finally { clearTimeout(deadline); await rm(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 }) }
+14 -1
View File
@@ -1,6 +1,8 @@
import type { DesktopFileActions } from '@deepseek-ai/dsh-client-ui-file-actions/types'
/** Typed preload operations exposed only by the Electron shell. */
export type { DesktopChannelState, DesktopChannelChangeResult } from '@deepseek-ai/dsh-client-ui-settings-general/types'
import type { DesktopChannelBridge, DesktopChannelState } from '@deepseek-ai/dsh-client-ui-settings-general/types'
import type { DesktopKeyboardApi, DesktopShortcutsApi } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import type { IpcMainInvokeEvent } from 'electron'
import type { DesktopBrowserBridge } from '@deepseek-ai/dsh-client-ui-sidebar-browser/types'
@@ -32,6 +34,9 @@ export const DESKTOP_IPC = {
deviceInfo: 'dsh-desktop:device-info',
localeBootstrap: 'dsh-desktop:locale-bootstrap',
localeChanged: 'dsh-desktop:locale-changed',
updatesChannelGet: 'dsh-desktop:updates-channel-get',
updatesChannelSet: 'dsh-desktop:updates-channel-set',
updatesChannelChanged: 'dsh-desktop:updates-channel-changed',
updatesStatus: 'dsh-desktop:updates-status',
updatesOpen: 'dsh-desktop:updates-open',
updatesPresentation: 'dsh-desktop:updates-presentation',
@@ -51,17 +56,23 @@ export interface DesktopUpdateState {
/** Main-owned diagnostics without subprocess output or credentials; hidden until expanded. */
readonly technicalDetails?: string
readonly percent?: number
readonly failedOperation?: 'check' | 'download' | 'install'
readonly failedOperation?: 'check' | 'download' | 'install' | 'configuration'
/** Main-owned preparation cause; UI wording is selected by the active locale. */
readonly preparationFailure?: DesktopUpdatePreparationFailureKind
/** Current filesystem failure, independent of previous cache cleanup attempts. */
readonly downloadFailure?: 'space' | 'permission' | 'busy'
}
/** Classified failure copy selected by the Web locale without exposing raw updater diagnostics. */
export type DesktopUpdateFailureKind =
| 'configuration'
| 'check'
| 'check-network'
| 'download'
| 'download-network'
| 'download-space'
| 'download-permission'
| 'download-busy'
| 'install'
| 'install-network'
| 'stop-failed'
@@ -75,6 +86,7 @@ export interface DesktopUpdatePresentation {
readonly version?: string
readonly percent?: number
readonly failure?: DesktopUpdateFailureKind
readonly configurationError?: DesktopChannelState['error']
}
/** Product documents cannot supply update versions, package URLs, or installation authorization. */
@@ -91,6 +103,7 @@ export interface DshDesktopProductApi {
*/
deviceInfo(): Promise<string>
readonly updates: {
readonly channels: DesktopChannelBridge
status(): Promise<DesktopUpdatePresentation>
open(): Promise<void>
subscribe(listener: (state: DesktopUpdatePresentation) => void): () => void
+88
View File
@@ -106,9 +106,20 @@ export const en = {
reloadPageMenu: 'Reload Page',
toggleDevToolsMenu: 'Developer tools',
restartAppHostMenu: 'Restart App and Host',
updateConfigurationFailed: 'Update settings could not be applied. Fix the configuration file and restart, or select an update channel in Settings.',
updateConfigurationRead: 'Settings file error; updates are paused',
updateConfigurationFormat: 'Settings file error; updates are paused',
updateConfigurationChannel: 'Settings file error; updates are paused',
updateConfigurationCleanup: 'Updates paused: cannot discard previous update; restart the app',
updateReloadSettings: 'Reload settings',
updateConfigurationDetails: 'Review settings',
updateShowSettingsFile: 'Show settings file',
updateCheckFailedTitle: 'Update Check Failed',
updateCheckFailed: 'Could not check for updates. Please try again later.',
updateDownloadFailed: 'Could not download the update. Please try again.',
updateDownloadSpaceFailed: 'Not enough disk space to download the update. Free up space, then retry.',
updateDownloadPermissionFailed: 'Cannot write the update files. Check the folder permissions and retry.',
updateDownloadBusyFailed: 'An update file is in use. Close the program using it, then retry.',
updateInstallFailed: 'Could not install the update. Please try again later.',
updateCheckNetworkFailed: 'Could not check for updates. Check your connection and try again.',
updateDownloadNetworkFailed: 'Could not download the update. Check your connection and try again.',
@@ -286,9 +297,20 @@ export const zh = {
reloadPageMenu: '刷新页面',
toggleDevToolsMenu: '开发者工具',
restartAppHostMenu: '重启应用与 Host',
updateConfigurationFailed: '更新配置无法应用。请修正配置文件并重启,或在设置中选择更新渠道。',
updateConfigurationRead: '配置文件异常,更新已暂停',
updateConfigurationFormat: '配置文件异常,更新已暂停',
updateConfigurationChannel: '配置文件异常,更新已暂停',
updateConfigurationCleanup: '更新已暂停:无法清理旧更新,请重启应用',
updateReloadSettings: '重新读取配置',
updateConfigurationDetails: '查看配置',
updateShowSettingsFile: '显示配置文件',
updateCheckFailedTitle: '更新检查失败',
updateCheckFailed: '检查更新失败,请稍后重试。',
updateDownloadFailed: '下载更新失败,请重试。',
updateDownloadSpaceFailed: '磁盘空间不足,无法下载更新。请释放空间后重试。',
updateDownloadPermissionFailed: '无法写入更新文件,请检查目录权限后重试。',
updateDownloadBusyFailed: '更新文件正被占用,请关闭占用它的程序后重试。',
updateInstallFailed: '安装更新失败,请稍后重试。',
updateCheckNetworkFailed: '检查更新失败,请检查网络连接后重试。',
updateDownloadNetworkFailed: '下载更新失败,请检查网络连接后重试。',
@@ -464,9 +486,20 @@ export const ja = {
reloadPageMenu: 'ページを再読み込み',
toggleDevToolsMenu: '開発者ツール',
restartAppHostMenu: 'アプリとホストを再起動',
updateConfigurationFailed: '更新設定を適用できませんでした。設定ファイルを修正して再起動するか、設定で更新チャンネルを選択してください。',
updateConfigurationRead: '設定ファイルに問題があるため、更新を停止しています',
updateConfigurationFormat: '設定ファイルに問題があるため、更新を停止しています',
updateConfigurationChannel: '設定ファイルに問題があるため、更新を停止しています',
updateConfigurationCleanup: '更新を停止中:以前の更新を削除できません。アプリを再起動してください',
updateReloadSettings: '設定を再読み込み',
updateConfigurationDetails: '設定を確認',
updateShowSettingsFile: '設定ファイルを表示',
updateCheckFailedTitle: 'アップデートの確認に失敗しました',
updateCheckFailed: 'アップデートを確認できませんでした。後でもう一度お試しください。',
updateDownloadFailed: 'アップデートをダウンロードできませんでした。もう一度お試しください。',
updateDownloadSpaceFailed: '更新のダウンロードに必要なディスク容量が不足しています。空き容量を増やして再試行してください。',
updateDownloadPermissionFailed: '更新ファイルを書き込めません。フォルダーのアクセス権を確認して再試行してください。',
updateDownloadBusyFailed: '更新ファイルが使用中です。使用しているプログラムを閉じて再試行してください。',
updateInstallFailed: 'アップデートをインストールできませんでした。後でもう一度お試しください。',
updateCheckNetworkFailed: 'アップデートを確認できませんでした。接続を確認してもう一度お試しください。',
updateDownloadNetworkFailed: 'アップデートをダウンロードできませんでした。接続を確認してもう一度お試しください。',
@@ -642,9 +675,20 @@ export const es = {
reloadPageMenu: 'Recargar página',
toggleDevToolsMenu: 'Herramientas de desarrollo',
restartAppHostMenu: 'Reiniciar la aplicación y el host',
updateConfigurationFailed: 'No se pudo aplicar la configuración de actualización. Corrige el archivo y reinicia, o selecciona un canal en Ajustes.',
updateConfigurationRead: 'Error en el archivo de ajustes; actualizaciones pausadas',
updateConfigurationFormat: 'Error en el archivo de ajustes; actualizaciones pausadas',
updateConfigurationChannel: 'Error en el archivo de ajustes; actualizaciones pausadas',
updateConfigurationCleanup: 'Actualizaciones pausadas: no se pudo descartar la actualización anterior; reinicia la aplicación',
updateReloadSettings: 'Volver a cargar ajustes',
updateConfigurationDetails: 'Revisar ajustes',
updateShowSettingsFile: 'Mostrar archivo de ajustes',
updateCheckFailedTitle: 'Error en la comprobación de actualización',
updateCheckFailed: 'No se pudo buscar actualizaciones. Inténtelo de nuevo más tarde.',
updateDownloadFailed: 'No se pudo descargar la actualización. Por favor inténtalo de nuevo.',
updateDownloadSpaceFailed: 'No hay espacio suficiente para descargar la actualización. Libera espacio y vuelve a intentarlo.',
updateDownloadPermissionFailed: 'No se pueden escribir los archivos de actualización. Revisa los permisos de la carpeta y vuelve a intentarlo.',
updateDownloadBusyFailed: 'Un archivo de actualización está en uso. Cierra el programa que lo usa y vuelve a intentarlo.',
updateInstallFailed: 'No se pudo instalar la actualización. Inténtelo de nuevo más tarde.',
updateCheckNetworkFailed: 'No se pudo buscar actualizaciones. Comprueba tu conexión y vuelve a intentarlo.',
updateDownloadNetworkFailed: 'No se pudo descargar la actualización. Comprueba tu conexión y vuelve a intentarlo.',
@@ -820,9 +864,20 @@ export const fr = {
reloadPageMenu: 'Recharger la page',
toggleDevToolsMenu: 'Outils de développement',
restartAppHostMenu: "Redémarrez l'application et l'hôte",
updateConfigurationFailed: 'Impossible d’appliquer les paramètres de mise à jour. Corrigez le fichier et redémarrez, ou sélectionnez un canal dans les paramètres.',
updateConfigurationRead: 'Erreur du fichier de paramètres ; mises à jour suspendues',
updateConfigurationFormat: 'Erreur du fichier de paramètres ; mises à jour suspendues',
updateConfigurationChannel: 'Erreur du fichier de paramètres ; mises à jour suspendues',
updateConfigurationCleanup: 'Mises à jour suspendues : suppression de la mise à jour précédente impossible ; redémarrez l’application',
updateReloadSettings: 'Recharger les paramètres',
updateConfigurationDetails: 'Vérifier les paramètres',
updateShowSettingsFile: 'Afficher le fichier de paramètres',
updateCheckFailedTitle: 'Échec de la vérification de la mise à jour',
updateCheckFailed: 'Impossible de vérifier les mises à jour. Veuillez réessayer plus tard.',
updateDownloadFailed: 'Impossible de télécharger la mise à jour. Veuillez réessayer.',
updateDownloadSpaceFailed: 'Espace disque insuffisant pour télécharger la mise à jour. Libérez de l’espace, puis réessayez.',
updateDownloadPermissionFailed: 'Impossible d’écrire les fichiers de mise à jour. Vérifiez les autorisations du dossier, puis réessayez.',
updateDownloadBusyFailed: 'Un fichier de mise à jour est utilisé. Fermez le programme qui l’utilise, puis réessayez.',
updateInstallFailed: "Impossible d'installer la mise à jour. Veuillez réessayer plus tard.",
updateCheckNetworkFailed: 'Impossible de vérifier les mises à jour. Vérifiez votre connexion et réessayez.',
updateDownloadNetworkFailed: 'Impossible de télécharger la mise à jour. Vérifiez votre connexion et réessayez.',
@@ -998,9 +1053,20 @@ export const de = {
reloadPageMenu: 'Seite neu laden',
toggleDevToolsMenu: 'Entwicklertools',
restartAppHostMenu: 'Anwendung und Host neu starten',
updateConfigurationFailed: 'Die Update-Einstellungen konnten nicht angewendet werden. Korrigiere die Datei und starte neu, oder wähle in den Einstellungen einen Update-Kanal.',
updateConfigurationRead: 'Fehler in der Einstellungsdatei; Updates pausiert',
updateConfigurationFormat: 'Fehler in der Einstellungsdatei; Updates pausiert',
updateConfigurationChannel: 'Fehler in der Einstellungsdatei; Updates pausiert',
updateConfigurationCleanup: 'Updates pausiert: vorheriges Update nicht verwerfbar; App neu starten',
updateReloadSettings: 'Einstellungen neu laden',
updateConfigurationDetails: 'Einstellungen prüfen',
updateShowSettingsFile: 'Einstellungsdatei anzeigen',
updateCheckFailedTitle: 'Update-Überprüfung fehlgeschlagen',
updateCheckFailed: 'Es konnte nicht nach Updates gesucht werden. Bitte versuchen Sie es später noch einmal.',
updateDownloadFailed: 'Das Update konnte nicht heruntergeladen werden. Bitte versuchen Sie es erneut.',
updateDownloadSpaceFailed: 'Nicht genügend Speicherplatz für das Update. Geben Sie Speicherplatz frei und versuchen Sie es erneut.',
updateDownloadPermissionFailed: 'Update-Dateien können nicht geschrieben werden. Prüfen Sie die Ordnerberechtigungen und versuchen Sie es erneut.',
updateDownloadBusyFailed: 'Eine Update-Datei wird verwendet. Schließen Sie das verwendende Programm und versuchen Sie es erneut.',
updateInstallFailed: 'Das Update konnte nicht installiert werden. Bitte versuchen Sie es später noch einmal.',
updateCheckNetworkFailed: 'Es konnte nicht nach Updates gesucht werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.',
updateDownloadNetworkFailed: 'Das Update konnte nicht heruntergeladen werden. Überprüfen Sie Ihre Verbindung und versuchen Sie es erneut.',
@@ -1176,9 +1242,20 @@ export const ru = {
reloadPageMenu: 'Обновить страницу',
toggleDevToolsMenu: 'Инструменты разработчика',
restartAppHostMenu: 'Перезапустите приложение и хост.',
updateConfigurationFailed: 'Не удалось применить настройки обновления. Исправьте файл и перезапустите приложение или выберите канал в настройках.',
updateConfigurationRead: 'Ошибка файла настроек; обновления приостановлены',
updateConfigurationFormat: 'Ошибка файла настроек; обновления приостановлены',
updateConfigurationChannel: 'Ошибка файла настроек; обновления приостановлены',
updateConfigurationCleanup: 'Обновления приостановлены: не удалось удалить предыдущее обновление; перезапустите приложение',
updateReloadSettings: 'Перечитать настройки',
updateConfigurationDetails: 'Проверить настройки',
updateShowSettingsFile: 'Показать файл настроек',
updateCheckFailedTitle: 'Проверка обновления не удалась',
updateCheckFailed: 'Не удалось проверить наличие обновлений. Пожалуйста, повторите попытку позже.',
updateDownloadFailed: 'Не удалось загрузить обновление. Пожалуйста, попробуйте еще раз.',
updateDownloadSpaceFailed: 'Недостаточно места для загрузки обновления. Освободите место и повторите попытку.',
updateDownloadPermissionFailed: 'Не удалось записать файлы обновления. Проверьте права доступа к папке и повторите попытку.',
updateDownloadBusyFailed: 'Файл обновления занят. Закройте использующую его программу и повторите попытку.',
updateInstallFailed: 'Не удалось установить обновление. Пожалуйста, повторите попытку позже.',
updateCheckNetworkFailed: 'Не удалось проверить наличие обновлений. Проверьте подключение и повторите попытку.',
updateDownloadNetworkFailed: 'Не удалось загрузить обновление. Проверьте подключение и повторите попытку.',
@@ -1354,9 +1431,20 @@ export const pt = {
reloadPageMenu: 'Recarregar página',
toggleDevToolsMenu: 'Ferramentas do desenvolvedor',
restartAppHostMenu: 'Reinicie o aplicativo e o host',
updateConfigurationFailed: 'Não foi possível aplicar as configurações de atualização. Corrija o arquivo e reinicie, ou selecione um canal nas configurações.',
updateConfigurationRead: 'Erro no arquivo de configurações; atualizações pausadas',
updateConfigurationFormat: 'Erro no arquivo de configurações; atualizações pausadas',
updateConfigurationChannel: 'Erro no arquivo de configurações; atualizações pausadas',
updateConfigurationCleanup: 'Atualizações pausadas: não foi possível descartar a atualização anterior; reinicie o aplicativo',
updateReloadSettings: 'Recarregar configurações',
updateConfigurationDetails: 'Verificar configurações',
updateShowSettingsFile: 'Mostrar arquivo de configurações',
updateCheckFailedTitle: 'Falha na verificação de atualização',
updateCheckFailed: 'Não foi possível verificar se há atualizações. Por favor, tente novamente mais tarde.',
updateDownloadFailed: 'Não foi possível baixar a atualização. Por favor, tente novamente.',
updateDownloadSpaceFailed: 'Não há espaço suficiente para baixar a atualização. Libere espaço e tente novamente.',
updateDownloadPermissionFailed: 'Não foi possível gravar os arquivos de atualização. Verifique as permissões da pasta e tente novamente.',
updateDownloadBusyFailed: 'Um arquivo de atualização está em uso. Feche o programa que o utiliza e tente novamente.',
updateInstallFailed: 'Não foi possível instalar a atualização. Por favor, tente novamente mais tarde.',
updateCheckNetworkFailed: 'Não foi possível verificar se há atualizações. Verifique sua conexão e tente novamente.',
updateDownloadNetworkFailed: 'Não foi possível baixar a atualização. Verifique sua conexão e tente novamente.',
+118 -17
View File
@@ -1,8 +1,12 @@
import { installFileActions } from './file-actions.ts'
import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
/** Electron shell: desktop project ownership, custom protocol, windows, and lifecycle. */
import { installFileActions } from './file-actions.ts'
import { configurationFailure, channelSaveFailure, configurationMessage } from './update-configuration.ts'
import type { DesktopChannelState, DesktopChannelChangeResult } from '@deepseek-ai/dsh-client-ui-settings-general/types'
import { DesktopUpdateSettings } from './update-settings.ts'
import { isDesktopUpdateChannel } from './settings-file.ts'
import type { ProductEventMap, ProductEvent } from '@deepseek-ai/dsh-client-product-analytics/types'
import { WINDOWS_TITLEBAR_HEIGHT } from './windows-layout.ts'
import { access, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -365,7 +369,7 @@ function createWindow(preload: string, show = false, primary = false): BrowserWi
}
async function main(): Promise<void> {
const desktopSettings = readDesktopSettings(app.getPath('userData'))
const desktopSettings = await readDesktopSettings(app.getPath('userData'))
void pruneCrashReports(app.getPath('logs'))
const journalDirectory = process.env.DSH_DESKTOP_UPDATE_JOURNAL_DIR
const updateJournal = journalDirectory === undefined ? undefined : new DesktopUpdateJournal(journalDirectory, app.getVersion())
@@ -421,6 +425,9 @@ async function main(): Promise<void> {
let updateStoppedHost = false
let updateStopFailure: DesktopHostUncleanExitError | undefined
let updateState: DesktopUpdateState = { phase: 'idle' }
let channelState: DesktopChannelState | undefined
const updatePresentation = (state: DesktopUpdateState) => ({ ...presentDesktopUpdate(state),
...(channelState?.error === undefined ? {} : { configurationError: channelState.error }) })
const systemLanguages = app.getPreferredSystemLanguages()
let locale = windowsLanguage === undefined
? resolveDesktopStartupLocale(null, systemLanguages) : resolveDesktopLocale(windowsLanguage)
@@ -442,11 +449,11 @@ async function main(): Promise<void> {
const updateOverlays = new DesktopUpdateOverlays()
const updateDialog = new DesktopUpdateDialog(fileURLToPath(new URL('./preload-update-dialog.cjs', import.meta.url)), () => locale, updateOverlays)
const isMandatory = (): boolean => mandatoryPolicy?.state.blocking === true
const ordinaryMessageBox = async (options: UpdateDialogOptions): Promise<Electron.MessageBoxReturnValue> => {
const ordinaryMessageBox = async (options: UpdateDialogOptions,
parent = currentDialogWindow()): Promise<Electron.MessageBoxReturnValue> => {
const controller = new AbortController()
ordinaryDialogs.add(controller)
try {
const parent = currentDialogWindow()
if (parent === undefined) return { response: options.cancelId ?? 0, checkboxChecked: false }
return await updateDialog.show(parent, { ...options, signal: controller.signal })
}
@@ -614,9 +621,9 @@ async function main(): Promise<void> {
updateState = state
mandatoryUI?.sync()
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(DESKTOP_IPC.updatesPresentation, presentDesktopUpdate(state))
window.webContents.send(DESKTOP_IPC.updatesPresentation, updatePresentation(state))
}
if (state.phase === 'error' && state.failedOperation !== 'check') {
if (state.phase === 'error' && state.failedOperation !== 'check' && state.failedOperation !== 'configuration') {
if (state.failedOperation === 'install' && fileDragStopped && !quitting) {
fileDragCleanups.add(installFileDrag())
fileDragStopped = false
@@ -678,7 +685,13 @@ async function main(): Promise<void> {
}
const manifest: unknown = JSON.parse(await readFile(join(app.getAppPath(), 'package.json'), 'utf8'))
const updateChannel = desktopUpdateChannel(manifest)
const packagedChannel = desktopUpdateChannel(manifest)
const channelSettings = new DesktopUpdateSettings(app.getPath('userData'), packagedChannel)
const initialChannel = await channelSettings.load()
channelState = { channel: initialChannel.kind === 'ready' ? initialChannel.channel : null,
packagedChannel, changing: false,
...(initialChannel.kind === 'error' ? { error: configurationFailure(initialChannel.error) } : {}) }
const updateChannel = initialChannel.kind === 'ready' ? initialChannel.channel : packagedChannel
const updates = new DesktopUpdateCoordinator(
publishUpdate,
async () => {
@@ -739,12 +752,22 @@ async function main(): Promise<void> {
updateChannel,
)
if (initialChannel.kind === 'error') updates.suspendConfiguration(initialChannel.error.path)
const publishChannel = (state: DesktopChannelState): DesktopChannelState => {
channelState = state
for (const window of BrowserWindow.getAllWindows()) {
window.webContents.send(DESKTOP_IPC.updatesChannelChanged, state)
window.webContents.send(DESKTOP_IPC.updatesPresentation, updatePresentation(updates.state))
}
return state
}
const updateSchedule = new DesktopUpdateSchedule(updates, resolveDesktopUpdateScheduleConfig(process.env))
const downloadUpdate = async (version: string): Promise<DesktopUpdateState> => {
const downloadUpdate = async (version: string, revision = updates.revision): Promise<DesktopUpdateState> => {
void track('desktop_upgrade_click', {})
updateJournal?.action('download-requested')
const state = await updates.download(version)
const state = await updates.download(version, revision)
if (state.phase !== 'ready' || quitting) return state
// Only a completed user-driven download opens this prompt; cancelling installation does not reopen it.
// A confirmation on a hidden window would go unseen, so it waits for the next show; the mandatory
@@ -752,7 +775,7 @@ async function main(): Promise<void> {
if (!isMandatory()) await windowShown()
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- A quit can begin while the show is awaited.
if (quitting) return state
return updates.install(version)
return updates.install(version, revision)
}
const windowShown = (): Promise<void> => new Promise((resolve) => {
const window = currentDialogWindow()
@@ -869,9 +892,39 @@ async function main(): Promise<void> {
windowsLanguage = locale.id
refreshApplicationMenu()
})
ipcMain.handle(DESKTOP_IPC.updatesChannelGet, (event) => {
assertProductSender(event)
return channelState
})
let channelRequests = 0
ipcMain.handle(DESKTOP_IPC.updatesChannelSet, async (event, channel: unknown): Promise<DesktopChannelChangeResult> => {
assertProductSender(event)
if (!isDesktopUpdateChannel(channel)) throw new Error('desktop update: unsupported channel')
if (channelState === undefined) throw new Error('desktop update: settings are not initialized')
channelRequests++
publishChannel({ ...channelState, changing: true })
try {
const kind = await updates.switchChannel(channel, async () => {
try { await channelSettings.save(channel) } catch (error) {
if (channelSaveFailure(error) === 'uncertain') {
updates.suspendConfiguration('desktop update: settings save outcome is unknown')
publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: true, error: 'read' })
}
throw error
}
})
if (kind === 'applied') void mandatoryPolicy?.check('channel-change', true).catch((error: unknown) => { console.error(error) })
return { kind, state: publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: --channelRequests > 0 }) }
} catch (error) {
console.error('desktop update: channel change failed', error)
const state = publishChannel({ ...channelState, changing: --channelRequests > 0,
...(updates.configurationFailure === 'cleanup' ? { error: 'cleanup' as const } : {}) })
return { kind: 'failed', state, reason: channelSaveFailure(error) }
}
})
ipcMain.handle(DESKTOP_IPC.updatesStatus, (event) => {
assertProductSender(event)
return presentDesktopUpdate(updates.state)
return updatePresentation(updates.state)
})
ipcMain.handle(DESKTOP_IPC.deviceInfo, (event) => {
assertProductSender(event)
@@ -897,9 +950,52 @@ async function main(): Promise<void> {
await openUpdatePrompt()
})
let configurationPrompt: Promise<void> | undefined
const showConfigurationError = (): Promise<void> => {
configurationPrompt ??= Promise.resolve().then(async () => {
while (channelState?.error !== undefined) {
const result = await ordinaryMessageBox({ type: 'error', title: locale.messages.updateFailedTitle,
message: configurationMessage(channelState.error, locale.messages),
...{ detail: channelSettings.path,
buttons: [locale.messages.updateReloadSettings, locale.messages.updateShowSettingsFile, locale.messages.updateClose],
defaultId: 0, cancelId: 2,
keepOpenAction: { index: 1, run: () => { shell.showItemInFolder(channelSettings.path) } },
action: { index: 0, run: async () => {
if (channelRequests > 0 || channelState === undefined) return false
channelRequests++
publishChannel({ ...channelState, changing: true })
try {
const loaded = await channelSettings.load()
if (loaded.kind === 'error') {
publishChannel({ ...channelState, changing: true, error: configurationFailure(loaded.error) })
return false
}
await updates.switchChannel(loaded.channel, async () => {})
publishChannel({ channel: updates.selectedChannel, packagedChannel, changing: true })
return true
} catch (error) {
console.error('desktop update: settings reload failed', error)
publishChannel({ ...channelState,
...(updates.configurationFailure === 'cleanup' ? { error: 'cleanup' as const } : {}) })
return false
} finally {
publishChannel({ ...channelState, changing: --channelRequests > 0 })
}
} } },
}, mandatoryUI?.confirmationWindow ?? currentDialogWindow())
if (result.response !== 0) return
}
}).finally(() => { configurationPrompt = undefined })
return configurationPrompt
}
let promptOperation: Promise<void> | undefined
let policyAuthenticationQueued = false
/** Present check results only while their captured channel revision remains current. */
const openUpdatePrompt = (manual = false): Promise<void> => {
if (channelState?.error !== undefined) {
return showConfigurationError()
}
if (authenticationOperation !== undefined) {
policyAuth?.focus(); updateDialog.focus()
}
@@ -917,6 +1013,7 @@ async function main(): Promise<void> {
let progress: Promise<unknown> | undefined
try {
let state = updates.state
const revision = updates.revision
if (manual || state.phase === 'idle' || (state.phase === 'error' && state.failedOperation === 'check')) {
controller = new AbortController()
ordinaryDialogs.add(controller)
@@ -928,6 +1025,7 @@ async function main(): Promise<void> {
}
state = await updateSchedule.check(true)
}
if (revision !== updates.revision) return
if (isMandatory()) { mandatoryUI?.focus(); return }
if (state.phase === 'error' && state.failedOperation === 'check') { await showUpdateFailure(state); return }
if (state.phase === 'idle') {
@@ -939,7 +1037,7 @@ async function main(): Promise<void> {
if (state.phase === 'ready' || (state.phase === 'error' && state.failedOperation === 'install')) {
if (state.version !== undefined) {
failedOperation = 'install'
await showUpdateFailure(await updates.install(state.version))
await showUpdateFailure(await updates.install(state.version, revision))
}
return
}
@@ -954,7 +1052,7 @@ async function main(): Promise<void> {
if (!isMandatory() && state.version !== undefined) {
controller?.abort()
failedOperation = 'download'
await showUpdateFailure(await downloadUpdate(state.version))
await showUpdateFailure(await downloadUpdate(state.version, revision))
}
} finally {
controller?.abort()
@@ -1411,7 +1509,7 @@ async function main(): Promise<void> {
let wasBlocking = false
mandatoryPolicy = new DesktopMandatoryUpdatePolicy(policyConfig, {
platform: process.platform as 'win32' | 'darwin', arch: process.arch as 'x64' | 'arm64',
channel: updateChannel,
get channel() { return updates.selectedChannel },
bundledDshVersion: app.isPackaged ? readDesktopRuntime(resources.dsh).release.version : app.getVersion(),
}, (state) => {
if (state.error !== 'authentication-required') policyAuthenticationQueued = false
@@ -1429,7 +1527,10 @@ async function main(): Promise<void> {
preload: fileURLToPath(new URL('./preload-mandatory.cjs', import.meta.url)), locale,
allowedPageOrigins: policyConfig.allowedPageOrigins, parent: () => mainWindow,
policy: () => policy.state, update: () => updates.state,
refresh: async () => { await Promise.all([checkPolicyManually(), updateSchedule.check(true)]) },
refresh: async () => {
if (channelState?.error !== undefined) await showConfigurationError()
if (channelState?.error === undefined) await Promise.all([checkPolicyManually(), updateSchedule.check(true)])
},
download: downloadUpdate, install: version => updates.install(version),
})
void mandatoryPolicy.check('launch').then((state) => {
+10 -6
View File
@@ -8,7 +8,7 @@ export interface DesktopPolicyIdentity {
readonly platform: 'win32' | 'darwin'
readonly bundledDshVersion: string
readonly arch: 'x64' | 'arm64'
readonly channel: 'nightly' | 'latest'
readonly channel: 'nightly' | 'stable'
}
/** Validated deployment choices; test authentication is explicitly enabled, never inferred from a redirect. */
@@ -129,6 +129,7 @@ function parsePolicy(body: unknown, ok: boolean, config: DesktopPolicyConfig): D
export class DesktopMandatoryUpdatePolicy {
private current: DesktopPolicyState = { blocking: false, checking: false }
private pending: Promise<DesktopPolicyState> | undefined
private pendingChannel: DesktopPolicyIdentity['channel'] | undefined
private controller: AbortController | undefined
private timer: ReturnType<typeof setTimeout> | undefined
private disposed = false
@@ -137,7 +138,7 @@ export class DesktopMandatoryUpdatePolicy {
/**
* @param config - Resolved deployment settings.
* @param identity - Installed software identity, fixed for this application build.
* @param identity - Installed software identity; a channel getter may follow an explicit user selection.
* @param publish - Receives policy changes without controlling downloads or existing tasks.
* @param request - Anonymous Fetch or the dedicated test-authentication Session transport.
* @param client - UI build version, language, and UTC offset, sampled for every check.
@@ -156,10 +157,10 @@ export class DesktopMandatoryUpdatePolicy {
}
/** Platform headers for one check; the calling UI's language and UTC offset are read now. */
private requestHeaders(): Record<string, string> {
private requestHeaders(channel: DesktopPolicyIdentity['channel']): Record<string, string> {
return {
...platformClientHeaders(this.identity.platform, this.client()),
'x-client-arch': this.identity.arch, 'x-client-update-channel': this.identity.channel,
'x-client-arch': this.identity.arch, 'x-client-update-channel': channel,
'x-client-bundled-dsh-version': this.identity.bundledDshVersion,
}
}
@@ -175,9 +176,12 @@ export class DesktopMandatoryUpdatePolicy {
*/
check(scenario: string, manual = false): Promise<DesktopPolicyState> {
if (this.disposed) return Promise.reject(new Error('desktop policy: disposed'))
if (this.pending !== undefined) return this.pending
if (this.pending !== undefined) return this.pendingChannel === this.identity.channel
? this.pending : this.pending.then(() => this.check(scenario, true))
if (!manual && Date.now() < this.nextCheck) return Promise.resolve(this.current)
clearTimeout(this.timer)
const channel = this.identity.channel
this.pendingChannel = channel
this.pending = Promise.resolve().then(async () => {
if (this.disposed) return this.current
const controller = new AbortController()
@@ -187,7 +191,7 @@ export class DesktopMandatoryUpdatePolicy {
try {
const url = new URL('/api/v0/check_client_update', this.config.origin)
url.searchParams.set('scenario', scenario)
const response = await this.request(url, { headers: this.requestHeaders(), signal: controller.signal,
const response = await this.request(url, { headers: this.requestHeaders(channel), signal: controller.signal,
credentials: this.config.authentication === 'feishu-test' ? 'include' : 'omit', cache: 'no-store', redirect: 'error' })
const body: unknown = await response.json()
if (this.config.authentication === 'feishu-test' && response.status === 401
+10
View File
@@ -1,6 +1,7 @@
import type { FileSaveResult } from '@deepseek-ai/dsh-client-ui-file-actions/types'
/** Origin-scoped boot, native directory selection, host paths of picked files, and update presentation with native confirmation actions. */
import type { DesktopChannelState, DesktopChannelChangeResult } from '@deepseek-ai/dsh-client-ui-settings-general/types'
import type { DesktopShortcutInput, ShortcutConfigSnapshot, ShortcutSaveResult } from '@deepseek-ai/dsh-client-shortcuts/protocol'
import { contextBridge, ipcRenderer, webUtils } from 'electron'
import { DESKTOP_IPC, SCHEME, type DshDesktopProductApi, type DesktopUpdatePresentation } from './ipc.ts'
@@ -65,6 +66,15 @@ function createProductApi(): DshDesktopProductApi {
},
},
updates: {
channels: {
get: () => ipcRenderer.invoke(DESKTOP_IPC.updatesChannelGet) as Promise<DesktopChannelState>,
set: channel => ipcRenderer.invoke(DESKTOP_IPC.updatesChannelSet, channel) as Promise<DesktopChannelChangeResult>,
subscribe(listener) {
const handle = (_event: Electron.IpcRendererEvent, state: DesktopChannelState): void => { listener(state) }
ipcRenderer.on(DESKTOP_IPC.updatesChannelChanged, handle)
return () => { ipcRenderer.off(DESKTOP_IPC.updatesChannelChanged, handle) }
},
},
status: () => ipcRenderer.invoke(DESKTOP_IPC.updatesStatus) as Promise<DesktopUpdatePresentation>,
open: () => ipcRenderer.invoke(DESKTOP_IPC.updatesOpen) as Promise<void>,
subscribe(listener) {
+166
View File
@@ -0,0 +1,166 @@
/** Shared shell settings storage; reads have no side effects and fields fail independently. */
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import { z } from 'zod'
const preferencesSchema = z.object({
allowTestAuthPopupWindow: z.boolean(),
channel: z.enum(['nightly', 'stable']),
})
const documentSchema = z.looseObject({ updates: z.looseObject({}).optional() })
/** Known field values before consumers supply defaults for absent fields. */
export type DesktopUpdatePreferences = z.infer<typeof preferencesSchema>
/** Unknown root and updates fields survive a read-modify-write operation. */
export type DesktopSettingsDocument = z.infer<typeof documentSchema>
/** Handling-relevant failure details; validation failures never include document contents. */
export type DesktopSettingsProblem =
| { kind: 'read' }
| { kind: 'invalid'; reason: 'json' | 'structure' }
| { kind: 'invalid'; reason: 'field'; field: keyof DesktopUpdatePreferences }
| { kind: 'write'; operation: 'initialize' | 'save'; effect: 'unchanged' | 'unknown' | 'written' }
/** A storage or validation failure; business consumers decide fallback or suspension. */
export class DesktopSettingsFileError extends Error {
/**
* @param problem - Stable failure classification and mutation outcome, when applicable.
* @param path - Settings file location, never file contents.
* @param options - Original filesystem error, when available.
*/
constructor(readonly problem: DesktopSettingsProblem, readonly path: string, options?: ErrorOptions) {
super(`desktop settings: ${problem.kind}: ${path}`, options)
}
}
/** Absence permits a caller default; an invalid explicit field does not. */
export type DesktopSettingsField<T> =
| { kind: 'absent' }
| { kind: 'valid'; value: T }
| { kind: 'invalid'; error: DesktopSettingsFileError }
/** File state and independent field results; a missing file is never implicitly created. */
export type DesktopSettingsRead =
| { kind: 'missing' }
| { kind: 'error'; error: DesktopSettingsFileError }
| {
kind: 'ready'
document: DesktopSettingsDocument
fields: { [K in keyof DesktopUpdatePreferences]: DesktopSettingsField<DesktopUpdatePreferences[K]> }
}
function field<T>(schema: z.ZodType<T>, value: unknown, name: keyof DesktopUpdatePreferences, path: string): DesktopSettingsField<T> {
if (value === undefined) return { kind: 'absent' }
const result = schema.safeParse(value)
return result.success ? { kind: 'valid', value: result.data }
: { kind: 'invalid', error: new DesktopSettingsFileError({ kind: 'invalid', reason: 'field', field: name }, path) }
}
/**
* Validate a channel at the IPC boundary using the same schema as file loading.
* @param value - Untrusted input.
* @returns Whether the input is a supported channel.
*/
export function isDesktopUpdateChannel(value: unknown): value is DesktopUpdatePreferences['channel'] {
return preferencesSchema.shape.channel.safeParse(value).success
}
/**
* Resolve the document shared by shell preferences and update channels.
* @param userData - Electron userData, independent of Host and DSH_HOME.
* @returns Settings file path without filesystem changes.
*/
function desktopSettingsPath(userData: string): string {
return join(userData, 'desktop', 'settings.json')
}
/** File operations do not watch, cache, choose defaults or apply runtime preferences. */
export class DesktopSettingsFile {
readonly path: string
/** @param userData - Electron userData, independent of Host and DSH_HOME. */
constructor(userData: string) { this.path = desktopSettingsPath(userData) }
/** @returns Fresh file and field states; performs no creation or repair. */
async read(): Promise<DesktopSettingsRead> {
let raw: string
try { raw = await readFile(this.path, 'utf8') } catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return { kind: 'missing' }
return { kind: 'error', error: new DesktopSettingsFileError({ kind: 'read' }, this.path, { cause: error }) }
}
let value: unknown
try { value = JSON.parse(raw) } catch (_error) {
// SyntaxError can quote user content; diagnostics expose only the file and failure category.
return { kind: 'error', error: new DesktopSettingsFileError({ kind: 'invalid', reason: 'json' }, this.path) }
}
return this.inspect(value)
}
private inspect(value: unknown): DesktopSettingsRead {
const result = documentSchema.safeParse(value)
if (!result.success) return { kind: 'error', error: new DesktopSettingsFileError({ kind: 'invalid', reason: 'structure' }, this.path) }
const document = result.data
return { kind: 'ready', document, fields: {
allowTestAuthPopupWindow: field(preferencesSchema.shape.allowTestAuthPopupWindow,
document.updates?.allowTestAuthPopupWindow, 'allowTestAuthPopupWindow', this.path),
channel: field(preferencesSchema.shape.channel, document.updates?.channel, 'channel', this.path),
} }
}
/**
* Explicit startup initialization; existing files, including invalid ones, are never replaced.
* @returns Current file state after exclusive creation if missing, or a structured failure.
*/
async initialize(): Promise<DesktopSettingsRead> {
const current = await this.read()
if (current.kind !== 'missing') return current
try { return await this.mutate({ kind: 'initialize' }) } catch (error) {
if (!(error instanceof DesktopSettingsFileError)) throw error
return { kind: 'error', error }
}
}
/**
* Patch known fields under the shared writer lock, preserving unknown and unrelated invalid fields.
* Malformed JSON/structure is rejected; a missing document starts empty. External editors must
* not write concurrently. No runtime settings change here. Write errors distinguish unchanged,
* uncertain write completion, and a committed write whose lock cleanup failed; no rollback is attempted.
* @param updates - Typed fields to replace; omitted keys remain unchanged.
* @returns After atomic replacement and lock release; otherwise throws DesktopSettingsFileError.
*/
async saveUpdates(updates: Partial<DesktopUpdatePreferences>): Promise<void> {
const result = await this.mutate({ kind: 'save', updates })
if (result.kind === 'error') throw result.error
}
private async mutate(request: { kind: 'initialize' } | { kind: 'save'; updates: Partial<DesktopUpdatePreferences> }): Promise<DesktopSettingsRead> {
let effect: 'unchanged' | 'unknown' | 'written' = 'unchanged'
try {
await mkdir(dirname(this.path), { recursive: true, mode: 0o700 })
return await withFileLock(this.path, async () => {
const current = await this.read()
if (current.kind === 'error') throw current.error
if (request.kind === 'initialize' && current.kind !== 'missing') return current
const document = current.kind === 'ready' ? current.document : {}
const updates = request.kind === 'initialize' ? { allowTestAuthPopupWindow: false } : request.updates
const next = { ...document, updates: { ...document.updates, ...updates } }
const raw = `${JSON.stringify(next, null, 2)}\n`
effect = 'unknown'
if (request.kind === 'initialize') {
try { await writeFile(this.path, raw, { flag: 'wx', mode: 0o600 }) } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
effect = 'unchanged'
return this.read()
}
} else {
await writeFileAtomic(this.path, raw, { mode: 0o600 })
}
effect = 'written'
return this.inspect(next)
})
} catch (error) {
if (error instanceof DesktopSettingsFileError) throw error
throw new DesktopSettingsFileError({ kind: 'write', operation: request.kind, effect }, this.path, { cause: error })
}
}
}
+17 -48
View File
@@ -1,57 +1,26 @@
/**
* Local Electron shell settings, loaded once per process independently of the Host.
* Ownership: .agents/notes/implemented/architecture/2026-10-08-desktop-shell-configuration.md.
*/
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
/** Startup policy for optional Electron shell preferences, independent of the Host. */
import { DesktopSettingsFile, type DesktopUpdatePreferences } from './settings-file.ts'
/** Resolved shell preferences; absent fields use the defaults without rewriting existing files. */
/** Resolved popup preference; channel failures do not disable a valid popup preference. */
export interface DesktopSettings {
readonly updates: {
readonly allowTestAuthPopupWindow: boolean
}
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
readonly updates: Pick<DesktopUpdatePreferences, 'allowTestAuthPopupWindow'>
}
/**
* Read shell settings, creating the default document exclusively when missing.
* @param userData - Electron's userData directory, independent of DSH_HOME.
* @returns Validated settings, or defaults when reading, creating or validating fails; changes require a restart.
* Initialize the shared document explicitly at startup, then resolve the popup preference.
* @param userData - Electron userData, independent of DSH_HOME.
* @returns Validated popup preference, or false with a diagnostic on failure; changes require restart.
*/
export function readDesktopSettings(userData: string): DesktopSettings {
const directory = join(userData, 'desktop')
const path = join(directory, 'settings.json')
const defaults: DesktopSettings = { updates: { allowTestAuthPopupWindow: false } }
try {
let raw: string
try { raw = readFileSync(path, 'utf8') } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
mkdirSync(directory, { recursive: true, mode: 0o700 })
try { writeFileSync(path, `${JSON.stringify(defaults, null, 2)}\n`, { flag: 'wx', mode: 0o600 }) } catch (error) {
// An existing document belongs to the user, including one created during initialization.
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error
}
raw = readFileSync(path, 'utf8')
export async function readDesktopSettings(userData: string): Promise<DesktopSettings> {
const file = new DesktopSettingsFile(userData)
const result = await file.initialize()
const preference = result.kind === 'ready' ? result.fields.allowTestAuthPopupWindow : undefined
if (preference?.kind === 'valid') return { updates: { allowTestAuthPopupWindow: preference.value } }
const error = result.kind === 'error' ? result.error : preference?.kind === 'invalid' ? preference.error : undefined
if (error) {
try { console.warn(`desktop settings: ${file.path}: using defaults because settings could not be loaded`, error) } catch (_logError) {
// Optional popup settings remain disabled even when diagnostic output is unavailable.
}
const value: unknown = JSON.parse(raw)
if (!record(value) || (value.updates !== undefined && !record(value.updates))) {
throw new Error('expected a JSON object with an optional updates object')
}
const allow = value.updates?.allowTestAuthPopupWindow
if (allow !== undefined && typeof allow !== 'boolean') {
throw new Error('updates.allowTestAuthPopupWindow must be true or false')
}
return { updates: { allowTestAuthPopupWindow: allow ?? false } }
} catch (error) {
try {
console.warn(`desktop settings: ${path}: using defaults because settings could not be loaded`, error)
} catch (_logError) {
// Diagnostic output is best effort; its failure must not make optional settings fatal.
// TODO: Persist settings diagnostics when console output is unavailable, without blocking startup.
}
return defaults
}
return { updates: { allowTestAuthPopupWindow: false } }
}
+3 -2
View File
@@ -1,7 +1,8 @@
/** Resolve the update channel sealed into the installed application manifest. */
import type { DesktopUpdatePreferences } from './settings-file.ts'
/** Installer-owned channel, independent of release version and upload environment. */
export type DesktopUpdateChannel = 'nightly' | 'latest'
export type DesktopUpdateChannel = DesktopUpdatePreferences['channel']
/**
* Read the packaged channel; older manifests retain their existing nightly behavior.
@@ -12,6 +13,6 @@ export function desktopUpdateChannel(manifest: unknown): DesktopUpdateChannel {
if (typeof manifest !== 'object' || manifest === null) throw new Error('desktop update: invalid application manifest')
if (!('dshDesktopUpdateChannel' in manifest)) return 'nightly'
const channel = manifest.dshDesktopUpdateChannel
if (channel !== 'nightly' && channel !== 'latest') throw new Error('desktop update: invalid packaged channel')
if (channel !== 'nightly' && channel !== 'stable') throw new Error('desktop update: invalid packaged channel')
return channel
}
+29
View File
@@ -0,0 +1,29 @@
/** Durable, best-effort diagnostics for obsolete update files that could not be removed. */
import { appendFileSync, mkdirSync } from 'node:fs'
import { join } from 'node:path'
/** Filesystem operation on a retired target; not evidence that a future download will fail. */
export interface UpdateCleanupFailure {
readonly operation: 'list' | 'remove'
readonly path: string
readonly error: unknown
}
/**
* Flush one cleanup warning to disk; diagnostic failure falls back to stderr without reviving a target.
* @param directory - Shell-owned log directory, outside the update cache.
* @param failure - Failed cleanup operation; no update URL or settings contents.
*/
export function recordUpdateCleanupFailure(directory: string, failure: UpdateCleanupFailure): void {
const error = failure.error
const entry = { time: new Date().toISOString(), event: 'update-cache-cleanup-failed',
operation: failure.operation, path: failure.path,
code: error instanceof Error && 'code' in error ? String(error.code) : null,
reason: error instanceof Error ? error.message : String(error) }
try {
mkdirSync(directory, { recursive: true })
appendFileSync(join(directory, 'update-cleanup.jsonl'), `${JSON.stringify(entry)}\n`, { mode: 0o600, flush: true })
} catch (logError) {
console.error('desktop update: could not persist cleanup warning', entry, logError)
}
}
+45
View File
@@ -0,0 +1,45 @@
/** Safe user-facing classifications for shell update settings failures. */
import type { DesktopChannelChangeResult, DesktopChannelState } from '@deepseek-ai/dsh-client-ui-settings-general/types'
import { DesktopSettingsFileError } from './settings-file.ts'
import type { DesktopMessages } from './locale.ts'
/**
* Classify an unreadable startup selection without exposing file contents.
* @param error - Validated storage failure.
* @returns The persistent reason updates are suspended.
*/
export function configurationFailure(error: DesktopSettingsFileError): NonNullable<DesktopChannelState['error']> {
const problem = error.problem
if (problem.kind !== 'invalid') return 'read'
return problem.reason === 'field' && problem.field === 'channel' ? 'channel' : 'format'
}
/**
* Reduce an operation failure to a short, trustworthy toast reason.
* @param error - Failed save or channel transition.
* @returns Safe detail for the initiating settings view.
*/
export function channelSaveFailure(error: unknown): Extract<DesktopChannelChangeResult, { kind: 'failed' }>['reason'] {
if (!(error instanceof DesktopSettingsFileError)) return 'apply'
if (error.problem.kind === 'write' && error.problem.effect === 'unknown') return 'uncertain'
if (error.problem.kind === 'invalid') return 'format'
const cause = error.cause
if (cause instanceof Error && 'code' in cause && (cause.code === 'EACCES' || cause.code === 'EPERM')) return 'permission'
return 'save'
}
/**
* Explain an actual suspension and its recovery action.
* @param reason - Persistent updater configuration failure.
* @param messages - Current desktop locale.
* @returns Localized dialog message.
*/
export function configurationMessage(reason: NonNullable<DesktopChannelState['error']>, messages: DesktopMessages): string {
const key = {
read: 'updateConfigurationRead',
format: 'updateConfigurationFormat',
channel: 'updateConfigurationChannel',
cleanup: 'updateConfigurationCleanup',
} as const
return messages[key[reason]]
}
+110 -21
View File
@@ -3,33 +3,41 @@
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import { app } from 'electron'
import electronUpdater, { type AppUpdater, type ProgressInfo, type UpdateInfo } from 'electron-updater'
import electronUpdater, { type AppUpdater, type ProgressInfo, type UpdateInfo, type UpdateCheckResult } from 'electron-updater'
import { gt, valid } from 'semver'
import type { DesktopUpdateState } from './ipc.ts'
import { DesktopUpdateHttpExecutor } from './update-http-executor.ts'
import { DesktopUpdatePreparationError } from './update-error.ts'
import { DesktopUpdateHttpExecutor } from './updates/electron-updater/http-executor.ts'
import { DesktopUpdatePreparationError, downloadFailureDetails } from './update-error.ts'
import type { DesktopUpdateChannel } from './update-channel.ts'
import { selectUpdateChannel } from './updates/electron-updater/channel.ts'
import { discardDesktopUpdate } from './updates/electron-updater/discard.ts'
const { autoUpdater } = electronUpdater
/** Owns one updater target until its download and installation settle. */
export class DesktopUpdateCoordinator {
private current: DesktopUpdateState = { phase: 'idle' }
private candidate: string | undefined
private downloaded = false
private release: { kind: 'none' } | { kind: 'checked' | 'downloaded' | 'handed-off'; version: string } = { kind: 'none' }
private disposed = false
private generation = 0
private switching = 0
private downloadGeneration = 0
private switchTail: Promise<void> = Promise.resolve()
private cancellation: UpdateCheckResult['cancellationToken']
private configurationError: 'startup' | 'cleanup' | undefined
private checkOperation: Promise<DesktopUpdateState> | undefined
private downloadOperation: Promise<DesktopUpdateState> | undefined
private installOperation: Promise<DesktopUpdateState> | undefined
private readonly onProgress = (progress: ProgressInfo): void => {
if (this.downloadOperation === undefined || this.downloaded) return
if (this.downloadGeneration !== this.generation || this.downloadOperation === undefined || this.downloaded) return
const percent = Math.min(100, Math.max(0, progress.percent))
this.setState({ phase: percent >= 100 ? 'verifying' : 'downloading', ...this.target(), percent })
}
private readonly onDownloaded = (info: UpdateInfo): void => {
if (this.downloadOperation === undefined || info.version !== this.candidate) return
this.downloaded = true
if (this.downloadGeneration !== this.generation || this.downloadOperation === undefined || info.version !== this.candidate) return
this.release = { kind: 'downloaded', version: info.version }
}
private readonly onError = (error: Error): void => {
@@ -46,6 +54,7 @@ export class DesktopUpdateCoordinator {
* @param enabled - Whether this process has a packaged update source.
* @param currentVersion - Actual installed application version.
* @param channel - Installed package channel; omitted test/local callers retain nightly.
* @param discard - Clears settled library targets and platform resources before another channel is used.
* @param downloadResult - Once per completed download attempt, including platform preparation failures.
*/
constructor(
@@ -55,7 +64,8 @@ export class DesktopUpdateCoordinator {
private readonly enabled: () => boolean = () => app.isPackaged && existsSync(join(process.resourcesPath, 'app-update.yml')),
private readonly currentVersion: () => string = () => app.getVersion(),
private readonly downloadResult?: (success: boolean, reason?: string) => void,
private readonly channel: 'nightly' | 'latest' = 'nightly',
private channel: DesktopUpdateChannel = 'nightly',
private readonly discard: (updater: AppUpdater) => Promise<void> = discardDesktopUpdate,
) {
if (updater === autoUpdater) {
// electron-updater omits this internal transport property from its public declarations.
@@ -68,10 +78,7 @@ export class DesktopUpdateCoordinator {
}
this.updater.autoDownload = false
this.updater.autoInstallOnAppQuit = false
this.updater.channel = channel
this.updater.allowPrerelease = channel === 'nightly'
// Selecting a channel can enable downgrade in electron-updater.
this.updater.allowDowngrade = false
selectUpdateChannel(this.updater, channel)
this.updater.on('download-progress', this.onProgress)
this.updater.on('update-downloaded', this.onDownloaded)
this.updater.on('error', this.onError)
@@ -80,6 +87,66 @@ export class DesktopUpdateCoordinator {
/** Latest observable state; complete download identity remains main-process-owned. */
get state(): DesktopUpdateState { return this.current }
/** Reason ordinary updates are suspended, independently of the last operation result. */
get configurationFailure(): 'startup' | 'cleanup' | undefined { return this.configurationError }
/** Selected runtime channel, including while a new feed is being checked. */
get selectedChannel(): DesktopUpdateChannel { return this.channel }
/** Revision captured by confirmations so channel changes invalidate old user actions. */
get revision(): number { return this.generation }
/**
* Suspend ordinary updates when settings or previous-target cleanup are invalid.
* @param detail - File path and safe diagnostic, without document contents.
* @param reason - Startup validation or retirement of a previous target.
*/
suspendConfiguration(detail: string, reason: 'startup' | 'cleanup' = 'startup'): void {
this.configurationError = reason
this.setState({ phase: 'error', failedOperation: 'configuration', message: detail })
}
/**
* Persist a UI choice, retire the old target, and check the selected feed.
* Accepted choices serialize; installation cannot start while any choice is pending.
* Persistence failure leaves the previous channel and target intact. Cleanup failure
* suspends updates until another explicit switch successfully retires the target.
* @param channel - Validated UI choice.
* @param persist - Atomic settings save; rejects before a choice is applied on failure.
* @returns Whether the selection changed; healthy repeated choices preserve the current operation.
*/
switchChannel(channel: DesktopUpdateChannel, persist: () => Promise<void>): Promise<'applied' | 'unchanged'> {
this.assertLive()
if (this.installOperation !== undefined || this.current.phase === 'installing' || this.release.kind === 'handed-off') {
return Promise.reject(new Error('desktop update: installation is in progress'))
}
this.switching++
const operation = this.switchTail.then(async () => {
this.assertLive()
if (channel === this.channel && this.configurationError === undefined) return 'unchanged' as const
await persist()
this.generation++
this.cancellation?.cancel()
await Promise.allSettled([this.checkOperation, this.downloadOperation])
this.assertLive()
this.release = { kind: 'none' }
this.cancellation = undefined
try { await this.discard(this.updater) } catch (error) {
this.suspendConfiguration('desktop update: could not retire the previous update', 'cleanup')
throw error
}
this.channel = channel
selectUpdateChannel(this.updater, channel)
this.configurationError = undefined
this.setState({ phase: 'checking' })
const result = await this.doCheck()
if (result.phase === 'error') this.setState(result)
return 'applied' as const
}).finally(() => { this.switching-- })
this.switchTail = operation.then(() => {}).catch(() => { /* The caller reports this selection's failure; later choices may retry. */ })
return operation
}
/**
* Check metadata without downloading, joining any current check.
* @param manual - Whether a failed check must remain visible in the status indicator.
@@ -87,7 +154,8 @@ export class DesktopUpdateCoordinator {
*/
async check(manual = false): Promise<DesktopUpdateState> {
this.assertLive()
if (this.downloadOperation !== undefined || this.installOperation !== undefined || this.downloaded) return this.current
if (this.configurationError !== undefined || this.switching > 0
|| this.downloadOperation !== undefined || this.installOperation !== undefined || this.downloaded) return this.current
if (!manual && this.current.phase === 'error' && this.current.failedOperation === 'download') return this.current
this.checkOperation ??= Promise.resolve().then(() => this.doCheck())
.finally(() => { this.checkOperation = undefined })
@@ -98,24 +166,31 @@ export class DesktopUpdateCoordinator {
/**
* @param version - Version shown in the user's download confirmation.
* @param revision - Revision captured by the download confirmation; omitted uses the current target.
* @returns Download readiness or failure, without authorizing installation.
*/
async download(version: string): Promise<DesktopUpdateState> {
async download(version: string, revision = this.generation): Promise<DesktopUpdateState> {
this.assertLive()
if (revision !== this.generation || this.configurationError !== undefined || this.switching > 0) return this.current
if (this.downloaded || this.installOperation !== undefined) return this.current
const generation = this.generation
this.downloadGeneration = generation
this.downloadOperation ??= Promise.resolve().then(async () => {
await this.checkOperation
if (generation !== this.generation) return this.current
this.assertLive()
if (this.candidate === undefined) throw new Error('desktop update: no checked update is available')
if (version !== this.candidate) throw new Error('desktop update: download confirmation is stale')
this.setState({ phase: 'downloading', version, percent: 0 })
try {
await this.updater.downloadUpdate()
await this.updater.downloadUpdate(this.cancellation)
if (generation !== this.generation || this.disposed) return this.current
if (!this.downloaded) throw new Error('desktop update: platform preparation did not report readiness')
this.downloadResult?.(true)
return this.setState({ phase: 'ready', version })
} catch (error) {
this.downloaded = false
if (generation !== this.generation || this.disposed) return this.current
this.release = { kind: 'checked', version }
this.downloadResult?.(false, error instanceof DesktopUpdatePreparationError ? error.kind : 'download_failed')
return this.setState(this.failure(error, 'download'))
}
@@ -126,16 +201,20 @@ export class DesktopUpdateCoordinator {
/**
* Install a prepared target after a separate user confirmation.
* @param version - Exact version displayed in the confirmation, never a renderer-selected URL.
* @param revision - Revision captured when the confirmation began; omitted uses the current target.
* @returns Installation handoff or a recoverable preparation error.
*/
async install(version: string): Promise<DesktopUpdateState> {
async install(version: string, revision = this.generation): Promise<DesktopUpdateState> {
this.assertLive()
if (revision !== this.generation) return this.current
if (this.configurationError !== undefined || this.switching > 0) throw new Error('desktop update: channel selection is not ready')
if (!this.downloaded || this.downloadOperation !== undefined || version !== this.candidate) throw new Error('desktop update: confirmed target is not ready')
this.installOperation ??= Promise.resolve().then(async () => {
this.setState({ phase: 'installing', version })
try {
if (!await this.beforeRestart()) return this.setState({ phase: 'ready', version })
this.assertLive()
this.release = { kind: 'handed-off', version }
this.updater.quitAndInstall(true, true)
return this.current
} catch (error) {
@@ -152,10 +231,14 @@ export class DesktopUpdateCoordinator {
this.updater.off('download-progress', this.onProgress)
this.updater.off('update-downloaded', this.onDownloaded)
// Pending updater promises can still emit EventEmitter errors during shutdown.
void Promise.allSettled([this.checkOperation, this.downloadOperation, this.installOperation])
void Promise.allSettled([this.checkOperation, this.downloadOperation, this.installOperation, this.switchTail])
.then(() => { this.updater.off('error', this.onError) })
}
private get candidate(): string | undefined { return this.release.kind === 'none' ? undefined : this.release.version }
private get downloaded(): boolean { return this.release.kind === 'downloaded' || this.release.kind === 'handed-off' }
private assertLive(): void {
if (this.disposed) throw new Error('desktop update: coordinator is disposed')
}
@@ -171,6 +254,7 @@ export class DesktopUpdateCoordinator {
private failure(error: unknown, failedOperation: 'check' | 'download' | 'install'): DesktopUpdateState {
return { phase: 'error', ...this.target(), failedOperation,
message: error instanceof Error ? error.message : String(error),
...(failedOperation === 'download' ? downloadFailureDetails(error) : {}),
...(error instanceof DesktopUpdatePreparationError ? {
preparationFailure: error.kind,
...(error.technicalDetails === undefined ? {} : { technicalDetails: error.technicalDetails }),
@@ -182,17 +266,22 @@ export class DesktopUpdateCoordinator {
}
private async doCheck(): Promise<DesktopUpdateState> {
const generation = this.generation
try {
this.assertLive()
if (!this.enabled()) throw new Error('desktop update: this application has no packaged update source')
const result = await this.updater.checkForUpdates()
if (generation !== this.generation || this.disposed) return this.current
if (result === null) throw new Error('desktop update: no check result was returned')
this.cancellation = result.cancellationToken
const version = result.updateInfo.version
if (valid(version) === null) throw new Error('desktop update: feed version is invalid')
if (this.channel === 'latest' && version.includes('-')) throw new Error('desktop update: latest feed contains a prerelease')
this.candidate = result.isUpdateAvailable && gt(version, this.currentVersion()) ? version : undefined
if (this.channel === 'stable' && version.includes('-')) throw new Error('desktop update: stable feed contains a prerelease')
this.release = result.isUpdateAvailable && gt(version, this.currentVersion())
? { kind: 'checked', version } : { kind: 'none' }
return this.setState(this.candidate === undefined ? { phase: 'idle' } : { phase: 'available', version })
} catch (error) {
if (generation !== this.generation || this.disposed) return this.current
return this.failure(error, 'check')
}
}
+43 -4
View File
@@ -8,6 +8,8 @@ export const UPDATE_DIALOG_IPC = { status: 'dsh-update-dialog:status', changed:
/** Text and choices supplied by the main process, never by product documents. */
export interface UpdateDialogView {
/** Button running a main-owned action; responses are disabled until it settles. */
readonly busyButton?: number
/** Identifies the displayed choices; a response from an older prompt is rejected. */
readonly revision: number
readonly locale: string
@@ -24,6 +26,10 @@ export interface UpdateDialogView {
/** Electron message options with separately expandable, main-owned diagnostics. */
export interface UpdateDialogOptions extends MessageBoxOptions {
readonly technicalDetails?: string
/** Synchronous auxiliary action that leaves the current prompt and choices open. */
readonly keepOpenAction?: { index: number; run: () => void }
/** Main-owned action kept in this dialog; false keeps the prompt open for correction. */
readonly action?: { index: number; run: () => Promise<boolean> }
}
/** The document can select only a displayed response index. */
@@ -43,7 +49,11 @@ export class DesktopUpdateDialog {
private view: DesktopUpdateOverlay | undefined
private parent: BrowserWindow | undefined
private closing: ReturnType<typeof setTimeout> | undefined
private active: { view: UpdateDialogView; finish: (index: number, retain?: boolean) => void } | undefined
private active: {
view: UpdateDialogView
finish: (index: number, retain?: boolean) => void
respond: (index: number) => Promise<void>
} | undefined
/** Focus the current explanation or confirmation without replacing it or granting permission. */
focus(): void {
@@ -71,7 +81,8 @@ export class DesktopUpdateDialog {
|| (index !== active.view.cancelId && (index < 0 || index >= active.view.buttons.length))) {
throw new Error('desktop update: invalid dialog response')
}
active.finish(index)
if (active.view.busyButton !== undefined) return
return active.respond(index)
})
}
@@ -95,7 +106,7 @@ export class DesktopUpdateDialog {
const surface = existing ?? this.overlays.create(parent, this.preload)
this.view = surface
this.parent = parent
const view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message,
let view: UpdateDialogView = { revision: ++this.revision, locale: locale.id, title: options.title ?? '', message: options.message,
detail: options.detail ?? '', buttons, cancelId, closeLabel: locale.messages.updateClose,
technicalDetails: options.technicalDetails ?? '', technicalDetailsLabel: locale.messages.updateTechnicalDetails }
return new Promise((resolve) => {
@@ -110,7 +121,35 @@ export class DesktopUpdateDialog {
}
resolve({ response, checkboxChecked: false })
}
this.active = { view, finish }
const respond = async (index: number): Promise<void> => {
const publish = (busy: boolean): void => {
const { busyButton: _busy, ...rest } = view
view = { ...rest, revision: ++this.revision, ...(busy ? { busyButton: index } : {}) }
if (this.active !== undefined) this.active.view = view
surface.webContents.send(UPDATE_DIALOG_IPC.changed, view)
}
if (options.keepOpenAction?.index === index) {
options.keepOpenAction.run()
publish(false)
return
}
if (options.action?.index !== index) { finish(index); return }
publish(true)
// Keep a fast local read perceptible without delaying the operation itself.
const held = new Promise<void>((resolveHold) => { setTimeout(resolveHold, 500) })
try {
const close = await options.action.run()
await held
if (this.active?.view !== view) return
if (close) finish(index)
else publish(false)
} catch (error) {
await held
if (this.active?.view === view) publish(false)
throw error
}
}
this.active = { view, finish, respond }
options.signal?.addEventListener('abort', abort, { once: true })
if (existing !== undefined) { surface.webContents.send(UPDATE_DIALOG_IPC.changed, view); return }
const failed = (): void => { if (this.view === surface) { this.cancel(); this.close() } }
+18 -1
View File
@@ -1,5 +1,5 @@
/** Localized update preparation failures with separately displayed diagnostics. */
import type { DesktopUpdatePreparationFailureKind } from './ipc.ts'
import type { DesktopUpdatePreparationFailureKind, DesktopUpdateState } from './ipc.ts'
/** Only explicitly safe main-process facts may be exposed as technical details. */
export class DesktopUpdatePreparationError extends Error {
@@ -10,3 +10,20 @@ export class DesktopUpdatePreparationError extends Error {
*/
constructor(readonly kind: DesktopUpdatePreparationFailureKind, message: string, readonly technicalDetails?: string) { super(message) }
}
/**
* Preserve current download diagnostics; never infer a cause from an older cleanup failure.
* @param error - Rejected updater download or filesystem error.
* @returns Structured filesystem cause when supplied by the failing operation.
*/
export function downloadFailureDetails(error: unknown): Pick<DesktopUpdateState, 'downloadFailure' | 'technicalDetails'> {
if (!(error instanceof Error) || !('code' in error) || typeof error.code !== 'string') return {}
const code = error.code
const kind = code === 'ENOSPC' ? 'space' : ['EACCES', 'EPERM', 'EROFS'].includes(code) ? 'permission' : code === 'EBUSY' ? 'busy' : undefined
const path = 'path' in error && typeof error.path === 'string' ? error.path : undefined
const destination = 'dest' in error && typeof error.dest === 'string' ? error.dest : undefined
return {
...(kind === undefined ? {} : { downloadFailure: kind }),
technicalDetails: [code, error.message, path, destination].filter(value => value !== undefined).join('\n'),
}
}
+6
View File
@@ -10,7 +10,9 @@ const NETWORK_FAILURE = /\b(?:ERR_CONNECTION_CLOSED|ERR_CONNECTION_RESET|ERR_INT
* @returns Stable presentation kind without raw diagnostics.
*/
function desktopUpdateFailureKind(state: DesktopUpdateState): DesktopUpdateFailureKind {
if (state.failedOperation === 'configuration') return 'configuration'
if (state.failedOperation === 'install' && state.preparationFailure !== undefined) return state.preparationFailure
if (state.failedOperation === 'download' && state.downloadFailure !== undefined) return `download-${state.downloadFailure}`
const operation = state.failedOperation ?? 'install'
return NETWORK_FAILURE.test(state.message ?? '') ? `${operation}-network` : operation
}
@@ -24,9 +26,13 @@ function desktopUpdateFailureKind(state: DesktopUpdateState): DesktopUpdateFailu
export function desktopUpdateErrorSummary(state: DesktopUpdateState, messages: DesktopMessages): string {
const kind = desktopUpdateFailureKind(state)
const summaries: Readonly<Record<DesktopUpdateFailureKind, string>> = {
configuration: messages.updateConfigurationFailed,
check: messages.updateCheckFailed,
'check-network': messages.updateCheckNetworkFailed,
download: messages.updateDownloadFailed,
'download-space': messages.updateDownloadSpaceFailed,
'download-permission': messages.updateDownloadPermissionFailed,
'download-busy': messages.updateDownloadBusyFailed,
'download-network': messages.updateDownloadNetworkFailed,
install: messages.updateInstallFailed,
'install-network': messages.updateInstallNetworkFailed,
+52
View File
@@ -0,0 +1,52 @@
/** Channel-specific default and failure policy over the shared shell settings document. */
import { DesktopSettingsFile, DesktopSettingsFileError } from './settings-file.ts'
import { appendFileSync } from 'node:fs'
import type { DesktopUpdateChannel } from './update-channel.ts'
/** Invalid explicit channels suspend ordinary updates; only absence uses the package default. */
export type DesktopUpdateSettingsResult =
| { kind: 'ready'; channel: DesktopUpdateChannel }
| { kind: 'error'; error: DesktopSettingsFileError }
/** No file watching; loading and explicit UI saves share shell storage and validation. */
export class DesktopUpdateSettings {
private readonly file: DesktopSettingsFile
readonly path: string
/**
* @param userData - Electron userData, independent of Host and DSH_HOME.
* @param packagedChannel - Default selected by the installer build input.
*/
constructor(userData: string, private readonly packagedChannel: DesktopUpdateChannel) {
this.file = new DesktopSettingsFile(userData)
this.path = this.file.path
}
/** @returns Startup channel or an error requiring suspension; never creates a missing file. */
async load(): Promise<DesktopUpdateSettingsResult> {
const result = await this.file.read()
if (result.kind === 'error') return result
if (result.kind === 'missing') return { kind: 'ready', channel: this.packagedChannel }
const channel = result.fields.channel
if (channel.kind === 'invalid') return { kind: 'error', error: channel.error }
return { kind: 'ready', channel: channel.kind === 'valid' ? channel.value : this.packagedChannel }
}
/**
* Save a selection while preserving other fields; never changes the runtime selection itself.
* @param channel - Validated UI selection.
* @returns After a confirmed write, including logged lock-release failures.
* Uncertain writes reject without retry; diagnostics are appended to settings.json.save.jsonl.
*/
async save(channel: DesktopUpdateChannel): Promise<void> {
try { await this.file.saveUpdates({ channel }) } catch (error) {
if (!(error instanceof DesktopSettingsFileError) || error.problem.kind !== 'write') throw error
const entry = { time: new Date().toISOString(), event: 'settings-save-failed', effect: error.problem.effect,
path: this.path, reason: error.cause instanceof Error ? error.cause.message : String(error.cause) }
try { appendFileSync(`${this.path}.save.jsonl`, `${JSON.stringify(entry)}\n`, { mode: 0o600, flush: true }) } catch (logError) {
console.error('desktop settings: could not persist save warning', entry, logError)
}
if (error.problem.effect !== 'written') throw error
}
}
}
@@ -0,0 +1,14 @@
/** Channel policy applied after electron-updater's channel setter. */
import type { AppUpdater } from 'electron-updater'
/**
* Select a feed without permitting downgrades or prereleases on stable.
* @param updater - Updater whose prior operations have settled.
* @param channel - Selected distribution channel.
*/
export function selectUpdateChannel(updater: AppUpdater, channel: 'nightly' | 'stable'): void {
updater.channel = channel
// The library channel setter enables downgrades.
updater.allowDowngrade = false
updater.allowPrerelease = channel === 'nightly'
}
@@ -0,0 +1,62 @@
/** Retires electron-updater 6.x targets only after owned checks and downloads settle. */
import type { Server } from 'node:http'
import { readdir, rm } from 'node:fs/promises'
import { join } from 'node:path'
import { app } from 'electron'
import { recordUpdateCleanupFailure } from '../../update-cleanup-log.ts'
import type { AppUpdater } from 'electron-updater'
/**
* Revoke the library's pending target and await macOS's local feed server closure.
* This adapter depends on private electron-updater 6.x fields, covered by real-library tests.
* autoInstallOnAppQuit must be false and quitAndInstall must not have been called.
* @param updater - Settled updater owned by the Desktop coordinator.
* @returns After old in-memory targets and the local feed server are retired; residual files only produce durable warnings.
*/
export async function discardDesktopUpdate(updater: AppUpdater): Promise<void> {
// The dependency omits these private fields from its public integration declarations.
const internal = updater as object as {
updateInfoAndProvider: unknown
downloadedUpdateHelper?: {
_file: string | null
_packageFile: string | null
versionInfo: unknown
fileInfo: unknown
_downloadedFileInfo: unknown
cacheDirForPendingUpdate: string
} | null
server?: Server | null
squirrelDownloadedUpdate?: boolean
}
if (internal.squirrelDownloadedUpdate === true) throw new Error('desktop update: native installation already staged')
const server = internal.server
if (server?.listening) {
await new Promise<void>((resolve, reject) => {
server.close((error) => { if (error === undefined) resolve(); else reject(error) })
})
}
internal.server = null
internal.updateInfoAndProvider = null
const helper = internal.downloadedUpdateHelper
if (helper == null) return
// Equivalent to the pinned helper's memory reset; its clear() silently swallows filesystem failures.
helper._file = null
helper._packageFile = null
helper.versionInfo = null
helper.fileInfo = null
helper._downloadedFileInfo = null
const directory = helper.cacheDirForPendingUpdate
let names: string[]
try { names = await readdir(directory) } catch (error) {
if (!(error instanceof Error && 'code' in error && error.code === 'ENOENT')) {
recordUpdateCleanupFailure(app.getPath('logs'), { operation: 'list', path: directory, error })
}
return
}
for (const name of names) {
const path = join(directory, name)
try { await rm(path, { recursive: true, force: true }) } catch (error) {
recordUpdateCleanupFailure(app.getPath('logs'), { operation: 'remove', path, error })
}
}
}
+2 -2
View File
@@ -21,8 +21,8 @@
en: 6f4de4503748f3e2
zh: 648370375f16e463
/desktop-local-verification/manual-walkthrough:
en: c4b4ee8763d8464a
zh: ee2159bc08966592
en: 1f333416e5a3fef1
zh: fe0f30418db59cb0
/desktop-local-verification/evidence:
en: dcb2473264c825d4
zh: cebb2832efa15974
+2
View File
@@ -65,6 +65,8 @@ With Host, client, and Desktop artifacts built, run `node --import tsx apps/desk
<a id="verification-evidence"></a>
After building Desktop, `node apps/desktop/scripts/test-update-channels.mjs` checks the pinned Electron updater against loopback feeds and downloaded inert files. It verifies channel filenames, downgrade prohibition, rollout eligibility, and retirement of actual download caches and stale confirmations after channel switching; on macOS it also closes the updater-created local handoff server. Windows pull-request CI runs this check after the blocking build. No installer, Host or remote publication runs. This qualification does not replace signed installed-app testing on Windows and macOS.
## Evidence
The local command builds Desktop and executes Electron 44 with the actual HTTP updater, policy client, sandboxed preload, and mandatory renderer. It records each scenario and preserves its report under `.desktop-build/qualification/local-updater-*`. The installer call, external browser, and clipboard are observation substitutes; downloaded bytes are not an executable installer.
+2
View File
@@ -65,6 +65,8 @@ Host、客户端与 Desktop 产物构建完成后,在 Windows 仓库根目录
<a id="verification-evidence"></a>
构建 Desktop 后,`node apps/desktop/scripts/test-update-channels.mjs` 使用本地回环清单和下载的无执行能力文件检查固定版本的 Electron updater。它验证渠道文件名、禁止降级、灰度命中,以及切换渠道后实际下载缓存和过期确认的撤销,在 macOS 上还关闭 updater 创建的本地交接服务。Windows pull request CI 在阻塞性构建后运行此检查。不运行安装器、Host 或远程发布。此验证不能替代 Windows 和 macOS 的签名安装应用测试。
## 验证证据
本地命令构建 Desktop,并在 Electron 44 中运行真实 HTTP updater、策略客户端、沙箱预加载和强更页面。它记录每个场景,并把报告保存在 `.desktop-build/qualification/local-updater-*`。安装器调用、外部浏览器和剪贴板均替换为观测记录;下载字节不是可执行安装器。
@@ -119,3 +119,14 @@ describe('desktop auto-update environment', () => {
expect(() => desktopUpdateMetadataFilename('1.2.3', 'linux')).toThrow(/unsupported metadata platform/u)
})
})
it.each([['darwin', 'arm64', 'mac-arm64', 'stable-mac.yml'], ['darwin', 'x64', 'mac-x64', 'stable-mac.yml'], ['win32', 'x64', 'win-x64', 'stable.yml']] as const)
('routes stable metadata and binaries separately for %s %s', (platform, arch, target, filename) => {
const config = resolveDesktopAutoUpdateConfig({ DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DSH_DESKTOP_UPDATE_CHANNEL: 'stable' }, platform, arch)
expect(config.channel).toBe('stable')
expect(config.keyPrefix).toBe(`dsh-desk/feeds/${target}`)
expect(config.binaryKeyPrefix).toBe(`dsh-desk/bin/${target}/stable`)
expect(desktopUpdateMetadataFilename('1.0.0', platform, config.channel)).toBe(filename)
expect(() => resolveDesktopAutoUpdateConfig({ DSH_DESKTOP_UPDATE_CHANNEL: 'latest' }, platform, arch)).toThrow(/channel/u)
})
@@ -6,16 +6,16 @@ import type { DesktopUploadPlan } from '../scripts/desktop-upload-plan.ts'
const source: DesktopRetainedSource = {
repository: 'owner/repo', runId: '20', attempt: '3', commit: 'a'.repeat(40), workflowCommit: 'b'.repeat(40),
sourceRef: 'dsh-v1.0.0', channel: 'latest', runnerName: 'windows-publisher', target: 'win-x64',
sourceRef: 'dsh-v1.0.0', channel: 'stable', runnerName: 'windows-publisher', target: 'win-x64',
preset: 'production', productVersion: '1.0.0', version: '1.0.0', testReleaseId: null, qualification: 'signed',
}
const plan: DesktopUploadPlan = {
environment: 'production', target: 'win-x64', version: '1.0.0', bucket: 'production-1234567890',
publicUrl: 'https://download.example.com/dsh-desk/feeds/win-x64/', secretIdEnvName: 'SECRET_ID', secretKeyEnvName: 'SECRET_KEY',
artifacts: [
{ path: '/not-read/installer.exe', filename: 'deepseek-harness-1.0.0-win-x64.exe', key: 'dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe',
{ path: '/not-read/installer.exe', filename: 'deepseek-harness-1.0.0-win-x64.exe', key: 'dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe',
contents: 'installer bytes', contentType: 'application/octet-stream', channelMetadata: false },
{ path: '/not-read/latest.yml', filename: 'latest.yml', key: 'dsh-desk/feeds/win-x64/latest.yml',
{ path: '/not-read/stable.yml', filename: 'stable.yml', key: 'dsh-desk/feeds/win-x64/stable.yml',
contents: 'version: 1.0.0\n', contentType: 'application/yaml', channelMetadata: true },
],
}
@@ -23,8 +23,8 @@ const plan: DesktopUploadPlan = {
it('shows production destinations and tag after approval with upload-byte fingerprints', async () => {
const summary = await desktopPublicationSummary(source, plan, 'c'.repeat(64))
await expect(summary).toMatchFileSnapshot('./expected/publication-production.md')
expect(summary).toContain('/latest/deepseek-harness-1.0.0-win-x64.exe')
expect(summary).toContain('https&#58;//download.example.com/dsh-desk/feeds/win-x64/latest.yml')
expect(summary).toContain('/stable/deepseek-harness-1.0.0-win-x64.exe')
expect(summary).toContain('https&#58;//download.example.com/dsh-desk/feeds/win-x64/stable.yml')
expect(summary).toContain(createHash('sha256').update('version: 1.0.0\n').digest('hex'))
expect(summary).not.toContain('测试 release ID |')
expect(summary).not.toContain('SECRET_KEY')
+7 -7
View File
@@ -55,17 +55,17 @@ it.each(['missing-file', 'changed-file', 'changed-channel'] as const)('rejects %
expect(f.writes).toEqual([])
})
it('restores nightly when latest already matches and resumes a partially restored pair', async () => {
for (const latestAlreadyMatches of [true, false]) {
it('restores nightly when stable already matches and resumes a partially restored pair', async () => {
for (const stableAlreadyMatches of [true, false]) {
const f = fixture()
const nightly = f.plan.artifacts[1]!
const plan = { ...f.plan, artifacts: [...f.plan.artifacts, { ...nightly, key: 'feed/latest.yml', filename: 'latest.yml' }] }
f.objects.set('feed/latest.yml', latestAlreadyMatches ? nightly.contents! : 'version: 2.0.0\n')
if (!latestAlreadyMatches) {
const plan = { ...f.plan, artifacts: [...f.plan.artifacts, { ...nightly, key: 'feed/stable.yml', filename: 'stable.yml' }] }
f.objects.set('feed/stable.yml', stableAlreadyMatches ? nightly.contents! : 'version: 2.0.0\n')
if (!stableAlreadyMatches) {
const put = f.operations.transport.put.bind(f.operations.transport)
const interrupted = { ...f.operations, transport: { ...f.operations.transport,
async put(...args: Parameters<typeof put>) {
if (args[0].key === 'feed/latest.yml') throw new Error('fixture interruption')
if (args[0].key === 'feed/stable.yml') throw new Error('fixture interruption')
return put(...args)
},
} }
@@ -73,7 +73,7 @@ it('restores nightly when latest already matches and resumes a partially restore
}
await expect(rollbackDesktopChannel(plan, '2.0.0', true, f.operations)).resolves.toMatchObject({ status: 'complete' })
expect(f.objects.get('feed/nightly.yml')).toBe(nightly.contents)
expect(f.objects.get('feed/latest.yml')).toBe(nightly.contents)
expect(f.objects.get('feed/stable.yml')).toBe(nightly.contents)
expect(f.writes.filter(key => key === 'feed/nightly.yml')).toHaveLength(1)
}
})
@@ -38,7 +38,7 @@ async function fixture(
target: DesktopPackageTargetName,
version = '1.2.3',
environment: 'test' | 'production' = 'test',
channel: 'nightly' | 'latest' = 'nightly',
channel: 'nightly' | 'stable' = 'nightly',
): Promise<Fixture> {
const root = await mkdtemp(join(tmpdir(), 'dsh-desktop-upload-'))
temporaryDirectories.push(root)
@@ -175,14 +175,14 @@ describe('desktop upload plan', () => {
.rejects.toThrow(failure === 'checksum' ? /SHA-512/u : /completion record/u)
})
it.each(['test', 'production'] as const)('isolates stable latest packages from nightly in %s', async (environment) => {
it.each(['test', 'production'] as const)('isolates stable-channel packages from nightly in %s', async (environment) => {
const nightly = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'nightly'))
const latest = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'latest'))
expect(latest.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['latest.yml'])
const stable = await createDesktopUploadPlan('win-x64', await fixture('win-x64', '1.2.3', environment, 'stable'))
expect(stable.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['stable.yml'])
expect(nightly.artifacts.filter(file => file.channelMetadata).map(file => file.filename)).toEqual(['nightly.yml'])
expect(latest.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true)
const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'latest')
await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/latest requires a stable version/u)
expect(stable.artifacts.every(file => !nightly.artifacts.some(other => other.key === file.key))).toBe(true)
const prerelease = await fixture('win-x64', '1.2.3-alpha.1', environment, 'stable')
await expect(createDesktopUploadPlan('win-x64', prerelease)).rejects.toThrow(/stable requires a stable version/u)
})
it('publishes fixed feeds referencing versioned binaries without overriding CDN cache policy', async () => {
@@ -7,10 +7,10 @@
| --- | --- |
| 发布环境 | <code>production</code> |
| 版本号 | <code>1.0.0</code> |
| 升级通道 | <code>latest</code> |
| 升级通道 | <code>stable</code> |
| 平台 | <code>win-x64</code> |
| 下载地址预览 | <a href="https&#58;//download.example.com/dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe">https&#58;//download.example.com/dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe</a><br>发布后的目标地址,审批前不保证可下载。 |
| 升级 YAML | <a href="https&#58;//download.example.com/dsh-desk/feeds/win-x64/latest.yml">https&#58;//download.example.com/dsh-desk/feeds/win-x64/latest.yml</a><br>本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 |
| 下载地址预览 | <a href="https&#58;//download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe">https&#58;//download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe</a><br>发布后的目标地址,审批前不保证可下载。 |
| 升级 YAML | <a href="https&#58;//download.example.com/dsh-desk/feeds/win-x64/stable.yml">https&#58;//download.example.com/dsh-desk/feeds/win-x64/stable.yml</a><br>本次发布将更新此文件;审批前可能不存在或仍指向旧版本。 |
| 打包来源 | <a href="https&#58;//github.com/owner/repo/actions/runs/20/attempts/3">查看打包运行 #20 · 第 3 次尝试</a><br>源码:<code>dsh-v1.0.0 @ aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa</code><br>打包机器:<code>windows-publisher</code> |
| 签名与公证 | EXE 签名校验通过 |
| Git tag | <code>desktop-v1.0.0</code> |
@@ -33,8 +33,8 @@ COS 存储桶:<code>production-1234567890</code>
| 文件 | 大小 | SHA-256 |
| --- | --- | --- |
| <a href="https&#58;//download.example.com/dsh-desk/bin/win-x64/latest/deepseek-harness-1.0.0-win-x64.exe">deepseek-harness-1.0.0-win-x64.exe</a> | 15 字节 | <code>e34210a6de4f653edf588301431c3d69a633638cbf587345cc50a7fed9f38f4c</code> |
| <a href="https&#58;//download.example.com/dsh-desk/feeds/win-x64/latest.yml">latest.yml</a> | 15 字节 | <code>8850e6075dda0080c526269a4de29848c32c2a65e3d81f4a64ed8ce53d4b47c1</code> |
| <a href="https&#58;//download.example.com/dsh-desk/bin/win-x64/stable/deepseek-harness-1.0.0-win-x64.exe">deepseek-harness-1.0.0-win-x64.exe</a> | 15 字节 | <code>e34210a6de4f653edf588301431c3d69a633638cbf587345cc50a7fed9f38f4c</code> |
| <a href="https&#58;//download.example.com/dsh-desk/feeds/win-x64/stable.yml">stable.yml</a> | 15 字节 | <code>8850e6075dda0080c526269a4de29848c32c2a65e3d81f4a64ed8ce53d4b47c1</code> |
</details>
+1 -1
View File
@@ -16,7 +16,7 @@ import { createUpdateServer } from './update-server.mjs'
import { DesktopMandatoryUpdatePolicy, resolveDesktopPolicyConfig } from '../../lib/types/mandatory-update-policy.js'
import { DesktopMandatoryUpdateWindow } from '../../lib/types/mandatory-update-window.js'
import { DesktopUpdatePreparationError } from '../../lib/types/update-error.js'
import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js'
import { DesktopUpdateHttpExecutor } from '../../lib/types/updates/electron-updater/http-executor.js'
import { DesktopUpdateOverlays } from '../../lib/types/update-overlay.js'
import { qualifyUpdateDialogs } from './update-dialogs.mjs'
+1 -1
View File
@@ -5,7 +5,7 @@ import { join } from 'node:path'
import { app } from 'electron'
import updaterModule from 'electron-updater'
import { DesktopUpdateCoordinator } from '../../lib/types/update-coordinator.js'
import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js'
import { DesktopUpdateHttpExecutor } from '../../lib/types/updates/electron-updater/http-executor.js'
import { resolveWindowsUpdatePublisher } from '../../scripts/windows-sign.mjs'
import { artifactDigest, createArtifactUpdateServer } from './artifact-update-server.mjs'
+1 -1
View File
@@ -10,7 +10,7 @@ import { app, BrowserWindow, Menu, webContents } from 'electron'
import updaterModule from 'electron-updater'
import { createUpdateServer } from './update-server.mjs'
import { fixture } from './workspace-update-adapters.mjs'
import { DesktopUpdateHttpExecutor } from '../../lib/types/update-http-executor.js'
import { DesktopUpdateHttpExecutor } from '../../lib/types/updates/electron-updater/http-executor.js'
import { desktopUpdateReadyConfirmation, resolveDesktopLocale } from '../../lib/types/locale.js'
const root = process.env.DSH_WORKSPACE_UPDATE_ROOT
+6
View File
@@ -3,6 +3,12 @@ import { describe, expect, it } from 'vitest'
import { en, formatDesktopMessage, resolveDesktopLocale, resolveDesktopStartupLocale, ja, zh, es, fr, de, ru, pt } from '../src/locale.ts'
describe('desktop locale dictionaries', () => {
it('uses the same concise Chinese configuration error in the toast and details', () => {
for (const message of [zh.updateConfigurationRead, zh.updateConfigurationFormat, zh.updateConfigurationChannel]) {
expect(message).toBe('配置文件异常,更新已暂停')
}
})
it('ships baseline and Japanese keys and falls back for unsupported languages', () => {
expect(Object.keys(zh)).toEqual(Object.keys(en))
expect(Object.keys(ja)).toEqual(Object.keys(en))
@@ -66,10 +66,10 @@ describe('macOS packaged updater configuration', () => {
})
it('seals latest into the macOS updater file and rejects a different expected channel', async () => {
it('seals stable into the macOS updater file and rejects a different expected channel', async () => {
const paths = await fixture()
const latest = resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'latest' }])
await writeMacOSAppUpdateConfig(paths.resourcesDir, latest, 'fixture-updater')
await expect(verifyMacOSAppUpdateConfig(paths.appPath, latest)).resolves.toBeUndefined()
const stable = resolveMacOSAppUpdateFeed([{ provider: 'generic', url: update.publicUrl, channel: 'stable' }])
await writeMacOSAppUpdateConfig(paths.resourcesDir, stable, 'fixture-updater')
await expect(verifyMacOSAppUpdateConfig(paths.appPath, stable)).resolves.toBeUndefined()
await expect(verifyMacOSAppUpdateConfig(paths.appPath, update)).rejects.toThrow(/does not match/u)
})
+266 -12
View File
@@ -72,8 +72,8 @@ const harness = await vi.hoisted(async () => {
})
const loginShell = vi.fn((base: NodeJS.ProcessEnv, _config: unknown, _options: { signal?: AbortSignal }) => readLoginShell(base))
const updateCheck = vi.fn(async (_manual?: boolean): Promise<DesktopUpdateState> => updateState)
const updateDownload = vi.fn(async (_version: string): Promise<DesktopUpdateState> => updateState)
const updateInstall = vi.fn(async (_version: string): Promise<DesktopUpdateState> => updateState)
const updateDownload = vi.fn(async (_version: string, _revision?: number): Promise<DesktopUpdateState> => updateState)
const updateInstall = vi.fn(async (_version: string, _revision?: number): Promise<DesktopUpdateState> => updateState)
const popup = vi.fn<(options: { window: FakeWindow; x?: number; y?: number; callback?: () => void }) => void>()
const menuBuilder = vi.fn<(template: MenuItemConstructorOptions[]) => { popup: typeof popup }>(() => ({ popup }))
const menu = Object.assign(menuBuilder, { buildFromTemplate: menuBuilder, setApplicationMenu: vi.fn() })
@@ -243,6 +243,7 @@ const harness = await vi.hoisted(async () => {
set publishUpdate(value: (state: DesktopUpdateState) => DesktopUpdateState) { publishUpdate = value },
dialog: { showOpenDialog: vi.fn(), showErrorBox: vi.fn(), showMessageBox: vi.fn() },
openExternal: vi.fn(async () => {}),
showItemInFolder: vi.fn(),
protocolHandle: vi.fn<(scheme: string, handler: (request: Request) => Response | Promise<Response>) => void>(),
applyRelease: vi.fn(() => { preparing.resolve(); return prepared.promise }),
disableAllPlugins: vi.fn(async () => {
@@ -308,7 +309,7 @@ vi.mock('electron', () => ({
BrowserWindow: harness.FakeWindow,
WebContentsView: harness.FakeView,
dialog: harness.dialog,
shell: { openExternal: harness.openExternal },
shell: { openExternal: harness.openExternal, showItemInFolder: harness.showItemInFolder },
nativeTheme: harness.nativeTheme,
ipcMain: {
on: harness.ipcOn,
@@ -370,10 +371,28 @@ vi.mock('../src/update-dialog.ts', () => ({ DesktopUpdateDialog: class {
dispose() {}
} }))
vi.mock('../src/update-coordinator.ts', () => ({ DesktopUpdateCoordinator: class {
constructor(publish: (state: DesktopUpdateState) => DesktopUpdateState, beforeRestart: () => Promise<boolean>) {
selectedChannel: 'nightly' | 'stable' = 'nightly'
revision = 0
configurationFailure: 'startup' | 'cleanup' | undefined
constructor(publish: (state: DesktopUpdateState) => DesktopUpdateState, beforeRestart: () => Promise<boolean>,
_updater: unknown, _enabled: unknown, _version: unknown, _downloadResult: unknown, channel: 'nightly' | 'stable') {
this.selectedChannel = channel
harness.prepareUpdate = beforeRestart
harness.publishUpdate = publish
}
async switchChannel(channel: 'nightly' | 'stable', persist: () => Promise<void>) {
await persist()
this.selectedChannel = channel
this.configurationFailure = undefined
this.revision++
harness.updateState = { phase: 'idle' }
return 'applied' as const
}
suspendConfiguration(detail: string, reason: 'startup' | 'cleanup' = 'startup') {
this.configurationFailure = reason
harness.updateState = { phase: 'error', failedOperation: 'configuration', message: detail }
harness.publishUpdate(harness.updateState)
}
get state() { return harness.updateState }
readonly check = harness.updateCheck
readonly download = harness.updateDownload
@@ -493,6 +512,219 @@ describe('desktop main startup', () => {
})
})
it('persists a channel through product IPC and ignores external edits during the process', async () => {
configureTestAuthPopup(true)
await readyForUpdate()
expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'nightly', changing: false })
expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toEqual({ kind: 'applied', state: { channel: 'stable', packagedChannel: 'nightly', changing: false } })
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
expect(JSON.parse(readFileSync(path, 'utf8'))).toEqual({ updates: { allowTestAuthPopupWindow: true, channel: 'stable' } })
writeFileSync(path, JSON.stringify({ updates: { channel: 'nightly' } }))
expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable' })
expect(harness.windows[0]!.webContents.send).toHaveBeenCalledWith(DESKTOP_IPC.updatesChannelChanged,
{ channel: 'stable', packagedChannel: 'nightly', changing: false })
await expect(invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'alpha')).rejects.toThrow('unsupported channel')
expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable' })
})
it('reports a corrupt document without replacing it or pretending a UI save succeeded', async () => {
await readyForUpdate()
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
writeFileSync(path, '{broken')
expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ kind: 'failed', reason: 'format', state: { channel: 'nightly', changing: false } })
expect(readFileSync(path, 'utf8')).toBe('{broken')
expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).not.toHaveProperty('configurationError')
})
it('applies a committed selection even when storage lock release fails', async () => {
await readyForUpdate()
const { DesktopSettingsFile, DesktopSettingsFileError } = await import('../src/settings-file.ts')
const save = vi.spyOn(DesktopSettingsFile.prototype, 'saveUpdates').mockRejectedValueOnce(
new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect: 'written' }, '/settings.json'))
try {
expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({
kind: 'applied', state: { channel: 'stable', changing: false },
})
expect(save).toHaveBeenCalledTimes(1)
} finally { save.mockRestore() }
})
it('suspends updates after an uncertain save and leaves recovery explicit', async () => {
await readyForUpdate()
harness.updateState = { phase: 'ready', version: '1.0.1' }
const { DesktopUpdateSettings } = await import('../src/update-settings.ts')
const { DesktopSettingsFileError } = await import('../src/settings-file.ts')
const save = vi.spyOn(DesktopUpdateSettings.prototype, 'save').mockRejectedValueOnce(
new DesktopSettingsFileError({ kind: 'write', operation: 'save', effect: 'unknown' }, '/settings.json'))
try {
expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({
kind: 'failed', reason: 'uncertain', state: { channel: 'nightly', changing: false, error: 'read' },
})
expect(harness.updateState).toMatchObject({ phase: 'error', failedOperation: 'configuration' })
expect(harness.updateInstall).not.toHaveBeenCalled()
expect(save).toHaveBeenCalledTimes(1)
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
const disk = JSON.stringify({ updates: { channel: 'stable' }, keep: true })
writeFileSync(path, disk)
harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => {
expect(await options.action!.run()).toBe(true)
return { response: 0 }
})
await invoke(DESKTOP_IPC.updatesOpen, 'app')
const recovered = await invoke(DESKTOP_IPC.updatesChannelGet, 'app')
expect(recovered).toMatchObject({ channel: 'stable', changing: false })
expect(recovered).not.toHaveProperty('error')
expect(readFileSync(path, 'utf8')).toBe(disk)
expect(save).toHaveBeenCalledTimes(1)
} finally { save.mockRestore() }
})
it('keeps the previous ready update installable after a channel save fails', async () => {
await readyForUpdate()
harness.updateState = { phase: 'ready', version: '1.0.1' }
writeFileSync(join(harness.app.getPath('userData'), 'desktop', 'settings.json'), '{broken')
await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')
await invoke(DESKTOP_IPC.updatesOpen, 'app')
expect(harness.updateInstall).toHaveBeenCalledWith('1.0.1', 0)
})
it('keeps startup channel errors visible and recovers after a valid UI selection', async () => {
configureTestAuthPopup(false)
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
writeFileSync(path, JSON.stringify({ updates: { channel: 'unknown' } }))
await readyForUpdate()
expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: null, error: 'channel' })
expect(harness.hosts).toHaveLength(1)
expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toEqual({ kind: 'applied', state: { channel: 'stable', packagedChannel: 'nightly', changing: false } })
})
it('keeps committed settings after apply failure and recovers through explicit reload', async () => {
await readyForUpdate()
const { DesktopUpdateCoordinator } = await import('../src/update-coordinator.ts')
vi.spyOn(DesktopUpdateCoordinator.prototype, 'switchChannel').mockImplementationOnce(async function (this: InstanceType<typeof DesktopUpdateCoordinator>, _channel, persist) {
await persist()
this.suspendConfiguration('cache retirement failed', 'cleanup')
throw new Error('cache retirement failed')
})
expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({
kind: 'failed', reason: 'apply', state: { channel: 'nightly', error: 'cleanup' },
})
expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).toMatchObject({ configurationError: 'cleanup' })
await invoke(DESKTOP_IPC.updatesOpen, 'app')
const options = harness.dialog.showMessageBox.mock.lastCall![0] as import('../src/update-dialog.ts').UpdateDialogOptions
expect(options.message).toBe(en.updateConfigurationCleanup)
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
expect(options.detail).toBe(path)
const saved = readFileSync(path, 'utf8')
expect(JSON.parse(saved)).toMatchObject({ updates: { channel: 'stable' } })
expect(await options.action!.run()).toBe(true)
expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable', changing: false })
expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).not.toHaveProperty('error')
expect(readFileSync(path, 'utf8')).toBe(saved)
})
it('recovers invalid settings from the mandatory block without bypassing that block', async () => {
configureTestAuthPopup(false)
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
writeFileSync(path, '{broken')
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
vi.stubGlobal('fetch', vi.fn<typeof fetch>().mockImplementation(async () => Response.json({ code: 40005,
data: { show_content: { title: 'Update', detail: 'Required' }, desktop_app_link: 'https://downloads.example.com/' } })))
await readyForUpdate()
await harness.policyBlocked.promise
const modal = harness.windows[0]!
const owned = { sender: modal.webContents, senderFrame: modal.webContents.mainFrame }
harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => {
options.keepOpenAction!.run()
return { response: 2 }
})
await harness.handlers.get(MANDATORY_IPC.action)!(owned, 'refresh')
expect(harness.showItemInFolder).toHaveBeenCalledWith(path)
expect(harness.updateDownload).not.toHaveBeenCalled()
harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => {
expect(options.detail).toBe(path)
expect(await options.action!.run()).toBe(false)
writeFileSync(path, JSON.stringify({ updates: { channel: 'stable' } }))
expect(await options.action!.run()).toBe(true)
return { response: 0 }
})
await harness.handlers.get(MANDATORY_IPC.action)!(owned, 'refresh')
expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ channel: 'stable' })
expect(harness.handlers.get(MANDATORY_IPC.status)!(owned)).toMatchObject({ policy: { blocking: true } })
expect(harness.updateDownload).not.toHaveBeenCalled()
})
it('checks mandatory policy immediately after a successful channel switch', async () => {
harness.embeddedPolicy = { origin: 'https://policy.example.com', allowedPageOrigins: ['https://downloads.example.com'] }
const checked = Promise.withResolvers<undefined>()
const request = vi.fn<typeof fetch>().mockImplementation(async (url, options) => {
if (url instanceof URL && url.searchParams.get('scenario') === 'channel-change') {
expect(new Headers(options?.headers).get('x-client-update-channel')).toBe('stable')
checked.resolve(undefined)
}
return Response.json({ code: 0, data: { biz_code: 0 } })
})
vi.stubGlobal('fetch', request)
await readyForUpdate()
expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({ kind: 'applied' })
await checked.promise
})
it('offers the actual settings file for malformed JSON without suggesting channel reselection', async () => {
configureTestAuthPopup(false)
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
writeFileSync(path, '{broken')
await readyForUpdate()
harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => {
options.keepOpenAction!.run()
return { response: 2 }
})
await invoke(DESKTOP_IPC.updatesOpen, 'app')
expect(harness.dialog.showMessageBox.mock.lastCall![0]).toMatchObject({
message: en.updateConfigurationFormat, detail: path,
buttons: [en.updateReloadSettings, en.updateShowSettingsFile, en.updateClose], cancelId: 2,
})
expect(en.updateConfigurationFormat).not.toContain('select')
expect(harness.showItemInFolder).toHaveBeenCalledWith(path)
expect(readFileSync(path, 'utf8')).toBe('{broken')
})
it('rereads a repaired file without writing it or restarting the shell', async () => {
configureTestAuthPopup(false)
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
writeFileSync(path, '{broken')
await readyForUpdate()
harness.dialog.showMessageBox.mockImplementationOnce(async (options: import('../src/update-dialog.ts').UpdateDialogOptions) => {
expect(await options.action!.run()).toBe(false)
expect(await invoke(DESKTOP_IPC.updatesChannelGet, 'app')).toMatchObject({ error: 'format', changing: false })
const repaired = JSON.stringify({ updates: { channel: 'stable' }, retained: 'keep' })
writeFileSync(path, repaired)
expect(await options.action!.run()).toBe(true)
expect(readFileSync(path, 'utf8')).toBe(repaired)
return { response: 0 }
})
await invoke(DESKTOP_IPC.updatesOpen, 'app')
const state = await invoke(DESKTOP_IPC.updatesChannelGet, 'app')
expect(state).toMatchObject({ channel: 'stable', changing: false })
expect(state).not.toHaveProperty('error')
})
it('shows the invalid startup field and file path until a valid channel is applied', async () => {
configureTestAuthPopup(false)
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
writeFileSync(path, JSON.stringify({ updates: { channel: 'unknown' } }))
await readyForUpdate()
await invoke(DESKTOP_IPC.updatesOpen, 'app')
expect(harness.dialog.showMessageBox.mock.lastCall![0]).toMatchObject({
message: en.updateConfigurationChannel, detail: path,
})
writeFileSync(path, '{broken')
expect(await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')).toMatchObject({
kind: 'failed', reason: 'format', state: { channel: null, error: 'channel' },
})
expect(await invoke(DESKTOP_IPC.updatesStatus, 'app')).toMatchObject({ configurationError: 'channel' })
})
it('starts the Host with authentication popups disabled when local settings are invalid', async () => {
configureTestAuthPopup(false)
const path = join(harness.app.getPath('userData'), 'desktop', 'settings.json')
@@ -822,7 +1054,7 @@ describe('desktop main startup', () => {
await vi.advanceTimersByTimeAsync(0)
harness.updateState = { phase: 'available', version: '1.0.1-nightly.1' }
await invoke(DESKTOP_IPC.updatesOpen, 'app')
expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1')
expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1', 0)
expect(testAuth.login).not.toHaveBeenCalled()
})
@@ -881,7 +1113,7 @@ describe('desktop main startup', () => {
it('accepts product IPC only from the current application top frame in the owned main window', async () => {
await readyForUpdate()
const sender = harness.windows[0]!.webContents
for (const channel of [DESKTOP_IPC.updatesStatus, DESKTOP_IPC.deviceInfo]) {
for (const channel of [DESKTOP_IPC.updatesStatus, DESKTOP_IPC.updatesChannelGet, DESKTOP_IPC.deviceInfo]) {
const handler = harness.handlers.get(channel)!
expect(() => handler({ sender, senderFrame: sender.mainFrame })).not.toThrow()
for (const event of [
@@ -1577,12 +1809,12 @@ describe('desktop main startup', () => {
window.visible = false
const opened = invoke(DESKTOP_IPC.updatesOpen, 'app') as Promise<void>
await vi.advanceTimersByTimeAsync(0)
expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1')
expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1', 0)
expect(harness.updateInstall).not.toHaveBeenCalled()
window.visible = true
window.emit('show')
await opened
expect(harness.updateInstall).toHaveBeenCalledWith('1.0.1')
expect(harness.updateInstall).toHaveBeenCalledWith('1.0.1', 0)
})
it('waits for Platform view storage cleanup before an ordinary quit completes', async () => {
@@ -1777,6 +2009,28 @@ describe('desktop main startup', () => {
}))
})
it.each([
{ phase: 'available', version: '1.0.3' },
{ phase: 'idle' },
{ phase: 'error', failedOperation: 'check', message: 'Old feed unavailable' },
] satisfies DesktopUpdateState[])('discards a manual check result after switching channel: $phase', async (result) => {
await readyForUpdate()
const checked = Promise.withResolvers<DesktopUpdateState>()
const requested = Promise.withResolvers<undefined>()
harness.updateCheck.mockImplementationOnce(() => { requested.resolve(undefined); return checked.promise })
harness.dialog.showMessageBox.mockResolvedValue({ response: 0 })
const action = applicationMenuItems().find(item => item.label === 'Check for Updates…')
Reflect.apply(action!.click!, undefined, [])
const prompt = Promise.resolve(invoke(DESKTOP_IPC.updatesOpen, 'app'))
await requested.promise
await invoke(DESKTOP_IPC.updatesChannelSet, 'app', 'stable')
checked.resolve(result)
await prompt
expect(harness.dialog.showMessageBox).toHaveBeenCalledTimes(1)
expect(harness.updateDownload).not.toHaveBeenCalled()
expect(harness.updateInstall).not.toHaveBeenCalled()
})
it('reports a manual check failure without offering a download', async () => {
await readyForUpdate()
harness.updateCheck.mockResolvedValueOnce({ phase: 'error', failedOperation: 'check', message: 'Feed unavailable' })
@@ -1809,7 +2063,7 @@ describe('desktop main startup', () => {
await invoke(DESKTOP_IPC.updatesOpen, 'app')
const messages = harness.dialog.showMessageBox.mock.calls.map(call => (call.at(-1) as { message: string }).message)
expect(messages).not.toContain(en.mandatoryUnavailable)
expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1')
expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1', 0)
})
it('does not wait for a pending policy request before proceeding with an ordinary update', async () => {
@@ -1823,7 +2077,7 @@ describe('desktop main startup', () => {
await invoke(DESKTOP_IPC.updatesOpen, 'app')
})
try {
await vi.waitFor(() => { expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1') })
await vi.waitFor(() => { expect(harness.updateDownload).toHaveBeenCalledWith('1.0.1-nightly.1', 0) })
} finally {
policy.resolve(Response.json({ code: 500 }, { status: 503 }))
await operation
@@ -1910,11 +2164,11 @@ describe('desktop main startup', () => {
await started.promise
const repeated = invoke(DESKTOP_IPC.updatesOpen, 'app')
expect(harness.dialog.showMessageBox).not.toHaveBeenCalled()
expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1')
expect(harness.updateDownload).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1', 0)
expect(harness.updateInstall).not.toHaveBeenCalled()
downloading.resolve({ phase: 'ready', version: '1.0.1-nightly.1' })
await Promise.all([action, repeated])
expect(harness.updateInstall).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1')
expect(harness.updateInstall).toHaveBeenCalledExactlyOnceWith('1.0.1-nightly.1', 0)
await harness.updateCheck()
expect(harness.updateInstall).toHaveBeenCalledOnce()
})
@@ -77,6 +77,24 @@ describe('mandatory update policy', () => {
expect(headers[1]).toMatchObject({ 'x-client-locale': 'zh_CN', 'x-client-timezone-offset': '28800' })
})
it('checks the selected channel after an old-channel request finishes', async () => {
let channel: 'nightly' | 'stable' = 'nightly'
const old = Promise.withResolvers<Response>()
const request = vi.fn<typeof fetch>().mockReturnValueOnce(old.promise).mockResolvedValue(Response.json(clear))
const policy = new DesktopMandatoryUpdatePolicy(resolveDesktopPolicyConfig(deployment)!,
{ ...identity, get channel() { return channel } }, () => {}, request, () => client)
instances.push(policy)
const first = policy.check('launch')
channel = 'stable'
const second = policy.check('channel-change', true)
old.resolve(Response.json(force))
await first
expect(await second).toMatchObject({ blocking: false })
expect(request).toHaveBeenCalledTimes(2)
expect(request.mock.calls.map(([, options]) => new Headers(options?.headers).get('x-client-update-channel')))
.toEqual(['nightly', 'stable'])
})
it('coalesces manual checks and honors the interval on foreground/resume', async () => {
const { policy, request } = fixture()
const pending = Promise.withResolvers<Response>()
Loaded 100 of 922 files, more files were not shown because too many files have changed in this diff. Show more