🔀 Sync master and resolve sidebar column comment

This commit is contained in:
imccyu
2026-10-28 11:31:29 +08:00
1631 changed files with 122333 additions and 5477 deletions
@@ -6,8 +6,8 @@
en: 982ac05aa02da2f0
zh: 225f89c994efb1c4
/agent-note-awaited-per-agent-initialization/problem:
en: 03e6b75365b29406
zh: cc880a33cf2303d0
en: 6e9393e78ff71286
zh: b931a7e606cad37e
/agent-note-awaited-per-agent-initialization/decision:
en: 48cdb570d3feb222
zh: 904a1169540e3111
@@ -15,5 +15,5 @@
en: 466a9ebbb13f68c6
zh: ab88fb34786d2c2b
/agent-note-awaited-per-agent-initialization/consequences:
en: d8cfe8d88ccacfc8
zh: 8d2aa21408e6e40e
en: 3fc2b36a94ace3ba
zh: 65bb386b89ff89ce
@@ -6,7 +6,7 @@ English | [中文](2026-09-09-awaited-agent-creation.zh.md)
## Problem
Shared presets install tools and prompt sections separately for each Agent. That installation can await plugin activation, and external SessionStart hooks can produce context asynchronously. A creator must know that these contributions have finished before the Agent's first model request.
Agent-specific plugins can install tools asynchronously, and external SessionStart hooks can produce context asynchronously. A creator must know that these contributions have finished before the Agent's first model request.
## Decision
@@ -28,4 +28,6 @@ This decision owns asynchronous creation timing. The [scope runtime reference](.
Creation latency includes asynchronous plugin and SessionStart work. Initializer failures become caller-visible creation failures, and cancellation relies on listeners settling cooperatively. Notifications already delivered cannot be undone; rollback pairs them with disposal notifications.
Scoped installer tests verify rollback, lifecycle tests verify ordered completion and cancellation, and the SDK serial-created scenario verifies that asynchronous prompt context reaches the first request. Hook tests cover awaited context injection and subprocess disposal.
Lifecycle tests verify initialization rollback, ordered completion and cancellation, and the SDK serial-created scenario verifies that asynchronous prompt context reaches the first request. Hook tests cover awaited context injection and subprocess disposal.
Delegation tools use [shared preset registrations](../bug-fix/2026-10-25-shared-preset-delegation-tools.md); they do not require per-Agent installation. This event still owns asynchronous Agent initialization.
@@ -6,7 +6,7 @@ Status: implemented
## 问题
共享 preset 为每个 Agent(智能体)分别安装工具与提示词段。安装可能要等待插件激活,外部 SessionStart 钩子也可能异步产生上下文。创建方必须知道这些贡献已完成,才能允许 Agent 发起首次模型请求。
针对单个 Agent(智能体)的插件可能异步安装工具,外部 SessionStart 钩子也可能异步产生上下文。创建方必须知道这些贡献已完成,才能允许 Agent 发起首次模型请求。
## 决策
@@ -28,4 +28,6 @@ AgentLoop 在 setup 与创建分发期间保留现有的维护活动。初始化
创建延迟包括异步插件与 SessionStart 工作。初始化器失败成为调用方可见的创建失败,取消依赖监听器协作结算。已经送达的通知无法撤回;回滚会为其配对销毁通知。
作用域安装器测试验证回滚,生命周期测试验证有序完成与取消,SDK serial-created 场景验证异步提示词上下文进入首次请求。钩子测试覆盖需等待的上下文注入与子进程 dispose。
生命周期测试验证初始化回滚、有序完成与取消,SDK serial-created 场景验证异步提示词上下文进入首次请求。钩子测试覆盖需等待的上下文注入与子进程 dispose。
委派工具使用[预设共享注册](../bug-fix/2026-10-25-shared-preset-delegation-tools.zh.md),无须按 Agent 安装。此事件仍负责异步 Agent 初始化。
@@ -9,11 +9,11 @@
en: b32a7ce6483b9b48
zh: ec0938ad032b99d8
/agent-note-application-consent-for-native-computer-use/decision:
en: 28ec4cc587ba487b
zh: 8b89bbdff8eff6eb
en: b34d24e39879277e
zh: 03363665d4682ddb
/agent-note-application-consent-for-native-computer-use/alternatives-considered:
en: 9f41ecc5e34efe17
zh: b30ba6dae25b20b2
en: 18b2c124f1d4e601
zh: 621a6c9512d5612b
/agent-note-application-consent-for-native-computer-use/consequences:
en: 4bda3366af65250a
zh: 7c83561b4db07284
@@ -14,9 +14,9 @@ The CUA executor owns application consent for every object-API and native-tool d
System-permission buttons call the native helper on the DSH host and remain available to remote browsers. The [usage documentation](../../../../packages/experimental/cua/README.md#use-this-package) explains where to complete authorization; the page has no persistent remote-access notice.
A saved identity combines the canonical executable path with a verified designated signing requirement, represented by a SHA-256 digest. The path prevents authorization from following a copied application, and the signing requirement distinguishes replacements while allowing matching signed updates. Unverifiable and ad-hoc applications receive session access only, tied to a process instance. Labels do not establish identity.
Application identity is owned by [bundle-ID consent](2026-10-25-cua-bundle-id-consent.md), which supersedes this note's original path/signature choice. That original choice prevented copied or replaced applications from inheriting access, at the cost of separate grants per installation and no saved grants for unsigned applications. Enforcement and human decision ownership remain as described here.
Cold application selection prepares an executable identity and handle without launching. The same consent decision guards the private launch continuation, and the helper checks the executable again before launch and binds the resulting process afterward. Signed selections retain the normal designated-requirement identity; an unverifiable executable uses a content identity for this pending selection and cannot create a saved grant. Preparing metadata before asking avoids starting an application the user refuses.
Cold application selection prepares an executable identity and handle without launching. The same consent decision guards the private launch continuation, and the helper checks the executable again before launch and binds the resulting process afterward. Selections with a bundle identifier retain that identity after launch; an application without one uses its canonical executable path before and after launch and can save that grant. Preparing metadata before asking avoids starting an application the user refuses.
Conversation decisions survive provider disposal and DSH restart according to [profile consent storage](2026-10-19-cua-profile-consent-storage.md). That decision owns persistence, migration and cross-process revocation. The executor continues to serialize application selections within a conversation, refuse repeated prompts after denial, and give an explicit long-term grant precedence over conversation refusals.
@@ -26,7 +26,7 @@ Revocation invalidates the Agent connections that retain the application's handl
**Trust model instructions or hide controls.** Retained objects and direct tool calls can still reach native operations. The executor must enforce the decision at dispatch.
**Persist application names or unsigned paths.** A replacement can reuse both. Durable grants require a verifiable signing identity; session access supports unsigned development applications without that promise.
**Persist application names or unsigned paths.** A replacement can reuse both. [Bundle-ID consent](2026-10-25-cua-bundle-id-consent.md) owns the identity choices and the decision to share permission between installations.
**Add an approval database or Session control stream.** User Questions already owns interactive answers. The existing domain service provides durable state without introducing a dedicated database or Session control stream; storage tradeoffs are owned by the profile consent decision.
@@ -14,9 +14,9 @@ CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。
系统权限按钮调用运行 DSH 的电脑上的原生 helper,远程访问时仍可使用。授权地点写在[使用说明](../../../../packages/experimental/cua/README.zh.md#use-this-package)中,页面不常驻远程访问提示。
长期身份由规范化可执行文件路径和经过验证的签名指定要求组成,以 SHA-256 摘要表示。路径阻止许可跟随应用副本,签名要求区分替换程序,并允许符合相同签名要求的更新。签名不可验证或使用 ad-hoc 签名的应用只能获得会话授权,许可绑定进程实例。显示名称不用于确定身份。
应用身份由[Bundle ID 授权](2026-10-25-cua-bundle-id-consent.zh.md)负责,取代本记录原先的路径和签名选择。原选择防止应用副本或替换程序继承许可,代价是各安装副本必须分别授权,未签名应用无法保存长期许可。执行检查和用户决定的所有权仍由本记录规定。
选择尚未运行的应用时,先准备可执行文件身份和句柄,不立即启动。同一授权决定保护内部启动续接,helper 在启动前再次核对可执行文件,并在启动后绑定实际进程。已签名应用沿用签名指定要求身份;签名不可验证的程序使用本次待启动选择的内容身份,不能生成长期许可。先准备元数据再询问,可以避免启动用户拒绝的应用。
选择尚未运行的应用时,先准备可执行文件身份和句柄,不立即启动。同一授权决定保护内部启动续接,helper 在启动前再次核对可执行文件,并在启动后绑定实际进程。有 Bundle ID 的应用在启动后沿用该身份;没有 Bundle ID 的程序在启动前后使用规范化可执行路径,并可保存该许可。先准备元数据再询问,可以避免启动用户拒绝的应用。
会话决定按[profile 授权存储](2026-10-19-cua-profile-consent-storage.zh.md)的规则,在提供方卸载和 DSH 重启后保留。该记录负责持久化、迁移和跨进程撤销。执行器继续将同一对话对同一应用的选择串行化,拒绝后不再次询问,显式长期许可优先于会话拒绝。
@@ -26,7 +26,7 @@ CUA 执行器在每次对象 API 和原生工具调用时检查应用授权。
**依赖模型指引或隐藏控件。** 保留的对象和直接工具调用仍能到达原生操作。执行器必须在派发时检查用户决定。
**保存应用名称或未签名路径。** 替换程序可以复用二者。长期许可需要可验证的签名身份;会话授权支持未签名的开发应用,但不承诺跨进程复用。
**保存应用名称或未签名路径。** 替换程序可以复用二者。身份选择和在安装副本之间共用许可的决定由[Bundle ID 授权](2026-10-25-cua-bundle-id-consent.zh.md)负责。
**增加审批数据库或 Session 控制流。** User Questions 已经负责交互式回答。现有域服务提供持久状态,无须增加专用数据库或 Session 控制流;存储取舍由 profile 授权存储决策负责。
@@ -9,8 +9,8 @@
en: 386864a282e5ad58
zh: a4ea75ec70eb7778
/agent-note-shared-native-cua-process-with-agent-scoped-state/decision:
en: ebf231a6aad109bb
zh: 53b553e679569da1
en: 11bab6870c3d802f
zh: a22114f2217a3a41
/agent-note-shared-native-cua-process-with-agent-scoped-state/alternatives-considered:
en: 883f71387f599160
zh: 4b5f1450be799397
@@ -12,6 +12,8 @@ On macOS 26.6.2, replayd derives capture-client identity from the executable pat
One user shares a native service per executable content digest across Agents and Hosts. A version lock elects the service; an additional canonical-executable-path lock prevents different builds published at one path from running together. Locks remain held until process exit. The managed startup command hands service lifetime to its client connections, and the last connection's completed cleanup ends the service. The diagnostic stdin/stdout entry proxies to this same service.
The service runs as a signed application launched through LaunchServices. Directly spawning the executable inherits its launcher’s TCC responsibility, so it cannot give the helper independent system permissions. A launch lock serializes discovery and startup. Complete signed applications use immutable content-addressed directories: replacing an executable used by a live process would violate both path-lock ownership and code-signature integrity. Ad-hoc builds can require fresh grants when their code hash changes; a stable bundle identifier alone does not preserve those grants. Installed packages retain their distributed signatures.
Each Agent connection owns its application handles, AX snapshots, limits and cancellation state. An app handle grants no exclusive use of the application. A FIFO orders complete desktop operations, including clipboard transactions, while response writes are independent. Agent disposal retires only its connection. The [object API decision](2026-09-17-experimental-cua-object-api.md) retains evaluator independence and coordinate semantics; the [consent decision](2026-09-26-cua-application-consent.md) retains application authorization.
Cancellation and timeout first stop one request and wait for acknowledgement. A timed-out screenshot retains the single system-RPC slot until the real callback completes; new screenshots fail with `CAPTURE_BUSY` instead of accumulating requests. Late pixels are discarded, while other AX and input operations remain available. Ending an await or creating a wrapper does not prove that the system connection has recovered. An operation or cleanup that cannot stop can invalidate the process, making every old handle unusable. Uncertain input is never replayed automatically.
@@ -12,6 +12,8 @@ Status: implemented
同一用户的多个 Agent 和 Host 按可执行文件内容摘要共享原生服务。版本锁选出服务实例,规范化可执行路径上的另一把锁阻止发布在同一路径的不同构建同时运行。进程退出才释放锁。受管启动命令将服务生命期交给客户端连接,最后一条连接清理完成后服务退出。诊断用 stdin/stdout 入口也代理到同一个服务。
服务通过 LaunchServices 以已签名应用运行。直接启动可执行文件会继承启动器的 TCC 责任,无法让 helper 独立取得系统权限。启动锁串行处理发现与启动。完整签名应用保存在不可变的内容寻址目录中,替换运行中进程的可执行文件会破坏路径锁的归属与代码签名完整性。临时签名构建的代码哈希变化后可能需要重新授权,固定 Bundle ID 不能保证保留授权。安装版保留分发时的签名。
每条 Agent 连接独立持有应用句柄、AX 快照、资源限制和取消状态。持有 app 句柄不代表独占应用。FIFO 队列串行执行包含剪贴板事务的完整桌面操作,响应写入独立进行。Agent 销毁只释放自己的连接。[对象接口决策](2026-09-17-experimental-cua-object-api.zh.md)继续负责执行器独立性及坐标语义,[授权决策](2026-09-26-cua-application-consent.zh.md)继续负责应用许可。
取消或超时先停止单次请求并等待确认。截图超时后,唯一的系统 RPC 槽位保留到真实回调结束;新截图返回 `CAPTURE_BUSY`,不继续堆积请求。迟到图片被丢弃,其他 AX 与输入操作仍可执行。结束 await 或创建包装对象不能证明系统连接已恢复。操作或清理无法停止时,才可能使进程失效,届时所有旧句柄均不可用。结果不确定的输入不会自动重放。
@@ -9,8 +9,8 @@
en: f2c70036a57ddb41
zh: 9bbbf3dd09d486f5
/agent-note-durable-computer-use-consent-within-one-profile/decision:
en: 1df102f3d0440a6a
zh: 3456af8e552729a3
en: 11622523090f5d88
zh: 1d0f8ad94645f069
/agent-note-durable-computer-use-consent-within-one-profile/alternatives-considered:
en: 2af74afa3ecaf3b9
zh: 96f7f43c5d1150df
@@ -10,7 +10,7 @@ Application grants stored as plugin configuration make each authorization change
## Decision
The CUA owner uses the existing domain storage service, with one domain per canonical profile directory. It stores long-term grants and conversation decisions by native application identity. Conversation IDs scope permission without turning it into permission for every conversation. The native identity policy remains owned by [application consent](2026-09-26-cua-application-consent.md); this note replaces only that note's storage and restart-lifetime choices.
The CUA owner uses the existing domain storage service, with one domain per canonical profile directory. It stores long-term grants and conversation decisions by native application identity. Conversation IDs scope permission without turning it into permission for every conversation. The native identity policy remains owned by [bundle-ID consent](2026-10-25-cua-bundle-id-consent.md); this note replaces only that note's storage and restart-lifetime choices.
Every operation opens fresh domain state while holding the profile's existing file-lock primitive and closes the domain before releasing the lock. Native dispatch and revocation use a separate lock for the selected application. Storage reads and unrelated applications remain available during a native call. Revocation cancels this process's affected work first, then commits after other processes' already-admitted operations have finished. A completed revocation therefore leaves no operation using the old authority. App-wide revocation generations and per-conversation decision revisions prevent an old handle or delayed answer from restoring revoked access.
@@ -10,7 +10,7 @@ Status: implemented
## 决策
CUA 使用现有域存储服务,每个 profile 的真实目录对应独立存储域,按本机应用身份保存长期许可和会话决定。对话 ID 限定权限范围,不把本会话许可扩展为所有对话的许可。本机身份规则仍由[应用授权](2026-09-26-cua-application-consent.zh.md)负责,本记录仅替代其中的存储和重启后有效期选择。
CUA 使用现有域存储服务,每个 profile 的真实目录对应独立存储域,按本机应用身份保存长期许可和会话决定。对话 ID 限定权限范围,不把本会话许可扩展为所有对话的许可。本机身份规则仍由[Bundle ID 授权](2026-10-25-cua-bundle-id-consent.zh.md)负责,本记录仅替代其中的存储和重启后有效期选择。
每个操作使用现有文件锁,在 profile 锁内打开并读取最新存储状态,关闭域后才释放锁。原生操作和撤销使用单独的目标应用锁;原生调用期间仍可读取存储或访问其他应用。撤销先取消本进程中受影响的工作,再等待其他进程已获准执行的操作结束后提交。因此,撤销完成后不会留下仍使用旧授权的操作。应用级撤销代次和各对话的决定版本,阻止旧句柄或迟到回答恢复被撤销的访问权限。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-25-cua-bundle-id-consent.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-25-cua-bundle-id-consent.md
/agent-note-macos-consent-shared-by-bundle-identifier:
en: 41cd1b8e2629e049
zh: f1bcef434b6d247f
/agent-note-macos-consent-shared-by-bundle-identifier/problem:
en: bb387fc89c1c578c
zh: 947456baea45cc95
/agent-note-macos-consent-shared-by-bundle-identifier/decision:
en: 7b1bbab1f95b7610
zh: 434e7a3fba81174b
/agent-note-macos-consent-shared-by-bundle-identifier/alternatives-considered:
en: 637b1fd2bafb1b28
zh: 06d882818ffaf800
/agent-note-macos-consent-shared-by-bundle-identifier/consequences:
en: 1af6d29a57ff86e2
zh: 32517607b2fc935f
@@ -0,0 +1,33 @@
# Agent Note: macOS consent shared by bundle identifier
Status: implemented
English | [中文](2026-10-25-cua-bundle-id-consent.zh.md)
## Problem
Users who install multiple versions of one application want one permission decision for that software. Executable paths split those decisions, and signing checks exclude unsigned development applications from saved access.
## Decision
macOS application consent uses the exact, case-sensitive native bundle identifier. The helper hashes `macos:bundle-id:` followed by that identifier into `CuaApplicationId`. When a bundle identifier exists, installation paths, versions and signatures do not distinguish grants. Otherwise, the helper hashes `macos:executable-path:` followed by the canonical executable path. Both identities support saved access; path identities survive restart and in-place updates, while a different path requires a new decision. Conversation decisions and saved grants retain the scopes and revocation behavior of [profile consent storage](2026-10-19-cua-profile-consent-storage.md).
This supersedes the executable/signature identity choice in [application consent](2026-09-26-cua-application-consent.md); that note still owns enforcement and human decisions. The running process path comes from the operating system. A valid runtime bundle identifier does not depend on path-query success. Missing paths omit optional grant metadata; they never substitute a cached catalog path. When both bundle identity and process path are unavailable, consent fails with the path query cause. Process identity must still be verified, and any available process path must match the selected target. Missing catalog path metadata does not block consent when the process path is available. If the runtime bundle identifier is unavailable, a reported application bundle can supply it only when its main executable matches the process path; otherwise the executable path identifies the grant. Native process, executable-location and window checks continue to bind each handle to its selected target. Launch checks reread the bundle identifier and executable path from disk; they do not fingerprint executable content. Replacing bytes at the same path while retaining the identifier or path identity therefore retains consent, including during an approval wait.
The identity prefixes separate new grants from old path/signature, process-instance and executable-content digests. Old grants remain removable but do not grant access to new bundle identities; users decide again before permission covers other installations.
Apple’s [PPPC configuration](https://support.apple.com/guide/deployment/privacy-preferences-policy-control-payload-dep38df53c2a/web) supports bundle-ID or path selectors and separately requires a code-signing requirement. [TN3127](https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements) explains that designated requirements track code identity across versions, while unsigned and ad-hoc code cannot provide the same stability. DSH adopts the selector alternatives for application management without requiring publisher authentication.
## Alternatives considered
**Bundle identifier and path.** This distinguishes installed copies but requires separate decisions for the multiple versions the user wants to authorize together.
**Bundle identifiers only.** Missing identifiers would exclude otherwise usable applications from saved access. Canonical executable paths supply a stable fallback without using a PID or content hash.
**Verified signing identity.** This distinguishes publishers but denies saved access when signing is absent or cannot be verified. Publisher authentication is outside this application-management policy.
## Consequences
Another program declaring the same bundle identifier or replacing an executable at its approved fallback path inherits consent, including a program created or changed through separately authorized model tools. The worst consequence is observing or controlling an application that the user did not approve separately, within its available data and capabilities. The model cannot supply the authorization identity through the CUA request; the native helper reads it from the selected application. Different bundle identifiers require different decisions, even when users consider them versions of one product.
Revocation applies to every installation sharing the identifier. Tests exercise unsigned copies, different identifiers, missing identifiers with persistent path consent, process restarts, symlink aliases, pending and running identity agreement, shared handle revocation and refusal to widen old grants.
@@ -0,0 +1,33 @@
# Agent Note: 按 Bundle ID 共用 macOS 应用授权
Status: implemented
[English](2026-10-25-cua-bundle-id-consent.md) | 中文
## 问题
用户安装同一应用的多个版本时,希望对这个软件只做一次权限决定。可执行文件路径会拆分这些决定,签名检查也会让未签名的开发版应用无法保存长期授权。
## 决策
macOS 应用授权使用原生应用提供的、区分大小写的完整 Bundle ID。helper 将 `macos:bundle-id:` 和该标识连接后计算摘要,生成 `CuaApplicationId`。有 Bundle ID 时,安装路径、版本和签名不区分授权。否则,helper 对 `macos:executable-path:` 与规范化可执行路径计算摘要。两种身份都支持长期许可;路径身份在重启和原地更新后保持不变,另一个路径需要新的决定。会话决定和长期许可保留[profile 授权存储](2026-10-19-cua-profile-consent-storage.zh.md)规定的范围和撤销行为。
本决策取代[应用授权](2026-09-26-cua-application-consent.zh.md)中的可执行文件和签名身份选择,原记录仍负责执行检查与用户决定。运行进程的可执行路径由操作系统提供。有效的运行时 Bundle ID 不依赖路径查询成功。无法取得路径时省略授权记录中的可选路径信息,不用缓存的应用目录路径代替。Bundle ID 和实际路径均不可用时,授权失败并报告路径查询原因。进程身份仍须验证,可取得的实际路径仍须与所选目标一致。只要能取得实际进程路径,应用目录信息中缺少路径不会阻止授权。运行时无法取得 Bundle ID 时,仅在其报告的应用 bundle 主可执行文件与进程路径一致时读取该标识,否则按可执行路径识别授权。原生进程、可执行文件位置和窗口检查继续把每个句柄绑定到所选目标。启动检查从磁盘重新读取 Bundle ID 和可执行路径,但不计算可执行内容指纹。因此,在相同路径下替换内容且保留相同标识或路径身份时,许可仍会沿用,包括等待用户批准期间的替换。
身份前缀将新授权与旧的路径和签名、进程实例、可执行内容摘要分开。旧授权仍可撤销,但不能授予新 Bundle ID 身份访问权限;许可覆盖其他安装副本前,需要用户重新决定。
Apple 的 [PPPC 配置](https://support.apple.com/guide/deployment/privacy-preferences-policy-control-payload-dep38df53c2a/web)支持 Bundle ID 或路径选择条件,同时另行要求代码签名条件。[TN3127](https://developer.apple.com/documentation/technotes/tn3127-inside-code-signing-requirements)说明了指定要求如何跨版本识别代码,以及未签名和临时签名代码无法提供同等稳定性。DSH 采用这两种选择条件管理应用,不要求验证发布者身份。
## 考虑过的替代方案
**Bundle ID 加路径。** 可以区分安装副本,但用户希望共同授权的多个版本仍需分别确认。
**仅使用 Bundle ID。** 缺少标识会让本可操作的应用无法获得长期许可。规范化可执行路径提供稳定的后备标识,无须使用进程号或内容摘要。
**验证签名身份。** 可以区分发布者,但没有签名或验签失败时无法保存长期授权。发布者身份验证不属于本项应用管理规则。
## 后果
其他程序声明相同 Bundle ID,或替换路径授权对应的可执行文件时,会继承许可,包括通过其他已授权模型工具创建或修改的程序。最坏后果是在应用已有数据和能力范围内,读取或控制用户未单独批准的程序。模型不能在 CUA 请求中提交授权身份,该身份由原生 helper 从实际选中的应用读取。不同 Bundle ID 仍需分别授权,即使用户将它们视为同一产品的不同版本。
撤销作用于同一标识的所有安装副本。测试覆盖未签名副本、不同标识、缺少标识时的持久路径许可、进程重启、符号链接、启动前后身份一致、多个句柄共同撤销,以及不扩大旧授权。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-09-20-unambiguous-tool-arguments.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/bug-fix/2026-09-20-unambiguous-tool-arguments.md
/agent-note-unambiguous-tool-argument-compatibility:
en: dcb2b9835f9ee758
zh: 68be9713f74571c4
/agent-note-unambiguous-tool-argument-compatibility/problem:
en: c389c332bdadbbe4
zh: 217520278ccf0932
/agent-note-unambiguous-tool-argument-compatibility/decision:
en: 71c04efcfd972b02
zh: d1f842920b1b0e3f
/agent-note-unambiguous-tool-argument-compatibility/alternatives-considered:
en: b2a96695819a978f
zh: c6f84b244be0dddf
/agent-note-unambiguous-tool-argument-compatibility/consequences:
en: bd24ad737c617c90
zh: dbb7fa67c41af828
@@ -0,0 +1,25 @@
# Agent Note: Unambiguous tool argument compatibility
Status: implemented
English | [中文](2026-09-20-unambiguous-tool-arguments.zh.md)
## Problem
Models can populate optional fields that do not affect the requested operation. Rejecting these calls adds retries without protecting any state.
## Decision
`grep` treats an exact empty `include` as omitted. Whitespace-only, negated, and comma-separated filters remain invalid. `update_goal` uses replacement fields only for `edit` and a blocker reason only for `blocked`; other actions ignore those known fields. Declared types, consumed values, authority, and exact revisions remain validated. Ignored fields never reach another goal operation or change unrelated state, and call summaries select only values used by the action. `write` and `edit` allow absent or blank justification when no sandbox mode is requested or the effective mode is repeated; actual widening retains its reason and approval requirements, and filesystem observation and denial checks remain active.
This partially supersedes the unused-field rejection in the [goal-tool decision](../feature/2026-07-19-model-facing-goal-tools.md). Its authority, lifecycle, scheduling, and persistence decisions remain active. The rules apply to every provider and stay inside the owning tool rather than weakening its underlying service.
## Alternatives considered
- **Complete strict-schema adaptation first** — deferred because it is a separate provider-wide change; these cases have deterministic meanings and direct regression evidence.
- **Normalize all empty, null, or zero values** — rejected because such values can be meaningful input, including empty file contents and numeric limits.
- **Reject every populated inactive field** — rejected because the explicit action determines the goal operation, and unused fields cannot alter it.
## Consequences
The tools accept these calls without extra mutations. Unit tests cover ignored fields for every goal action, consumed-field and authority failures, stale revisions, filter validation, and real search results. A headless recorded-session scenario verifies both file extensions are searched and completion preserves the original objective and round cap. These rules give up detecting conflicting intent expressed only in inactive goal fields; they do not weaken authorization or establish a generic coercion policy.
@@ -0,0 +1,25 @@
# Agent Note: 无歧义的工具参数兼容
Status: implemented
[English](2026-09-20-unambiguous-tool-arguments.md) | 中文
## 问题
模型可能填写不影响所请求操作的可选字段。拒绝这些调用只会增加重试,并不保护任何状态。
## 决策
`grep` 将值恰好为空字符串的 `include` 视为省略。仅含空白、否定形式和逗号分隔的过滤器仍然无效。`update_goal` 仅在 `edit` 时使用替换字段,仅在 `blocked` 时使用阻塞原因;其他 action 忽略这些已知字段。声明的类型、实际使用的值、权限和精确修订号仍会校验。被忽略的字段不会传入另一种目标操作,也不会改变无关状态;调用摘要仅选择当前 action 使用的值。未请求沙箱模式或重复当前生效模式时,`write` 和 `edit` 允许省略理由或提供空白理由;真正升权仍需理由和审批,文件系统观察与拒绝检查继续生效。
本决策部分取代[目标工具决策](../feature/2026-07-19-model-facing-goal-tools.zh.md)中对无关字段的拒绝规则。其权限、生命周期、调度和持久化决策继续生效。这些规则适用于所有提供方,并保留在所属工具内,不削弱底层服务的校验。
## 考虑过的替代方案
- **先完成严格 schema 适配**——暂缓,因为这是单独的提供方级改造;这些情况具有确定含义和直接的回归证据。
- **统一规范化所有空值、null 或零值**——不予采纳,因为这些值可能是有意义的输入,包括空文件内容和数值限制。
- **拒绝所有已填写的非当前 action 字段**——不予采纳,因为显式 action 决定目标操作,而未使用的字段无法改变该操作。
## 后果
工具接受这些调用,且不会产生额外变更。单元测试覆盖每种目标 action 对无关字段的忽略、有效字段与权限失败、陈旧修订号、过滤器校验以及真实搜索结果。headless 会话回放验证两种扩展名的文件都参与搜索,并且完成操作保留原始目标和轮数上限。这些规则放弃检测仅由非当前 action 的目标字段表达的冲突意图;它们不削弱授权,也不建立通用类型转换策略。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-25-shared-preset-delegation-tools.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/bug-fix/2026-10-25-shared-preset-delegation-tools.md
/agent-note-shared-preset-delegation-tools:
en: 2f33b8a19d8291b8
zh: 4f49de632ac53d01
/agent-note-shared-preset-delegation-tools/problem:
en: 3150532ae6efd14c
zh: 3c3d662c5bcb323b
/agent-note-shared-preset-delegation-tools/decision:
en: ccaa0792d54fe79d
zh: b2993214c35c1db0
/agent-note-shared-preset-delegation-tools/alternatives-considered:
en: 2f53caf2af750178
zh: cd4d9b823a9348dc
/agent-note-shared-preset-delegation-tools/consequences:
en: 4aaaaec2038b3f1d
zh: b52bdd02118ffb6f
@@ -0,0 +1,31 @@
# Agent Note: Shared preset delegation tools
Status: implemented
English | [中文](2026-10-25-shared-preset-delegation-tools.zh.md)
## Problem
Agents sharing a preset need the same delegation definitions immediately after a preset switch. Installing identical tools separately for each Agent introduces an asynchronous visibility gap and places those definitions in the Agent's own registration layer, which is exempt from inherited-tool restrictions.
## Decision
Each tool-subagent instance registers its definition in its owning composition. Agents sharing a preset revision inherit that registration. Switching presets changes ancestry; it requires no per-Agent installation or tool-change reconciliation. Provider availability and the owning composition's disposal control registration lifetime.
Tool configuration validates its own fields, allowlist consistency, required services and provider capabilities. Candidate preset documents are not read during registration, for either Host-mounted or preset-mounted tools. The delegation executor validates the model's selection against the allowlist; child startup resolves the selected id against the current registry. A later declaration can therefore satisfy a previously unavailable target without reinstalling the tool. Independent selection and restoration retain the [spawn preset semantics](../feature/2026-09-20-spawn-agent-preset-selection.md).
The schema contains preset ids and default or inheritance guidance without candidate descriptions. This keeps shared definitions independent of other presets' declaration order and avoids retaining document text that can become stale. [Awaited Agent initialization](../architecture/2026-09-09-awaited-agent-creation.md) remains necessary for other asynchronous contributions.
## Alternatives considered
**Reinstall tools for each Agent.** Identical definitions gain extra installation, disposal and reconciliation work, and Agent-local ownership prevents inherited-tool restrictions from applying.
**Validate every target at tool registration.** A target declared later is rejected even though it can be available when used. An early successful lookup also cannot guarantee availability after registry changes.
**Retain candidate descriptions in shared schemas.** Reading descriptions during registration makes the result depend on declaration order. Keeping those descriptions current requires additional update coordination; the tool exposes ids instead.
## Consequences
Unknown or unloadable targets fail the selected delegation without leaving a child Agent or Session. An unused invalid reference remains undetected. Agent restrictions and restored historical filters apply to inherited delegation schemas, execution and guidance; sibling Agents retain their tools.
Focused preset-selection tests cover immediate switch visibility, shared ownership and disposal, restrictions, missing targets and retry after declaration. The Web Loader cases cover shipped composition compatibility, and the headless preset snapshot pins model-visible choices. The historical-filter tests exercise child composition directly; they do not establish cold-resume coverage.
@@ -0,0 +1,31 @@
# Agent Note: 预设共享委派工具
Status: implemented
[English](2026-10-25-shared-preset-delegation-tools.md) | 中文
## 问题
共享预设的 Agent(智能体)需要在预设切换完成后立即取得相同的委派定义。为每个 Agent 分别安装相同工具会产生异步可见性缺口,并将定义放在 Agent 自身的注册层,而该层不受继承工具限制约束。
## 决策
每个 tool-subagent 实例在所属组装中注册定义。共享同一预设代际的 Agent 继承该注册。切换预设只改变祖先关系,无须按 Agent 安装或协调工具变更。提供方可用性及所属组装的 dispose(资源释放)决定注册的生命周期。
工具配置校验自身字段、白名单一致性、必需服务及提供方能力。Host 挂载和预设内挂载的工具都不在注册时读取候选预设文档。委派执行器按白名单校验模型选择,子级启动时再从当前注册表解析所选 id。因此,后续声明能够使原先不可用的目标变得可用,无须重新安装工具。独立选择与恢复保留 [spawn 预设语义](../feature/2026-09-20-spawn-agent-preset-selection.zh.md)。
schema 包含预设 id 及默认或继承提示,不包含候选描述。这使共享定义不依赖其他预设的声明顺序,也避免保留可能陈旧的文档文本。其他异步贡献仍需要[等待 Agent 初始化](../architecture/2026-09-09-awaited-agent-creation.zh.md)。
## 考虑过的替代方案
**为每个 Agent 重新安装工具。** 相同定义需要额外的安装、释放与协调工作,Agent 本地所有权还会阻止继承工具限制生效。
**在工具注册时校验所有目标。** 后声明的目标即使在使用时可用,也会被拒绝。提前查找成功同样无法保证注册表变更后的可用性。
**在共享 schema 中保留候选描述。** 注册时读取描述会使结果依赖声明顺序。保持描述同步需要额外的更新协调;工具选择公开 id。
## 后果
未知或无法加载的目标会使选中它的委派失败,不残留子 Agent 或 Session。未使用的无效引用仍不会被发现。Agent 限制及恢复的历史过滤器约束继承的委派 schema、执行与指导文本;同级 Agent 保留各自的工具。
针对性预设选择测试覆盖切换完成时的即时可见性、共享所有权与释放、限制、缺失目标及补声明后重试。Web Loader 用例覆盖 shipped 组装兼容性,headless 预设快照固定模型可见选项。历史过滤器测试直接执行子级组装,不证明冷恢复覆盖。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-26-malformed-charset-declaration.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/bug-fix/2026-10-26-malformed-charset-declaration.md
/agent-note-malformed-charset-recovery-prefers-utf-8:
en: d8a759b84c36b308
zh: 3b6ff2eb5519afb1
/agent-note-malformed-charset-recovery-prefers-utf-8/problem:
en: dce96445b3801a7f
zh: e026d26fa680556a
/agent-note-malformed-charset-recovery-prefers-utf-8/decision:
en: ba74ad57757231ca
zh: 46999cd0100814ae
/agent-note-malformed-charset-recovery-prefers-utf-8/alternatives-considered:
en: 9fafffb6a7a78a29
zh: ee1dcc27d0315e68
/agent-note-malformed-charset-recovery-prefers-utf-8/consequences:
en: c8aad3cc3f02e81e
zh: 29d9313afd1c7b5b
@@ -0,0 +1,33 @@
# Agent Note: Malformed charset recovery prefers UTF-8
Status: implemented
English | [中文](2026-10-26-malformed-charset-declaration.zh.md)
## Problem
A response can declare `charset=utf-8,gbk` over readable UTF-8 HTML. Passing that entire parameter to `TextDecoder` rejects the page before reading it. Compatibility needs an explicit encoding preference because successful decoding alone cannot identify the server's intended encoding.
## Decision
`decoderForCharset` preserves valid single-label declarations and the absent-header UTF-8 default. If a declaration is unsupported as a whole, it checks its comma- and whitespace-separated labels for UTF-8, including standard aliases. A match selects a fatal UTF-8 decoder; otherwise the declaration fails before the response body is read and the provider cancels the stream.
The provider decodes once. Recovery rejects invalid UTF-8 with `WEB_UNSUPPORTED_CONTENT_TYPE` and preserves the decoding error as its cause. Only an actual byte-cap truncation enables streaming decode, which omits an incomplete suffix and keeps `truncated: true`. Complete responses, including those exactly at the cap, validate the final sequence at EOF.
UTF-8 recovery is an explicit heuristic, not proof of the original encoding. The bytes `C2 A9` represent `©` in UTF-8 and `漏` in GBK; repeating them preserves that ambiguity at any length. The provider accepts that ambiguity for UTF-8 recovery but does not extend it to arbitrary legacy-encoding guesses.
## Alternatives considered
**Choose the first declared encoding that decodes.** A GBK body can also decode as `windows-1252` with different text. Declaration order does not resolve the conflict, and single-byte encodings can turn a decoding failure into silent corruption. Rejected.
**Use strict probes followed by ordinary decoding.** This decodes successful bodies twice. An unflushed streaming probe also accepts an incomplete EOF sequence that the ordinary decoder replaces. One fatal decode with explicit truncation state is sufficient.
**Read BOM and HTML meta declarations.** This would add HTML encoding identification and a dependency or parser, with a separate policy for missing metadata and non-HTML content. It remains deferred; the bounded UTF-8 preference resolves the reported response without claiming browser-equivalent detection.
## Consequences
- Malformed declarations containing UTF-8 can return readable text; a failed UTF-8 recovery never falls back to another encoding or inserts replacement characters.
- Malformed declarations without a UTF-8 label remain unsupported, including repeated legacy aliases. Valid single-label legacy decoding is unchanged.
- [`fetch-http.spec.ts`](../../../../packages/web/web-fetch-http/tests/fetch-http.spec.ts) covers recovery, conflicting labels, invalid bytes, EOF, truncation, and response-stream cancellation.
- The recorded [`web-fetch`](../../../../snapshots/session/web-fetch/snapshot.yml) scenario serves literal UTF-8 characters with `charset=gbk,utf-8` through the real HTTP provider and verifies the same model-visible text.
- HTML `<meta charset>` remains ignored. The package README documents the remaining ambiguity.
@@ -0,0 +1,33 @@
# Agent Note: 畸形 charset 声明的恢复优先使用 UTF-8
Status: implemented
[English](2026-10-26-malformed-charset-declaration.md) | 中文
## Problem
响应可能在可读的 UTF-8 HTML 上声明 `charset=utf-8,gbk`。把整个参数交给 `TextDecoder` 会在读取前拒绝页面。兼容处理需要明确的编码优先规则,因为解码成功本身无法识别服务器原本使用的编码。
## Decision
`decoderForCharset` 保留合法的单标签声明,以及未声明时默认使用 UTF-8 的行为。如果整个声明不受支持,它检查按逗号和空白分隔的标签是否包含 UTF-8(含标准别名)。匹配时选择 fatal UTF-8 解码器;否则在读取正文前拒绝声明,由提供方取消响应流。
提供方只解码一次。恢复路径以 `WEB_UNSUPPORTED_CONTENT_TYPE` 拒绝非法 UTF-8,并保留解码错误作为 cause。只有真正触发字节上限截断时才启用流式解码,省略不完整的末尾序列并保持 `truncated: true`。完整响应(包括恰好达到上限的响应)在 EOF 校验末尾序列。
UTF-8 恢复是明确的启发式规则,不是原始编码的证明。字节 `C2 A9` 在 UTF-8 下表示 `©`,在 GBK 下表示 `漏`;重复这些字节后,任意长度都保留该歧义。提供方为 UTF-8 恢复接受这种歧义,但不将其扩展为任意传统编码的猜测。
## Alternatives considered
**选择声明中第一个能解码的编码。** GBK 正文也可能按 `windows-1252` 解出不同文本。声明顺序不能解决冲突,单字节编码还可能把解码失败变成静默的内容损坏。拒绝。
**先严格探测,再常规解码。** 这会对成功的正文解码两次。未结束的流式探测还会接受 EOF 处不完整的序列,随后被常规解码器替换。一次 fatal 解码配合显式截断状态已经足够。
**读取 BOM 和 HTML meta 声明。** 这会增加 HTML 编码识别能力及依赖或解析器,还需单独规定缺少元数据和非 HTML 内容的策略。继续延期;有范围的 UTF-8 优先规则可以处理所报告的响应,无需宣称与浏览器等价的编码检测。
## Consequences
- 含有 UTF-8 的畸形声明可以返回可读文本;UTF-8 恢复失败时不会回退到其他编码,也不会插入替换字符。
- 不含 UTF-8 标签的畸形声明仍不受支持,包括重复的传统编码别名。合法的单标签传统编码解码保持不变。
- [`fetch-http.spec.ts`](../../../../packages/web/web-fetch-http/tests/fetch-http.spec.ts) 覆盖恢复、冲突标签、非法字节、EOF、截断和响应流取消。
- 已录制的 [`web-fetch`](../../../../snapshots/session/web-fetch/snapshot.yml) 场景通过真实 HTTP 提供方,以 `charset=gbk,utf-8` 提供包含原样 UTF-8 字符的页面,并验证相同的模型可见文本。
- HTML `<meta charset>` 仍然被忽略。包 README 记录了剩余歧义。
@@ -12,8 +12,8 @@
en: 0ab1e1c40cb2dac9
zh: b4180b6681c130b2
/agent-note-model-facing-same-session-goal-tools/decision/tools-and-model-contract:
en: 288a819f644611c9
zh: d6bd7986ba091519
en: 8589421c225beac0
zh: d2a49dec31ba260c
/agent-note-model-facing-same-session-goal-tools/decision/execution-authority:
en: 63a9e7e85303b9f1
zh: d3f1d0c225103cfe
@@ -24,8 +24,8 @@
en: 92650dd6648ca455
zh: 5b57276a6a983fea
/agent-note-model-facing-same-session-goal-tools/alternatives-considered:
en: e84b9fc4823bcc1e
zh: a125628e4746bf50
en: 84f7829c020f282b
zh: 37c4366efc9dd8dc
/agent-note-model-facing-same-session-goal-tools/consequences:
en: e254571a176b37e5
zh: 01f76aff35e3618d
@@ -16,7 +16,7 @@ The tool API also needs to preserve the separation between durable state and liv
### Tools and model contract
`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are valid only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. A durable paused goal rejects `resume` with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. The executor treats exact empty-string optional fields and a zero `max_goal_rounds` as strict-schema fillers: they count as omitted, an edit still requires at least one meaningful replacement, and all non-filler values retain the action restrictions.
`get_goal()` returns the current goal or `null`. A non-null result contains the compare-and-set id and revision, objective, durable phase, admitted and maximum goal rounds, any blocker reason, plus the process-local activation observation. `create_goal(objective, max_goal_rounds?)` creates one long-running same-session objective. `update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` supports `edit`, `pause`, `resume`, `complete`, and `blocked`; replacement fields are used only for `edit`, while a non-empty `blocked_reason` is required only for `blocked` and persists under the stable `model-reported` code. A durable paused goal rejects `resume` with `GOAL_TOOL_RESUME_PAUSED`; the user-facing command or Web control owns that transition. For `edit`, exact empty-string objectives and a zero `max_goal_rounds` count as omitted, and at least one meaningful replacement is required. Each action ignores known optional fields it does not use ([rationale](../bug-fix/2026-09-20-unambiguous-tool-arguments.md)).
The prompt tells the model that it may infer goal intent from a direct human request in any wording or language, but should not convert routine single-turn work into a goal. It must read the current goal before updating and copy the exact id and revision. On a restored or forked active-but-disarmed goal, a semantic human request to continue is grounds for `resume`. The prompt does not announce the durable paused boundary; execution rejects that attempt with `GOAL_TOOL_RESUME_PAUSED`, and the user-facing resume path owns the transition. Completion is reserved for an achieved objective, and difficulty or uncertainty alone is not a blocker; a block report must name the concrete condition.
@@ -48,7 +48,7 @@ Unit coverage pins registration and disposal, exclusive scheduling, generated pr
- **Authorize from persisted root or fork metadata** — rejected because a fork that becomes an independently resumed top-level session should accept new human authority, while a currently owned child should not.
- **Let autonomous rounds edit or resume the goal** — rejected because continuation authority is narrower than authority to redefine or restart the human objective.
- **Treat the blocked threshold as an evaluator** — rejected because event counts cannot prove that an obstacle is semantically unchanged or truly terminal.
- **Reject every present action-specific field** — rejected because strict-schema providers can serialize zero-value placeholders for every optional field; only meaningful values can express a conflicting action.
- **Reject every present action-specific field** — rejected because strict-schema providers can serialize zero-value placeholders for every optional field; only meaningful values can express a conflicting action. The [unused-field decision](../bug-fix/2026-09-20-unambiguous-tool-arguments.md) defines which populated fields are ignored.
## Consequences
@@ -16,7 +16,7 @@ Status: implemented
### 工具与模型约定
`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大 Goal Round 数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅对 `edit` 有效,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。持久 paused goal 会以 `GOAL_TOOL_RESUME_PAUSED` 拒绝 `resume`;面向用户的命令或 Web 控件拥有该转换。执行器把值恰好为空字符串的可选字段和值为 0 的 `max_goal_rounds` 视为严格 schema 占位值:这些值等同于省略;编辑时仍必须提供至少一个有实际意义的替换字段;所有非占位值仍受对应操作的限制。
`get_goal()` 返回当前目标或 `null`。非空结果包含用于比较并交换的 id 与修订号、目标描述、持久阶段、已接纳和最大 Goal Round 数、可能存在的阻塞原因,以及进程本地激活态观察。`create_goal(objective, max_goal_rounds?)` 创建一个长时间运行的同会话目标。`update_goal(goal_id, revision, action, objective?, max_goal_rounds?, blocked_reason?)` 支持 `edit`、`pause`、`resume`、`complete` 和 `blocked`;替换字段仅在 `edit` 时使用,非空的 `blocked_reason` 仅在 `blocked` 时必填,并以稳定代码 `model-reported` 持久化。持久 paused goal 会以 `GOAL_TOOL_RESUME_PAUSED` 拒绝 `resume`;面向用户的命令或 Web 控件拥有该转换。对于 `edit`,值恰好为空字符串的目标描述和值为 0 的 `max_goal_rounds` 视为省略,且至少需要一个有实际意义的替换字段。每个 action 都忽略它不使用的已知可选字段([理由](../bug-fix/2026-09-20-unambiguous-tool-arguments.zh.md))。
提示词告诉模型:它可以从任何措辞或语言的直接人类请求中推断目标意图,但不应把常规单轮工作转换为目标。更新前必须读取当前目标,并复制准确的 id 和修订号。对于恢复或 fork 后处于活跃但未激活状态的目标,人类在语义上要求继续即可成为执行 `resume` 的依据。提示词不会静态声明持久 paused 的边界;执行时以 `GOAL_TOOL_RESUME_PAUSED` 拒绝该尝试,面向用户的恢复路径拥有该转换。只有目标已经实现时才能标记完成,困难或不确定性本身不构成阻塞;阻塞报告必须说明具体条件。
@@ -48,7 +48,7 @@ Status: implemented
- **根据持久的根或 fork 元数据授权**——不予采纳,因为作为顶层会话独立恢复的 fork 应接受新的人类权限,而当前仍受所有权约束的 subagent 则不应接受。
- **允许自主 Round 编辑或恢复目标**——不予采纳,因为继续执行权限比重新定义或重启人类目标的权限更窄。
- **把阻塞阈值当作评估器**——不予采纳,因为事件计数无法证明障碍在语义上未改变或确实不可继续。
- **拒绝所有已提供的特定操作字段**——不予采纳,因为采用严格 schema 的提供方可能为每个可选字段序列化零值占位符;只有有实际意义的字段值才能表示与指定操作相冲突的另一项操作。
- **拒绝所有已提供的特定操作字段**——不予采纳,因为采用严格 schema 的提供方可能为每个可选字段序列化零值占位符;只有有实际意义的字段值才能表示与指定操作相冲突的另一项操作。[无关字段决策](../bug-fix/2026-09-20-unambiguous-tool-arguments.zh.md)规定了哪些已填写字段会被忽略。
## 后果
@@ -9,14 +9,14 @@
en: f62d87b7623a9904
zh: 48bbb7c35298f96c
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision:
en: 125dea7427047966
zh: 1e459b1106597695
en: 10e9cfb7972cff03
zh: 8406ba64d72a1fcc
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery:
en: a5bcdf2a6133abfe
zh: 477d4915d61945e3
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/how-the-restriction-works-why-no-new-identity:
en: ac9b3d800889ea39
zh: 9db1065b6027f65f
en: 1ba89a61a733b371
zh: 4fa134ad1b2059a1
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/alternatives-considered:
en: 6119e2aff3b6be5a
zh: 4237c596c10e1ee9
@@ -38,3 +38,6 @@
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/related:
en: dd16f0c5815df991
zh: df3c1cf511ee52bf
/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/follow-up-2026-10-26-lua-token-is-dropped:
en: 5262178f0035b215
zh: 10505c394ea33ee3
File diff suppressed because one or more lines are too long
@@ -10,7 +10,7 @@ Status: implemented
## 决策
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE`,不采用 `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。
<a id="acl-diagnosis-and-recovery"></a>
## ACL 诊断与恢复
@@ -23,7 +23,7 @@ Status: implemented
## How the restriction works (why no new identity)
身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE` 禁用除 `SeChangeNotifyPrivilege` 外的所有特权;不采用 `LUA_TOKEN`,因此派生令牌保留调用者的组 SID。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。
## 考虑过的替代方案
@@ -52,3 +52,7 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher
[pwsh 执行器决策](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md)拥有本档所消费的 pwsh-sandbox/tool-pwsh 方言划分。
[删除约束决策](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md)以 Low 完整性层与环境性删除拒绝扩展本档;它只取代本文档所述的删除路径。
## 后续追记(2026-10-26):去掉 `LUA_TOKEN`
`CreateRestrictedToken` 仍传 `DISABLE_MAX_PRIVILEGE | WRITE_RESTRICTED`,不再传 `LUA_TOKEN`:带上该标志时,内置 Administrator 用户无法启动任何进程,受限启动全部失败。写限制仍来自 restricting SIDs 与 Low 标签。
@@ -0,0 +1,55 @@
# Bilingual-pair consistency record for 2026-09-13-liveshare-protocol-and-owner-surfaces.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-09-13-liveshare-protocol-and-owner-surfaces.md
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague:
en: e73b246e19c9b412
zh: c87594ad5eccbfb5
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/problem:
en: 17bc15d382139909
zh: 3eff34b5ddea951e
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision:
en: a15c7c13f167e5ac
zh: a15c7c13f167e5ac
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-a-share-is:
en: ff1dbb0f7c2355e4
zh: 3de92b2e6c5e9e15
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-a-reader-can-do:
en: 5652ed7f515a2a5d
zh: 993378f78f5a4120
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/two-driving-tiers:
en: 0b9303333bf482ae
zh: 6dfc68ad9e2924c3
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/default-visibility:
en: 754705367c350b31
zh: e10b6541458c4a45
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/how-shared-work-stays-inside-its-grant:
en: fa3ab42895a16ce1
zh: 429be63cfb8e55a6
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/keeping-one-branch-across-retries:
en: 6b0369dafe34a5aa
zh: aea9465216204122
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/who-the-owner-is:
en: 7e6d1305782d2822
zh: 66fb2cbef0f49d47
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-the-reader-sees-at-the-top:
en: 44d02a91cf0c5715
zh: dabbf6c59ff08e4a
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/when-the-connection-breaks:
en: 36ee6e090797806b
zh: 21809a0cf990d905
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/how-the-served-page-is-exercised:
en: d9cbb8277862587c
zh: 867fa6c4db95d09c
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/decision/what-the-package-exposes:
en: d13a0df787f212d4
zh: 24794e8bf4d92492
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/alternatives-considered:
en: 2daeba6e763383e4
zh: fe0fe3f7f4d546e7
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/verification:
en: 76cc786336c5daf5
zh: 98bd2b9cc7d1791c
/agent-note-liveshare-sharing-a-dsh-session-with-a-colleague/consequences:
en: 8a6e06a9fba917e2
zh: 68a9d19b15119345
@@ -0,0 +1,156 @@
# Agent Note: Liveshare — sharing a DSH session with a colleague
Status: implemented
English | [中文](2026-09-13-liveshare-protocol-and-owner-surfaces.zh.md)
## Problem
A DSH session runs on your own computer. A colleague sometimes needs to watch one while you work — to follow a debugging session, or to see how a long task is going. Today there is no way to do that. Sending the session log is not the same thing: the colleague would need your plugins, configuration, and sandbox to render it, and the session only makes sense while it is running on your machine.
So a share needs three things at once. A **page the colleague opens in a browser** that shows the session running on your machine, without connecting them to it. A **limit on what that page can do**, starting with "read without writing" — anything the page offers beyond what you agreed is a security hole, not a convenience. And an **answer to "who shared this?"**, because a page that cannot say who its owner is cannot be trusted by whoever opens it.
This note covers the half that runs inside the owner's DSH — the share dialog, the owner-side service behind it, the package's browser face, and the page a reader opens — plus the protocol package both sides speak. The Liveshare server, the shared service in the middle, is its own package (`@deepseek-ai/dsh-experimental-liveshare-relay`) and is reviewed and deployed separately.
## Decision
### What a share is
An owner creates a share for one session from DSH itself. The share is published to the Liveshare server, a service both sides can reach, and DSH returns a link. Opening that link shows the session rendered by the owner's own DSH with its own plugins, so the reader never needs the owner's configuration. The reader's list contains the source Session and any children that reader created through the share. Workspace names, membership, and ordering are trimmed to those Sessions; unrelated Sessions and workspaces stay hidden.
A share created from the DSH Share dialog is an **organization link** share: it carries `accessMode: 'open'`, so anyone in the organization who has the link is admitted. The deployment's `LIVESHARE_ALLOWED_SUBJECTS` is unioned into every share's ACL: it narrows an `open` share to the subjects it lists, and on a stored `selected` record it adds those subjects beside the record's own selection.
**Specific colleagues (`selected`) and the older `approval` spelling are legacy stored records.** The Host still reads them with their recorded ACL and shows their access summary, and the Liveshare server still admits their viewers by that ACL, but the shipped Share dialog offers no way to create one. `liveshareShares.create` keeps its `accessMode` and `allowedSubjectIds` fields for those records and for callers that invoke the Remote directly; the shipped dialog sends neither.
### What a reader can do
The reader's page is the normal DSH client, not a cut-down copy. The sidebar, the session list, expanding, collapsing, and selecting are all there, because browsing is part of reading. What is missing is every action the reader is not allowed to perform: creating an unrelated session, renaming, archiving, deleting, and managing a workspace. Branching follows the share's own fork switch, prompting is available in both tiers and runs inside that tier's sandbox, and attachment upload follows the Sessions the reader can see; management permissions remain independent of the tier and the fork switch.
These actions are not hidden with CSS. One access snapshot decides what the page may do: the remote-access client follows the reader's own `remoteAccess/follow`, and each button and menu entry reads its endpoint's answer on the global resource, a Session, or a Workspace through `useRemoteAccess`, `useAccess` or `useSessionAccess`, or `useWorkspaceAccess`, rendering again whenever the answer changes. The owner's DSH computes the snapshot from this share's `share-viewer` policy, so an ability the owner withdraws closes without reloading the page — and the server refuses those actions anyway; the page only avoids offering what would be refused. The served index carries the reader's opening snapshot, so no entry appears before the page knows it is allowed, and after the snapshot stream fails every answer is refused. The owner's own page opens with the owner baseline and behaves exactly like an ordinary DSH. Per-Session refusal reasons are not part of the snapshot: the page states the page-level reason (share mode, stop, expiry, lost access) and generic read-only copy. A page that cannot read the Session at all — offline owner, stopped or expired share, lapsed sign-in — states that condition at the top and gives the reader an action, instead of leaving an empty shell or a bare code.
### Two driving tiers
Every share carries a `ViewerSessionMode` with two values, and both drive the shared Session:
- **view** — the reader drives the shared Session's loop through the normal prompt path, inside a read-only sandbox in the granted workspace, so the Session's tools never write a file. The read and search surface (`read`, `read_image`, `glob`, `grep`, `present`), the tools with no local file effect (`todo_write`, `skill`, `web_search`, `web_fetch`). Every declared path is checked before the tool runs, and a tool whose call cannot be checked is refused.
- **write** — the reader drives the same loop in a bounded writable sandbox that also modifies files inside the granted workspace, under the Host's write ceiling.
The sandbox is installed on the Session, but it confines only the turns a share viewer starts: `agent/pre-step` records whether an admitted turn's entering input names a share viewer, and the guard and the replacements read that answer for each execution. A turn the owner starts keeps the Session's tools; an owner's steering input inside a viewer's turn stays confined with it. The marker lives in Host memory, is recomputed for each turn, and never enters the Session log. Stopping every share, or letting them all expire, disposes the layer and gives the Session its own tools back.
The earlier vocabulary had three tiers — `view` watched, `fork-readonly` branched, and `write` could write. `view` and `fork-readonly` differed only in whether the reader could branch and drive a child, which the share's own `allowsFork` switch already decides; a third mode duplicated that switch and let the two answers disagree, so the two driving tiers replaced it.
Forking is not a tier. The share carries its own `allowsFork` switch, independent of the tier: a `view` share may allow it and a `write` share may refuse it. A fork creates a child the viewer owns with its own read-only or writable ceiling and leaves the source untouched.
Attachment upload is not a tier or a switch. `fileUploads/upload` rides the read role, so every Session a share serves the reader accepts files; visibility alone decides it, and the owner has no separate attachment switch.
A mode this version does not recognize, and a persisted record whose mode the host cannot establish, are not driven: the share stays readable, and the page reports what it could not establish. The withdrawn `fork-readonly` name is not read back as `view`; a persisted owner record that names it is rejected when the owner state loads.
The sandbox is a property of the Session, but it applies per turn: the Host records, in memory, whether the admitted input of the running turn names a share viewer, and confines that turn while leaving an owner-started turn on the Session's native tools. The earlier shipped design confined every turn, including the owner's; it needed no turn read, but a share changed how the owner's own work ran. Persisting a turn's requester was rejected: a committed-message projection had to be folded, re-read for every execution, and kept in step with the share that admitted it, and it still had to fail closed on mixed or unestablished requesters. The in-memory marker keeps the guard and the replacements reading one answer per execution without adding a Session event; a restart recomputes it from the next admitted step, and a rejected or never-admitted step leaves no marker.
### Default visibility
The package ships as a runtime dependency of every dsh installation, but a default installation must show it nowhere in the plugin manager: neither under **Built-in plugin settings**, nor under **Experimental plugins**, nor on the **More** page. A person enables it deliberately — by inserting the bundle's rows in the active profile's `cordis.patch.yml`, or by selecting the bundle in `dsh.profile.bundles`; the [package README](../../../../packages/experimental/liveshare/README.md) owns both routes. The package must still ship with the release and stay installable, and users who enabled it before this rule keep seeing it.
The Host's bundle `enabled` flag states whether the profile selected the bundle, which is not the same as whether the bundle's plugins run: inserting the bundle's rows through `cordis.patch.yml` starts its plugins while leaving the bundle unselected, so a visibility rule built on `enabled` would hide a bundle the person had just enabled and would strand the documented enable path.
The package declares the hiding intent in its own bundle manifest as `dsh.bundle.listedByDefault: false`; the manager keeps one generic visibility rule rather than a client-side name set. A bundle whose manifest does not declare `listedByDefault: false` is listed; one that declares it is listed only when the profile enables it, the installation carries it selected, or one of the rows its bundle patch declares is running (`pkg.rows.some(row => row.enabled)`). The filter applies before both lists are derived, so the name is absent from the main Experimental plugins group, the More page, Built-in plugin settings, and the Installed plugins group until one of those holds. The Host's `listBundles` contract, the profile format, and the installed package set are unchanged. `BundleInfo.rows` already joins each declared row with the Loader entry currently carrying it, and the client's `PackageView` carries whether that entry runs, so row state is the effective answer for both enable routes: removing the rows, disabling them, or deselecting the bundle stops them and the card leaves both lists again.
A default-hidden bundle is not addressable by ID while its card is hidden: `openPkg` resolves only the listed inventory, so `pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-liveshare')` renders no details until the same enablement conditions list it.
### How shared work stays inside its grant
The Owner, not the viewer page or the Liveshare server, authorizes each Session operation. A child receives its real scoped tool restrictions before it can accept a prompt, including after a cold resume: the host installs the limit from the base `agent/created` event. That listener runs after the agent entered the registry, but creation has not resolved, AgentLoop holds the agent's queued input, and a throw fails creation, so a refused limit still prevents any prompt. A missing host service or failed installation refuses the child; a configuration flag, a tool name, or a promise to install later is not enforcement.
A shared source Session can still serve its owner's own work. Every reader call is dispatched through the owner's own Connection and Gateway as that share-and-reader pair's own Peer, whose `share-viewer` principal (the share id and the server-asserted identity triple) is fixed by in-process owner code when the Peer is created; no wire field can change it. The Gateway decides the Peer's calls, and the Session notifications it forwards to the Peer, through remote access: this package registers the `share-viewer` policy, which binds a served reader to read roles on the shared Session, its own forks, and the Workspaces holding them, and to fork and write roles whose conditions apply the share mode; it binds nothing else, so every other endpoint and notification is refused, and it binds nothing once the share stops serving the reader. The owner invalidates the reader's access on every grant, revocation, mode change, and expiry, and disposes the reader's Peer when the grant ends or the share stops or expires. A reader's prompt enters the Session as a `user-rpc` message whose `source.principal` names that reader; owner messages carry no principal. While any share over that Session is active, the owner host mounts its own confined replacements for the read and search surface and the no-effect tools — plus bounded `edit` and `write` when some share may write — in the Session's own scope, and a viewer-started turn may run nothing else. An owner-started turn keeps the Session's native tools, so a share never narrows the owner's own work. The pre-step records which kind of turn is running, in memory only. Each operation re-reads the shares' current Relay grants and checks the resolved target against the granted root; the pre-step re-checks every entering message's viewer against the Relay's admission before a model step, so reader input queued before a revocation never drives the model. A reader's stop of the shared Session is dispatched through the Gateway like a prompt: the policy admits it while the share's tier is established and the Session is confined, and the owner's own Session controller stops whatever turn that Session is running, the owner's included. The general Peer, principal, and remote-access design is in [Peer principals and remote access](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.md).
A confined file tool resolves every path its own arguments declare, checks that the granted root contains each result, and hands the deployment's tool that canonical path; a pathless search uses the Session's working directory, which the replacement checks like a named path. A bounded edit or write performs the same containment check and then edits or writes through the filesystem backend with the Session's current sandbox policy and the normal observation policy.
### Keeping one branch across retries
One fork request binds its viewer, share, source, operation identity, and completed prefix. The Owner reserves that operation durably before creating a child, and records the child the owner's fork answer returned; a retry of a recorded operation replays that answer instead of forking again. A different prefix under the same identity is refused. The owner's `SessionForkRequest` mints the child id, so the Host sends no child identity: a host that dies after the child was created but before the operation was recorded cannot name that child on retry and creates a new one. Closing that window needs a Session Controller request that accepts a caller-supplied child identity. The inherited prefix stops before unrelated queued input; only immediately following request metadata is retained. Revoked children keep their logs but cannot resume with broader access, and removing a share does not expose the source Session's unrestricted tools.
### Who the owner is
A share can only be created by a verified owner. The dialog shows the identity the owner's DSH read back — name, expiry, when it will renew — and uses a label derived from the identity when there is no display name. The code that creates the share refuses an unverified owner as well, so the question is not settled by whether a button was clickable.
Signing in happens once, in a window the page opens while the owner clicks. The DSH creates a short-lived transaction tied to the Liveshare server address, to the Liveshare server instance, and to the page allowed to finish it; the page opens the sign-in page the DSH names, accepts one one-time code only from that exact origin, window, and transaction, and hands it back to its own DSH.
The Owner redeems the code with the verifier it retained, stores the credential with owner-only permissions, and sends it only to the Liveshare server address that issued it. New registration requires that verified credential; existing-share reconnect uses the share's management token, and explicit identity claim preserves the share's ACL, mode, expiry, and token. A share's named colleagues are the owner's stored selection plus the deployment's own allowlist; nothing is derived from a display identity.
### What the reader sees at the top
The served page reuses the sidebar's existing brand row instead of adding a bar on top of the app: the usual Harness mark, plus a LiveShare entry that names who shared the session and which DSH version they run, with the Liveshare server's own release labeled separately. Clicking it returns to the page listing the shares you can open.
### When the connection breaks
A reader's feed can fail: a stream disappears, the Liveshare server cancels a request, a read times out. The page retries only the failures that are defined as repeatable, and stops for good on authentication, permission, and missing-share errors, showing the reason instead of reconnecting forever.
### How the served page is exercised
The page a viewer opens is the owner's own Web page. The Liveshare server asks the owner for its rendered index document, places a short carrier script at its top, and passes the page's shell assets and plugin bundles through from the owner, so the page runs every owner plugin at the owner's version. The carrier only turns the page's calls and streams into reads over the Liveshare server's channel; the Liveshare plugin itself recognizes a viewer page and mounts the viewer surfaces instead of the Share action. The owner renders that index with the reader's own access snapshot in place of the owner baseline, and the page then follows the reader's `remoteAccess/follow` snapshot; no other read carries a reader's grants.
The viewer page is tested in-process: the test assembles the page from the carrier, the shell, and the client plugins including Liveshare's, stands in for the Liveshare server's read channel, and answers the page's reads from a scripted owner, so the assertions see the real shell, carrier, and presentation plugins.
### What the package exposes
The package ships one Host program and one browser face. Its public entries are the Host export (`lib/index.js`), `./client` (`lib/client.js`, a closure-factory bundle), `./viewer-declarations`, the share-management Remote's `./typert` and `./remote` faces, `./types`, the bundle patch `./cordis.patch.yml`, and the locale JSON files. The manifest declares the browser face as `dsh.client` with `platform: web` and the client services it needs; that face reads `__DSH_LIVESHARE_VIEWER__`, mounts the viewer session plugin and its brand, model, permission, session-row, and share-path surfaces on a served viewer page, and otherwise mounts the owner's generated Remote before the Session-header Share action.
Share management is one `liveshareShares` Typert Remote with twelve methods — `create`, `list`, `stop`, `updatePermissions`, `setCapabilities`, `capabilities`, `relay`, `setRelay`, `identity`, `audit`, `testRelay`, and `testPublish` — over the operations the owner host runs. The only same-origin HTTP routes left are the four owner sign-in and credential routes: `/api/liveshare.ownerLoginStart`, `/api/liveshare.ownerLoginRedeem`, `/api/liveshare.ownerLoginCancel`, and `/api/liveshare.ownerCredential`.
The outgoing gateway is a supervised child process whose source entry is `src/owner-gateway/cli.ts` and whose published entry is `lib/owner-gateway/cli.js`; the directory name keeps it distinct from the DSH API gateway.
## Alternatives considered
- **Build a separate read-only page instead of serving the normal client.** Rejected: reading needs browsing, grouping, and selecting, so a second page would have to rebuild all of it and would quietly lose the navigation the product keeps.
- **Decide each button's availability where the button is.** Rejected: every entry reads one access snapshot the owner computes, so a screen added later cannot quietly reintroduce an action the reader may not perform.
- **Let the reader's page decide its own access.** Rejected: only the owner and the service decide what a share grants; the page states what it was told.
- **Treat an older share with no mode as writable.** Rejected: that would hand a reader access the owner never configured, so a share without a mode is read-only.
- **Put the sign-in code in the page address, or read the credential from the popup's cookies.** Rejected: addresses leak through history, referrers, and logs, and a page should not hold credentials.
- **Show the service's own error text when the page cannot explain a refusal.** Rejected: that text may contain a file path, a credential, or another reader's session, so the page shows the stable reason it can vouch for.
- **Put execution restrictions only in editable configuration or a sandbox mode.** Rejected: a child can change configuration, and per-call execution choices can outrank a mode. The real operation needs a monotonic guard and a verified implementation.
- **Check a model-supplied path only before dispatch, or globally constrain every filesystem read.** Rejected: a pre-dispatch check is separated from the tool call by other listeners, while a global read rule would also constrain unrelated Owner work. The containment check runs inside the authorized Session's own read and write.
- **Reuse the deployment's default root or copy the entire source tail into a child.** Rejected: both can include files or pending input the viewer was never granted.
- **Use a deployment-wide secret or self-reported display fields as owner identity.** Rejected: neither identifies the logged-in person who approved this publication.
- **Keep three tiers.** Rejected: `view` and `fork-readonly` differ only in whether the reader can branch and drive a child, which is already the independent fork switch. A third mode duplicates that switch and lets the two answers disagree.
- **Keep a per-share attachment-upload switch.** Rejected: an attachment is part of a prompt on a Session the reader can already see. The separate switch duplicated visibility and could only subtract from it, while the upload role already rides the read role.
- **Attribute every turn to a requester and exempt the owner's own turns.** Rejected after it shipped, then narrowed: persisting a turn's requester needed a Host-only projection over message sources, a turn-boundary read per execution, and a re-check of the share that admitted each turn, and it still had to fail closed on mixed or unestablished requesters. The shipped design keeps only an in-memory marker for the running turn — `agent/pre-step` reads the entering principals and the guard reads that answer — so an owner-started turn keeps the Session's native tools without adding a Session event. The accepted cost is that an owner's steering input inside a viewer's turn stays confined; a viewer message that reaches a later step narrows that turn.
- **Let each tool name its own tier or sandbox.** Rejected: the tier belongs to the share. A per-call choice would let one execution outrank the grant that admitted it.
- **Test the bundle's `enabled` flag directly.** Rejected: it would keep the code smallest, but the patch route starts the bundle's plugins while leaving the bundle unselected, so the card would stay hidden exactly when its plugins started, contradicting the product rule.
- **Derive effective enablement in the Host.** Rejected: `listBundles` could report a new or redefined effective state, but that would change a cross-package contract for one client-only presentation rule and invite every bundle consumer to reinterpret `enabled`, which must keep meaning "selected in the profile".
- **Ship LiveShare outside the installation's optional bundles.** Rejected: that would make it visible nowhere by construction, but the package must still ship as an npm package with the release and be installable, and it would also remove the bundle from the Host's list entirely, taking the details entry with it.
- **Keep the colleague picker, or replace it with a directory-backed selector.** Deferred: naming a colleague needs an identity directory the Liveshare server does not provide, so the dialog cannot offer a choice it can verify. Stored `selected` and `approval` records keep working through their ACL, and the creation entry point stays retired until a directory contract exists; `liveshareShares.create` keeps its fields for those records and direct Remote callers, so this defers the API rather than deleting it.
## Verification
- A reader's page keeps the normal navigation, and no action the reader may not perform appears on the shared session or on any branch.
- A DSH without the Liveshare bundle, and the owner's own page, behave exactly as before.
- A share uses one of two tiers and that tier's sandbox; unknown modes, unreadable answers, and shares without a mode stay readable but are never driven.
- A share cannot be created without a verified owner identity.
- A share created from the dialog carries `open`; a stored `selected` or `approval` record keeps its recorded ACL and access summary, and `create` still stores a requested `selected` mode with its subjects.
- Sign-in accepts one one-time code from the transaction's origin, window, and state, and nothing else; a failed or abandoned attempt discards the transaction, and no credential, cookie, or code reaches a URL, storage, or a log.
- Real Controller, ToolRuntime, filesystem, and profile tests cover fork identity, cold enforcement, bounded execution, revocation, and normal cancellation. The built viewer must use its real POST carrier and Owner authorization handler; deterministic display fixtures do not replace execution or persistence tests.
- Generated API documentation and both SDK projections remain aligned with the `user-rpc` source principal, which only the `agent/pre-step` admission check consumes.
- `ViewerSessionMode` has two members, and a persisted `fork-readonly` record is rejected when the owner state loads.
- A `view` share drives the source Session, refuses file writes and out-of-grant reads, and can fork when `allowsFork` is set.
- `fileUploads/upload` is allowed on every Session the reader can see, for every tier and fork combination.
- Source tool decisions read only the Session's mounted layer: the guard permits this installation's confined replacements for the read and search surface and the no-effect tools, plus bounded `edit` and `write` on a writable Session, and refuses every other name, with no turn, requester, or origin consulted.
- Unit and real-runtime tests cover the tier policy, confined reads and writes, fork ceilings, the layer's installation and release, and the pre-step admission check over the durable message source.
- The plugin-manager component tests hold the bundle out of both lists while its declared rows are off, list it once a declared row runs, and still open it by bundle ID while it is hidden; the recorded goldens and the refresh card count move with that rule.
## Consequences
The reader's page can only be as accurate as what the service tells it. Capability answers, the owner's identity status, the session mode, the owner's DSH version, and the sign-in endpoints all come from the owner's DSH and the Liveshare server; until those are deployed together with this layer, the page stays at its most conservative setting — read-only, with a sign-in that cannot complete — rather than guessing.
The page is not a security boundary. The owner's DSH and the Liveshare server refuse a reader's writes no matter what the page does; the access snapshot exists so the page does not offer actions that would only be refused.
Existing ACLs are retained. A record without a mode is readable but not driven, and contradictory mode fields are refused. Unattributed shares are shown as unverified history.
Owner reconnect state is atomically replaced and queued within one process, but multiple processes sharing one file can still lose updates or compete for a gateway. Cross-process execution ownership is unresolved; atomic replacement alone is not a transaction or a route claim. Containment is checked by path: a local process that replaces a directory inside the grant with a symlink between the check and the open can redirect a viewer's read, or a write elsewhere within the sandbox's writable roots. Operations anchored to a directory handle would close this window and are left to a separate change. Final viewer-artifact and SDK snapshot evidence remains required before release.
A `view` share is not watch-only: it runs the reader's prompts and tool calls inside the read-only sandbox. A deployment that wants a passive reader has no separate mode and must rely on the reader not sending prompts rather than on a tier name. Fork reach is the only independently switchable action; attachment upload cannot be disabled separately.
The sandbox reaches the owner's own turns too. While any share over a Session is active the owner can only read inside the granted workspace, and can write only when some share allows writing; a share that is merely published — even one nobody is using — therefore changes the owner's own tool surface until it is stopped or expires. Cancellation has no attribution either: a reader's stop ends whatever turn the shared Session is running.
The default cards list, the More page, and their recorded goldens lose one card, and the manager's refresh card count drops by one. A person who only knows the plugin page cannot discover LiveShare or switch it on from that page; the package README owns the two enable routes instead. A bundle enabled through the patch route shows its card with the switch off while its rows run, because the switch reflects the profile's bundle selection and writes that selection; the documentation states the two routes and this difference. The rule covers one explicitly named bundle rather than a manifest flag, so adding another hidden-until-running bundle, or retiring this one, is a one-line set edit with a test and docs update.
Shipped creation produces organization-link shares only: the Share dialog sends neither `accessMode` nor `allowedSubjectIds`, so every new share is `open`, and the deployment's `LIVESHARE_ALLOWED_SUBJECTS` is the only subject-level narrowing a new share can carry. Stored `selected` and `approval` records keep working and keep their access summaries, and the create fields stay on the Remote for those records and direct callers while no UI exposes them.
@@ -0,0 +1,156 @@
# Agent Note: Liveshare —— 把 DSH 会话分享给同事看
Status: implemented
[English](2026-09-13-liveshare-protocol-and-owner-surfaces.md) | 中文
## Problem
DSH 会话跑在你自己的电脑上。同事有时需要在你工作的同时看某个会话——跟着一起排查问题,或者看看一个长任务进展到哪了。现在没有这个能力。把会话日志发过去并不等价:对方要有你的插件、配置和沙箱才能渲染出来,而且会话只有在你机器上运行时才有意义。
所以一次分享要同时解决三件事:一个**同事用浏览器打开就能看的页面**,看到你机器上正在跑的会话,但不需要连到你的机器;一个**对页面能做什么的限制**,从"能读、不能写文件"开始——页面多给一个你没同意的操作就是安全漏洞,不是便利;以及一个**"这是谁分享的"的答案**,因为说不清归属的页面,打开它的人无从信任。
这条 Note 覆盖 owner 自己 DSH 里运行的那一半——分享对话框、背后的 owner 端服务、本包的浏览器端,以及读者打开的页面——以及双方共用的协议包。中间的 Liveshare 服务端是独立的包(`@deepseek-ai/dsh-experimental-liveshare-relay`),单独审查与部署。
## Decision
### 一次分享是什么
owner 在 DSH 里为某一个会话创建分享。分享发布到 Liveshare 服务端(双方都能访问的服务)后,DSH 返回一个链接。打开链接看到的是 owner 自己的 DSH 用自己插件渲染出的会话,读者不需要 owner 的配置。读者列表包含源会话,以及该读者通过分享创建的子会话。工作区名称、成员和顺序裁剪为这些会话;无关会话和工作区保持不可见。
从 DSH 分享弹窗新建的分享是**组织内持链接**分享:它携带 `accessMode: 'open'`,组织内拿到链接的人都能看。部署级 `LIVESHARE_ALLOWED_SUBJECTS` 会并入每份分享的 ACL:对 `open` 分享,它把准入收窄到列出的主体;对已存储的 `selected` 记录,它把列出的主体加在记录自己的选择旁边。
**指定同事(`selected`)与更早的 `approval` 拼写是旧版持久记录。** Host 仍按记录里的 ACL 读取它们并展示访问摘要,Liveshare 服务端仍按该 ACL 接纳它们的查看者,但随版本发布的分享弹窗不提供新建入口。`liveshareShares.create` 的 `accessMode` 与 `allowedSubjectIds` 字段保留给这些记录以及直接调用该 Remote 的调用方;随版本发布的弹窗两个都不发送。
### 读者能做什么
读者页面就是正常的 DSH 客户端,不是精简版:侧栏、会话列表、展开折叠、选择都在,因为浏览本来就是"看"的一部分。缺的只是读者无权执行的动作——创建无关会话、重命名、归档、删除、管理工作区。分支遵循分享自己的 fork 开关;发送消息在两档里都可用,并在该档的沙箱内运行;附件上传跟随读者可见的会话。管理权限与档位和 fork 开关都保持独立。
这些动作不是用 CSS 藏起来的。页面能做什么由一份访问快照决定:remote-access 客户端跟随读者自己的 `remoteAccess/follow`,每个按钮和菜单项经 `useRemoteAccess`、`useAccess` 或 `useSessionAccess`、`useWorkspaceAccess` 读取其端点在全局资源、某个会话或某个工作区上的答案,答案变化时重新渲染。owner 的 DSH 依据该分享的 `share-viewer` 策略计算快照,因此 owner 收回某项能力时,对应入口不用重新加载页面就会关闭——而且服务端本来就会拒绝这些操作,页面只是不去提供注定被拒绝的按钮。读者收到的 index 已带上读者的首份快照,因此页面在确认允许之前不会显示任何入口;快照流失败后,所有答案都为拒绝。owner 自己的页面以 owner 基线打开,行为与普通 DSH 完全一致。快照不包含逐会话的拒绝原因:页面陈述页面级原因(分享模式、停止、过期、失去访问权)和通用的只读文案。拿不到分享正文时,页面顶部直接陈述该状态,并且每一档都给出读者自己能做的动作(重试、重新登录、回到分享列表,或明确指引联系分享者),不会停在空白外壳或错误码上。
### 两档驱动
每个分享带一个 `ViewerSessionMode`,只有两个值,且两档都会驱动共享会话:
- **view** —— 读者通过普通 prompt 路径驱动共享会话的 loop,在授予工作区的只读沙箱里运行,因此会话的工具绝不写文件。读取与检索面(`read`、`read_image`、`glob`、`grep`、`present`)、无本地文件副作用的工具(`todo_write`、`skill`、`web_search`、`web_fetch`)都限制在授权范围内:工具运行前检查其声明的每个路径,无法检查的调用一律拒绝。
- **write** —— 读者在受宿主写入上限约束的可写沙箱里驱动同一个 loop,沙箱也会修改授予工作区内的文件。
沙箱安装在会话上,但只约束分享查看者发起的轮次:`agent/pre-step` 记录一个已准入轮次的进入输入是否带有 share-viewer principal,守卫与替代定义对该轮次的每次执行读取这一结论。所有者自己发起的轮次保留会话自己的工具;所有者在查看者轮次中追加的输入仍留在该受限轮次内。该标记只存在于 Host 内存,按轮次重算,不写入会话日志。所有分享停止或全部过期后执行层被释放,会话恢复自己的工具。
较早的词汇有三档——`view` 只观看、`fork-readonly` 分支、`write` 可写。`view` 与 `fork-readonly` 只差在读者能否分支并驱动子会话,而这已经由分享自己的 `allowsFork` 开关决定;第三档重复了该开关,还会让两处答案互相矛盾,因此由两档驱动取代。
fork 不是一档。分享自带独立的 `allowsFork` 开关,与档位无关:`view` 分享可以允许 fork,`write` 分享也可以拒绝。fork 创建的子会话归读者所有,带有自己的只读或可写上限,源会话保持不变。
附件上传既不是一档,也不是一个开关。`fileUploads/upload` 随读角色授予,因此分享向读者提供的每条会话都接受文件;只由可见性决定,所有者没有单独的附件开关。
本版本不认识的模式、以及宿主无法确定模式的持久记录,都不会被驱动:分享仍可读取,页面会说明它无法确定什么。已退场的 `fork-readonly` 名称不会读回为 `view`;持久化的 owner 记录若写有该值,owner 状态加载时会直接拒绝。
沙箱是会话的属性,但按轮次生效:Host 在内存中记录运行中轮次的已准入输入是否带有 share-viewer principal,只约束该轮次,把所有者发起的轮次留在会话原生工具上。此前上线的实现限制每个轮次(包括所有者的),虽不需要读取轮次,却让分享改变了所有者自己的工作方式。持久化轮次请求者被否决:那需要从已提交消息来源折叠的投影、每次执行读取 turn boundary、并与接纳该轮次的分享保持同步,而且在混合或无法确立请求者时仍必须默认拒绝。内存标记让守卫与替代定义对每次执行读取同一个结论,且不新增会话事件;重启后从下一个准入步骤重新计算,被拒绝或从未准入的步骤不留下标记。
### 默认可见性
本包作为运行时依赖随每个 dsh 安装一同交付,但默认安装必须在插件管理器里完全不显示它:**内置插件配置**下不显示,**实验性插件**下不显示,**更多**页也不显示。用户主动启用它——在当前 profile 的 `cordis.patch.yml` 中插入组合包的行,或在 `dsh.profile.bundles` 中选中该组合包;两条启用路径由[包 README](../../../../packages/experimental/liveshare/README.zh.md)负责。该包仍必须随版本发布、保持可安装,且在此规则之前已启用的用户继续能看到它。
Host 的组合包 `enabled` 标志表示 profile 是否选中了这个组合包,这与它的插件是否在运行并不等价:通过 `cordis.patch.yml` 插入组合包的行会让插件运行,而组合包本身仍未选中;因此以 `enabled` 为准的可见性规则会把用户刚刚启用的组合包隐藏起来,使写入文档的启用路径失效。
因此管理器维护一个 `HIDDEN_UNTIL_RUNNING` 组合包名集合——当前只有 `@deepseek-ai/dsh-experimental-liveshare`——集合中的组合包只有在它 patch 声明的行至少有一条在运行时才会被列出(`pkg.rows.some(row => row.enabled)`)。过滤发生在两个列表派生之前,因此在满足条件前,它不会出现在主"实验性插件"组、**更多**页、**内置插件配置**和"已安装插件"组中。该集合是 `PluginManagerPage.tsx` 中的客户端数据;Host 的 `listBundles` 契约、profile 格式和已安装包集合都保持不变。`BundleInfo.rows` 已把每条声明的行与当前承载它的 Loader 条目关联起来,客户端的 `PackageView` 携带该条目是否在运行,因此行状态就是两条启用路径的有效答案:删除这些行、把它们置为 disabled,或取消选中组合包,都会让它们停止运行,卡片随之从两个列表消失。
卡片隐藏期间组合包 ID 仍可按 ID 打开:`openPkg` 会从未过滤的 Host 组合包列表中解析隐藏的组合包,因此 `pluginNavigation.openBundle('@deepseek-ai/dsh-experimental-liveshare')` 仍能渲染其详情。其它按 ID 链接组合包的界面因此继续可用,组合包页也像其它未选中组合包的页面一样列出各行及其关闭状态。
### 分享工作如何限制在授权范围内
Owner 而不是查看页或 Liveshare 服务端授权每次会话操作。子会话必须先安装真实作用域工具限制,才能接受消息,冷恢复后也一样:宿主机从基础 `agent/created` 事件安装该限制。该监听器运行时智能体已登记进注册表,但创建尚未完成,AgentLoop 会扣住其排队输入,且监听器抛错会使创建失败,因此被拒绝的限制仍能阻止任何消息。宿主机服务缺失或安装失败都会拒绝子会话;配置开关、工具名或稍后安装的承诺都不是执行限制。
共享源会话仍可服务所有者自己的工作。每次读者调用都以「该分享与该读者」专属的 Peer 经所有者自己的 Connection 与 Gateway 分派,该 Peer 的 `share-viewer` principal(分享 ID 与服务端断言的身份三元组)由所有者进程内代码在创建 Peer 时确定,任何线路字段都无法改变它。Gateway 经 remote access 判定该 Peer 的调用以及转发给它的会话通知:本包注册 `share-viewer` 策略,把受服务的读者绑定到共享会话、其自己的 fork 及包含它们的工作区上的读取角色,以及条件按分享模式判定的 fork 与写入角色;除此之外不做任何绑定,因此其它端点和通知一律被拒绝,分享不再服务该读者后也不再有任何绑定。所有者在每次授权、撤权、模式变化与过期时使读者的访问失效,并在授权结束、分享停止或过期时释放读者的 Peer。读者的提示词以 `user-rpc` 消息进入会话,其 `source.principal` 指明该读者;所有者的消息不带 principal。只要该会话还有分享活跃,所有者 Host 就在会话自身作用域内挂载读取与检索面与无副作用工具的受限替代,以及(某个分享允许写入时)有界 `edit` 与 `write`,查看者发起的轮次只能运行这些。所有者自己发起的轮次保留会话原生工具,因此分享不会收窄所有者自己的工作。pre-step 只在内存中记录当前轮次属于哪一类。每次操作都会重新读取分享当前的 Relay 授权,并按授予根目录检查解析后的目标;pre-step 在模型步骤前按 Relay 准入重新检查每条进入消息的查看者,因此撤权前排队的读者输入永远不会驱动模型。读者对共享会话的停止与提示词一样经 Gateway 分派:策略在分享档位确立且会话受沙箱限制时放行,随后由所有者自己的会话控制器停止该会话当前运行的轮次,包括所有者发起的轮次。通用的 Peer、principal 与 remote access 设计见 [Peer principal 与 remote access](../../implemented/architecture/2026-10-12-peer-principal-and-remote-access.zh.md)。
受限文件工具解析其自身参数声明的每个路径,检查获授根目录包含解析结果,再把规范路径交给部署的工具;无路径的搜索使用会话工作目录,由替代定义按命名路径一样检查。受限编辑或写入执行同样的包含检查,然后按会话当前沙箱策略和正常观察策略经文件系统后端编辑或写入。
### 重试时保留同一个分支
一次 fork 请求绑定查看者、分享、源会话、操作身份和已完成前缀。Owner 在创建子会话前持久化预留该操作,并记录 owner 的 fork 应答返回的子会话;重试已记录的操作会重放该应答,而不是再次 fork。同一身份指定不同前缀会被拒绝。owner 的 `SessionForkRequest` 自己生成子会话 id,因此 host 不发送子会话身份:若 host 在子会话已创建、但操作尚未记录时退出,重试无法指名该子会话,会再创建一个。要关闭这一窗口,需要 Session Controller 的请求接受调用方提供的子会话身份。继承前缀停在无关排队输入之前;只保留紧随其后的请求元数据。被撤销的子会话保留日志,但不能以更宽权限恢复;移除分享不会暴露源会话不受限制的工具。
### owner 是谁
只有已验证的 owner 才能创建分享。对话框显示 owner 的 DSH 回读到的身份——名字、有效期、何时续期——没有显示名时用身份派生出来的标签。创建分享的代码本身也会拒绝未验证的 owner,不是只看按钮能不能点。
登录只做一次,且发生在 owner 点击时页面预留的窗口里。DSH 生成一个短寿命事务,绑定 Liveshare 服务端地址、Liveshare 服务端实例,以及允许完成它的页面;页面打开 DSH 指定的登录页,只接受来自该 origin、该窗口、该事务的一次性 code,并把它交回自己的 DSH。
Owner 使用自己保留的校验值兑换代码,以仅所有者可读写的权限保存凭据,并只向签发它的 Liveshare 服务端地址发送。首次登记需要已验证凭据;已有分享重连使用分享管理 token,显式身份认领保留该分享的 ACL、模式、过期时间和 token。分享指定的同事是 owner 的存储选择加上部署自身的允许列表;不会从显示身份派生任何内容。
### 读者页面顶部显示什么
被服务的页面复用侧栏原有的品牌行,而不是在应用上再压一条横条:原本的 Harness 标记,加上一个 LiveShare 入口,写明是谁分享的、对方跑的是哪个 DSH 版本(Liveshare 服务端自己的 release 另行标注)。点击它回到"我能看的分享"列表页。
### 连接断开时
读者的数据流可能失败:流消失、Liveshare 服务端取消请求、读取超时。页面只重试定义上可重复的失败;遇到认证、权限与分享不存在的错误就直接停下并显示原因,不会无休止地重连。
### 服务出去的页面怎么测
读者打开的页面就是 owner 自己的 Web 页面。Liveshare 服务端向 owner 要渲染好的 index 文档,在最前面插入一段很短的 carrier 脚本,页面的 shell 资源和插件 bundle 也从 owner 透传过来,所以页面跑的是 owner 版本的全部插件。carrier 只把页面的调用和流转成 Liveshare 服务端通道上的读请求;由 Liveshare 插件自己识别 viewer 页,挂上 viewer 界面,而不是分享按钮。owner 渲染这份 index 时用读者自己的访问快照取代 owner 基线,页面随后跟随读者的 `remoteAccess/follow` 快照;其他读请求都不夹带读者的授权。
读者页面在进程内测试:测试用 carrier、shell 和包括 Liveshare 在内的 client 插件拼出页面,顶替 Liveshare 服务端的读通道,并用脚本化的 owner 回答页面的读请求,因此断言看到的是真实 shell、carrier 与呈现插件。
### 本包对外提供什么
本包交付一个 Host 程序和一个浏览器端。公开入口包括 Host 导出(`lib/index.js`)、`./client`(`lib/client.js`,closure-factory bundle)、`./viewer-declarations`、分享管理 Remote 的 `./typert` 与 `./remote` 两个面、`./types`、组合包 patch `./cordis.patch.yml`,以及 locale JSON 文件。manifest 以 `dsh.client` 声明浏览器端,`platform` 为 `web`,并列出它需要的客户端服务;该入口读取 `__DSH_LIVESHARE_VIEWER__`:在被服务的查看页上挂载查看页 session 插件及其品牌、模型、权限、会话行与分享路径界面,否则先挂载 owner 生成的 Remote,再挂载 Session 头部操作。
分享管理是一个 `liveshareShares` Typert Remote,共十二个方法——`create`、`list`、`stop`、`updatePermissions`、`setCapabilities`、`capabilities`、`relay`、`setRelay`、`identity`、`audit`、`testRelay` 与 `testPublish`——背后是 owner 宿主执行的操作。仅存的同源 HTTP 路由是四条 owner 登录与凭据路由:`/api/liveshare.ownerLoginStart`、`/api/liveshare.ownerLoginRedeem`、`/api/liveshare.ownerLoginCancel` 与 `/api/liveshare.ownerCredential`。
出站网关是受监督的子进程,源码入口为 `src/owner-gateway/cli.ts`,发布入口为 `lib/owner-gateway/cli.js`;目录名让它与 DSH API gateway 明确区分。
## Alternatives considered
- **单独做一个只读页面,不服务正常客户端。** 否决:阅读需要浏览、分组与选择,另做一个页面就得把这些全部重建,还会悄悄丢掉产品要保留的导航。
- **每个按钮各自判断能不能点。** 否决:每个入口都读取 owner 计算的同一份访问快照,后加的界面才不会悄悄重新引入读者无权执行的动作。
- **让读者页面自己决定权限。** 否决:分享授予什么,只由 owner 与服务端决定;页面只陈述被告知的结果。
- **把没有模式字段的旧分享当作可写。** 否决:那会把 owner 从未配置的权限交给读者,因此没有模式的分享一律只读。
- **把登录 code 放进页面地址,或从弹窗的 cookie 读凭据。** 否决:地址会经浏览历史、referrer 和日志泄漏,页面也不应该持有凭据。
- **页面解释不了某个拒绝时,直接展示服务端的错误原文。** 否决:那段文本可能包含文件路径、凭据或别的读者的会话,所以页面只显示它能担保的稳定原因。
- **只在可编辑配置或沙箱模式中写执行限制。** 否决:子会话能够改变配置,而逐调用执行选择可能优先于模式。真实操作必须经过单调拒绝的守卫和已验证实现。
- **只在分派前检查模型路径,或全局限制所有文件系统读取。** 否决:分派前检查与工具调用之间还隔着其他监听器,而全局读取规则还会限制无关 Owner 工作。包含检查在已授权会话自身的读写中执行。
- **复用部署默认根目录,或把源会话整个尾部复制到子会话。** 否决:两者都可能包含查看者未获授的文件或待处理输入。
- **以部署共享密钥或自报显示字段作为所有者身份。** 否决:两者都不能识别批准本次发布的已登录人员。
- **保留三档。** 否决:`view` 与 `fork-readonly` 只差在读者能否分支并驱动子会话,而这已经是独立的 fork 开关。第三档重复了该开关,还会让两处答案互相矛盾。
- **保留分享级的附件上传开关。** 否决:附件是读者已经能看到的会话上的一条 prompt 的一部分。单独的开关重复了可见性,且只能从可见性里做减法,而上传角色本来就随读角色授予。
- **按轮次归属请求者并让所有者轮次豁免。** 上线后否决,之后缩小范围:持久化轮次请求者需要仅 Host 可见的消息来源投影、每次执行读取 turn boundary、重新检查接纳该轮次的分享,而且在混合或无法确立请求者时仍必须默认拒绝。现在的实现只为运行中轮次保留内存标记——`agent/pre-step` 读取进入消息的 principal,守卫读取该结论——因此所有者发起的轮次保留会话原生工具,且不新增会话事件。接受的代价是:所有者在查看者轮次中追加的输入仍留在受限轮次内;查看者消息若进入后续步骤,会立即收窄该轮次。
- **让每个工具自行声明档位或沙箱。** 否决:档位属于分享。逐调用选择会让某次执行凌驾于批准它的授权之上。
- **严格的 manifest 选中判断。** 否决:直接判断 `pkg.enabled` 代码最少,但默认安装启用 LiveShare 的唯一文档路径是编辑 `cordis.patch.yml`,那不会让组合包被选中。卡片会在插件恰好开始运行时仍然隐藏,与产品规则矛盾。
- **在 Host 派生有效启用状态。** 否决:`listBundles` 可以以新字段或改写字段报告有效状态,但这会为一条纯客户端的展示规则改动跨包契约,并诱使每个组合包消费方重新解释 `enabled`——它必须继续表示"profile 已选中"。
- **把 LiveShare 移出安装随附的可选组合包。** 否决:将它从可选集合移除即可从构造上让它处处不可见,但该包仍需随版本作为 npm 包发布并可安装,而且这会把组合包从 Host 列表中完全移除,详情入口也会随之消失。
- **保留选人器,或换成由目录支撑的选择器。** 推迟:点名同事需要身份目录,而 Liveshare 服务端目前不提供;弹窗无法给出它能验证的选择。已存储的 `selected` 与 `approval` 记录仍按 ACL 工作,在有目录契约之前新建入口保持退役;`liveshareShares.create` 的字段保留给这些记录与直接调用方,所以这是推迟 API,而不是删除它。
## Verification
- 读者页面保留正常导航,且在共享会话与任何分支上都不会出现读者无权执行的动作。
- 没有 Liveshare bundle 的 DSH 以及 owner 自己的页面,行为与以前完全一致。
- 分享使用两档之一及该档的沙箱;未知模式、读不懂的应答、没有模式的分享仍可读取,但绝不会被驱动。
- 没有已验证的 owner 身份,就无法创建分享。
- 弹窗创建的分享携带 `open`;已存储的 `selected` 或 `approval` 记录保留记录中的 ACL 与访问摘要,`create` 仍会存储调用方请求的 `selected` 模式及其主体。
- 登录只接受来自该事务 origin、窗口与 state 的一次性 code,别的都不接受;失败或放弃都会丢弃事务,凭据、cookie 与 code 都不进入 URL、存储或日志。
- 真实 Controller、ToolRuntime、文件系统和 profile 测试覆盖 fork 身份、冷恢复限制、有界执行、撤权和正常取消。已构建查看页必须使用真实 POST 载体和 Owner 授权处理器;确定性显示 fixture(测试前置数据)不替代执行或持久化测试。
- 生成的 API 文档和两种 SDK 投影与 `user-rpc` 来源 principal 保持一致;该字段只由 `agent/pre-step` 准入校验消费。
- `ViewerSessionMode` 只有两个成员;持久化的 `fork-readonly` 记录在 owner 状态加载时被拒绝。
- `view` 分享驱动源会话,拒绝文件写入与越界读取,并在设置 `allowsFork` 时可以 fork。
- 对读者可见的每条会话,无论档位与 fork 开关如何组合,`fileUploads/upload` 都被允许。
- 源会话的工具决策只读取会话上挂载的执行层:守卫放行本次安装的读取与检索面与无副作用工具的受限替代,可写会话下另有有界 `edit` 与 `write`,拒绝其它所有工具名,不读取轮次、请求者或来源。
- 单元测试与真实运行时测试覆盖档位策略、受限读写、fork 上限、执行层的安装与释放,以及基于持久消息来源的 pre-step 准入校验。
- 插件管理器的组件测试在声明行全部关闭时把该组合包排除在两个列表之外,在声明行有一条运行后列出它,并在它隐藏时仍能按组合包 ID 打开;录制的 golden 与刷新卡片数随该规则变化。
## Consequences
读者页面只能准确到服务端告诉它的程度。能力回答、owner 身份状态、会话模式、owner 的 DSH 版本、登录端点,都由 owner 的 DSH 与 Liveshare 服务端提供;在与本层一起部署之前,页面保持最保守的状态——只读,且登录无法完成——而不是去猜。
页面不是安全边界。无论页面做什么,owner 的 DSH 与 Liveshare 服务端都会拒绝读者的写入;这份访问快照的作用只是让页面不去提供注定被拒绝的按钮。
已有 ACL 保持不变。无模式记录仍可读取,但不会被驱动;互相矛盾的模式字段被拒绝。没有归属的分享显示为“未验证历史”。
Owner 重连状态以原子替换写入,并在单进程内排队,但多个进程共享一个文件仍可能丢失更新或争用网关。跨进程执行归属尚未解决;原子替换本身不是事务或路由认领。包含关系按路径检查:若本地进程在检查与打开之间把获授范围内的目录换成符号链接,可以让查看者的读取改指别处,或让写入落到沙箱可写根内的其他位置。基于目录句柄锚定的操作可以消除这一窗口,留待单独变更引入。发布前仍需完成查看页产物和 SDK 快照验证。
`view` 分享不是"只看":它会在只读沙箱里运行读者的 prompt 与工具调用。想要被动观看的部署没有单独模式,只能依赖读者不发送 prompt,而不能依赖档位名称。fork 是唯一可独立关闭的动作;附件上传无法单独关闭。
沙箱同样作用于所有者自己的轮次。只要某会话还有分享活跃,所有者也只能在授予工作区内读取,且只有在某个分享允许写入时才能写入;因此一条仅仅发布、并没有人在用的分享也会改变所有者自己的工具面,直到它停止或过期。取消同样没有归属:读者的一次停止会结束共享会话正在运行的任何轮次。
默认卡片列表、**更多**页及其录制 golden 各少一张卡片,管理器的刷新卡片数也减一。只认识插件页的人无法从该页发现 LiveShare 或直接开启它;启用路径由包 README 负责。通过 patch 路径启用的组合包,其卡片显示为关闭状态而行在运行,因为开关反映并写入 profile 的组合包选中状态;文档会说明两条路径及其差异。该规则覆盖一个显式命名的组合包而不是 manifest 标志,因此新增另一个"运行前隐藏"的组合包或退役这一个,都只是一行集合改动,并配一条测试和文档更新。
随版本发布的创建流程只产生组织链接分享:分享弹窗不发送 `accessMode` 与 `allowedSubjectIds`,因此新分享一律为 `open`,部署级 `LIVESHARE_ALLOWED_SUBJECTS` 是新分享唯一能获得的主体级收窄。已存储的 `selected` 与 `approval` 记录继续工作并保留访问摘要;`liveshareShares.create` 的字段仍留在 Remote 上供这些记录与直接调用方使用,而界面不暴露它们。
@@ -0,0 +1,22 @@
# Bilingual-pair consistency record for 2026-10-09-forge-panel.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-10-09-forge-panel.md
/agent-note-change-request-panel-and-worktree-sessions:
en: a524673f0d9cc9d8
zh: db8a2c1ad8f9c8a3
/agent-note-change-request-panel-and-worktree-sessions/problem:
en: 344bcc7e9825bbc8
zh: c1d0f4b54f5b3786
/agent-note-change-request-panel-and-worktree-sessions/decision:
en: b18b287b701aef93
zh: 462c0f561f1a0c18
/agent-note-change-request-panel-and-worktree-sessions/deferred:
en: 32ef8af85f4d2f66
zh: b82b67f94346399e
/agent-note-change-request-panel-and-worktree-sessions/alternatives-considered:
en: 91b3338f958ad0e9
zh: b64f30d333280084
/agent-note-change-request-panel-and-worktree-sessions/consequences:
en: 97976f073b9f65e0
zh: 95be3f7f375b0876
@@ -0,0 +1,84 @@
# Agent Note: Change request panel and worktree Sessions
Status: implemented
English | [中文](2026-10-09-forge-panel.zh.md)
## Problem
[Session change request bindings](2026-10-09-session-change-request-bindings.md) give each Session a list of change requests, but nothing shows it. A user who asks the agent to open change requests needs their checks, reviews, and mergeability without leaving the conversation, and wants a new Session's edits and commits kept out of the current checkout. Git must run where the Session's commands run, which can be a remote or confined execution world; inspection must not change the repository, and a worktree must exist before the model's first action.
## Decision
[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.md) is a two-face package. Its Host half, the `forgePanel` Remote, assembles everything the panel shows for one Session into one streamed state; the forge registry and the binding service keep only their domain operations. Its Client half is a right-sidebar tab, a Session header button that shows or hides it, a New Session option, a creation card above the composer, a toast, and the [Session list icons and hover card section](2026-10-20-forge-session-list.md). Every surface calls a bound item a change request, the forge registry's term, whatever its code host calls it. The [forge bundle](../../../../packages/experimental/forge-bundle/README.md) ships the panel, the forge registry, the GitHub provider, the bindings, and an isolated worktree service as one default-disabled Plugins page entry.
The Remote inspects the checkout through the Session's subprocess provider under the Session's read-only sandbox policy, so it reads what the Session's commands see and cannot write. Inspection reads the top-level, Git, and common Git directories, the branch or the abbreviated HEAD commit, the upstream, the remotes, and the size of the branch's changes against its fork point. It runs when a Session is followed, after each `working-directory/change`, `tool/result`, and `sandbox/mode` event, and on refresh, one at a time with one trailing rerun.
Status polling follows panel subscriptions: a Session's bindings are watched only while one of its surfaces is mounted, and the registry stops polling change requests nobody watches. Each card keeps the last snapshot a poll returned, so a transient failure keeps the title and number visible. A Session's only open change request starts expanded, and with several every card starts collapsed; the user's first toggle replaces this default.
An expanded card marks a draft ready for review and merges a change request the host would merge now. The forge seam carries both as provider actions that return a typed failure, and every snapshot says whether the host would merge the change request now for the signed-in user and which methods the repository allows, so the panel offers only actions that can succeed. A merge carries the head commit the card showed, and the host refuses it once new commits arrived. The panel acts only on change requests bound to the Session.
A new Session starts in a worktree through a request, not an immediate creation. Checking the option in the New Session row, a `conversation.hero.options` entry, appends a `plugin:client-ui-forge/worktree` record with the base branch; changing the base or clearing the option appends another, and nothing touches the repository until the user sends. The option is offered only where creation can succeed: under `workspace-write`, the checkout and its common Git directory must lie inside the writable root, which excludes a project that is itself a linked worktree. The worktree must exist before the first message's turn starts, so the turn runs there and its first request already names the new directory. When the message enters the Session's inbox, the panel's `agent/inbox/inserted` listener starts maintenance of the Agent before the delivery wakes it; the Agent latches the wake until the maintenance ends, and the panel creates and enters the worktree inside it. The creation's stage streams to a card above the composer, where Cancel stops the attempt. After a stopped or failed attempt the message keeps waiting in the inbox, and the card keeps the failure and offers another attempt with a new branch name or Continue without a worktree, which withdraws the request and lets the message go on. An `agent/pre-step` listener still creates the worktree at the first step proposal of a Session whose request is open, because a message that enters while the Agent runs a turn or other maintenance gets no hold; it moves the working-directory notice into the proposed messages, and a failure ends the turn. The `forgeWorktree` projection ignores requests after the first `turn/start` and creations after the first `step/start`, so a running Session never moves. The tab's first row names the branch the Session works on, and the Session list marks a Session that works in its created worktree.
The worktree a Session works in can be removed outside it. Each inspection checks whether the created worktree's directory still exists, and every message that enters while the Agent is idle starts a hold that checks again, so the message waits without starting its turn while the directory is missing. The check must come before the turn: while it assembles a step's context, the working-directory service moves a Session whose directory is gone back to its project, before any pre-step listener runs. Restore checks the branch out again at the recorded directory, or creates the branch again at its base commit when it is gone; Continue in the project moves the Session to the source checkout. Either runs inside the hold and lets the waiting message go on.
The panel does not move a Session between existing worktrees. Removing a change request appends a `dismiss` record, and its toast offers Undo through `track`.
The panel also shows the changes behind a Session's work in a changes view, a resource tab type of this package. The Remote parses every diff on the Host into files and hunks, so the browser draws them without a patch parser, and bounds each read with `maxDiffBytes` and `maxUntrackedFiles`. Local changes come from confined `git diff` in the Session's execution world, in a scope the user picks: the branch against its fork point by default, or the uncommitted, unstaged, or staged changes; untracked files are read through the Session's filesystem and shown as new files. A change request's changes come from its code host at the head of the binding's latest status, through two provider reads the forge seam gained, `diff` and `fileText`. The Host keeps the patch it read last for each change request, keyed by the head commit and base branch and bounded by `maxCachedDiffBytes`, so reopening, refreshing, and navigating at the same head download nothing again; a host or provider that refuses an answer over its size limit reports the forge failure `too-large`, and the view then offers the change request's web page instead of a retry. Unchanged lines between hunks expand from the file's new side, which is the working tree, the index, or the head commit, and only while that text still matches the hunks. Each inspection also sizes the branch's changes for a local changes card, shown while no open change request carries the checked-out branch; its Create change request sends the Session a user message, so the Agent creates the change request with its own tools and the binding follows from `gh pr create` as for any other.
## Deferred
A left-navigation review page next to Plugins and Scheduled tasks, listing the user's pull requests or merge requests on the selected provider, is deferred. It attaches without changing this package's Session surfaces:
- **Provider capability** — an optional `ForgeProvider` method such as `listForViewer(host, filter, signal)` returns change request references; GitHub implements it with `gh search prs --author @me` or the GraphQL `viewer`, and the registry aggregates enabled providers.
- **Remote** — a stream like `follow` that is keyed by provider and filter instead of a Session and polls through the same `ctx.forge.watch`.
- **Client** — a `sidebar.panellist` entry with a `main` page in this package, as `ui-schedule` does, rendering `ChangeRequestCard`, which takes a change request, its snapshot, and optional actions.
- **Settings** — provider choice and hosts on the bundle's configuration page.
The changes view offers no scope for one commit or for the last turn yet; both would be further `ForgeLocalScope` kinds read through `localDiff`.
## Alternatives considered
**Per-host nouns.** Calling items pull requests on GitHub and merge requests on GitLab would need each provider to declare its noun and every string to take it; GitHub is the only provider, and one term keeps the copy fixed.
**Merging through `gh pr merge`.** It enables auto-merge while required checks are pending and joins a merge queue when the base requires one, so a click could schedule a later merge instead of merging. The REST merge endpoint merges at once or refuses.
**Deleting the head branch after a merge.** The Session's worktree can be on that branch.
**A Host service package for Git inspection.** No second consumer needs checkout facts yet; a model tool can move them into a shared service when it exists.
**Running Git as a Host process.** Remote and confined execution worlds would show the Host's files instead of the Session's, and the Host would read directories the Session cannot.
**Creating the worktree when the option is checked.** Every toggle or base change would create and abandon a checkout, and the Session would move before the user decided to send.
**Carrying the option in the Session creation request.** The Session controller owns a closed request; a plugin record keeps the option inside this package and survives a reload of the blank Session.
**Switching between existing worktrees from the panel.** A switched Session keeps the sandbox root of its project, so a worktree outside the project stays read-only, and moving mid-conversation splits the model's view of the files. Starting a Session in its worktree avoids both.
**The option in the composer tool row.** The tool row stays after the first message, when the option no longer applies; the New Session row disappears once the Session starts. The row offered only single slots, so `ui-conversation` gained the list slot `conversation.hero.options`.
**Creating the worktree within the first turn.** The turn has started when the first step proposal runs: its context was assembled for the old directory, a failure can only end the turn and drop the claimed message, and the working-directory service has already moved a Session out of a removed worktree. It remains the fallback for a message no hold took.
**Holding the message at Agent admission.** An `agent-controller/admission` stage would hold a message before it enters the inbox, so the composer keeps it until the worktree exists and a failure returns it for editing. It needs a controller service in front of every sender's delivery, a core addition that one panel does not justify; a hold at inbox insertion uses the existing inbox event and maintenance, and covers every sender.
**Returning a waiting message to the composer.** Once the Host accepted a message the composer cannot take it back without a new public API in the conversation package for one plugin; the message keeps waiting with the card's actions instead.
**A submit gate in the Web composer.** The browser held the message before the Host saw it, so every check needed a Remote call, the stage of a refused message lived only in the browser, and other senders bypassed it. A hold at inbox insertion keeps the message where it enters the Agent and the failure in the panel state.
**The creation card in the transcript.** Transcript nodes accept no plugin kinds; the input dock above the composer is the nearest list slot, and it renders on the New Session screen.
**Manual URL tracking.** It duplicated the bindings the Session's own commands record. Bindings come from `gh pr create`, the `gh pr` commands that act on one change request, pushes, and the branch the checkout tracks, and `track` remains for Undo.
**Parsing patches in the browser.** The Remote would carry unbounded patch text, and the browser would need a patch parser; the Host bounds the read and parses it once.
**Reusing the review tab's file comparison.** It belongs to another plugin, which this package may not import at run time, and moving it into the shared primitives would change a shared package for one consumer; the view is built from the shared highlighter, menu, tooltip, and diff color tokens instead.
**Reading a change request's diff from the local repository.** The checked-out branch can be ahead of or behind the host, and the base may not be fetched; the host's diff is what reviewers see. The view says when the checked-out branch has commits beyond the head instead.
**Recognizing a too-large diff from the failure message in the panel.** The message belongs to the provider and its host and changes with them; a failure code lets each provider report its own limits, and the panel branches on the code.
**Creating the change request from the card.** The panel would need to push the branch and compose the title and description; a message lets the Agent do both with its tools and the user's instructions, and keeps one way to bind change requests.
## Consequences
The panel shows current status only while it is open; checks that finish while nothing is mounted produce no notification. Changes made outside the Session appear after Refresh or the next tool result. Every inspection spawns up to eleven short Git processes in the Session's execution world with one remote, and two more for each further remote it tries for a fork point. A worktree exists only for a Session that requested it before its first step, and a failed or cancelled attempt can leave its branch or checkout behind. A message that enters while the Agent runs a turn or other maintenance gets no hold, so its worktree is created within the first turn and a removed worktree is not checked. While a message waits, the Agent stays idle in maintenance: the composer offers no stop, and callers waiting for the Agent to become idle wait as well. Restoring loses changes that were never committed. A base branch that requires a merge queue cannot merge from the panel. With Git Worktrees also enabled, two independent worktree services exist; the model's tool never sees the panel's service. A local or change request diff over `maxDiffBytes`, or one the host refuses, shows no files. Expanding unchanged lines of a change request reads each file from the code host once.
@@ -0,0 +1,84 @@
# Agent Note: 变更请求面板与 worktree Session
Status: implemented
[English](2026-10-09-forge-panel.md) | 中文
## Problem
[Session 变更请求绑定](2026-10-09-session-change-request-bindings.zh.md)为每个 Session 提供了变更请求列表,但没有地方展示。让 agent 开变更请求的用户需要在不离开对话的情况下看到它们的检查、评审与能否合并,也希望新 Session 的修改和提交不影响当前检出。Git 必须在 Session 命令运行的地方执行,这可能是远程或受限的执行环境;检查不能改动仓库,而且 worktree 必须在模型第一次动作之前就存在。
## Decision
[`dsh-experimental-client-ui-forge`](../../../../packages/experimental/client-ui-forge/README.zh.md) 是一个双端包。宿主端的 `forgePanel` Remote 把面板为一个 Session 展示的全部内容组装成一条流式状态;forge 注册表与绑定服务只保留各自的领域操作。客户端包括一个右侧边栏标签页、一个显示或隐藏它的 Session 标题栏按钮、一个新会话选项、一张位于输入框上方的创建卡片、一个 toast,以及[会话列表中的图标与悬浮卡片部分](2026-10-20-forge-session-list.zh.md)。所有界面都把已绑定的条目称为变更请求(forge 注册表的术语),不随代码托管平台的叫法变化。[forge 套件](../../../../packages/experimental/forge-bundle/README.zh.md)把面板、forge 注册表、GitHub 提供方、绑定服务与一个隔离的 worktree 服务作为插件页中默认禁用的一个条目发布。
Remote 通过 Session 的子进程提供方、在 Session 的只读沙箱策略下检查检出,因此它读到的与 Session 命令看到的相同,且无法写入。检查读取顶层目录、Git 目录与公共 Git 目录、分支或缩写的 HEAD 提交、上游与远端,以及分支相对于其分叉点的变更规模。它在 Session 被关注时、每个 `working-directory/change`、`tool/result` 与 `sandbox/mode` 事件之后以及刷新时运行,一次只运行一个,最多再追加一次。
状态轮询跟随面板订阅:只有该 Session 的某个界面挂载时才监听其绑定,注册表停止轮询无人监听的变更请求。每张卡片保留最近一次轮询得到的快照,因此短暂的失败不会让标题与编号消失。Session 只有一个打开的变更请求时,它的卡片默认展开;有多个时全部折叠;用户第一次展开或收起卡片后,以用户的选择为准。
展开的卡片可以把草稿标记为可评审,也可以合并主机现在就会合并的变更请求。forge seam 把两者作为提供方操作承载,失败时返回带类型的结果;每个快照都说明主机现在是否会为已登录用户合并该变更请求、仓库允许哪些合并方式,因此面板只提供能够成功的操作。合并带上卡片显示的 head 提交,有新提交到达后主机会拒绝。面板只操作绑定到该 Session 的变更请求。
新 Session 通过请求而不是立即创建来以 worktree 开始。勾选新会话那一行中的选项(一个 `conversation.hero.options` 条目)会追加一条带基准分支的 `plugin:client-ui-forge/worktree` 记录;更换基准或取消勾选会再追加一条;用户发送之前不会改动仓库。只有创建能够成功时才提供该选项:在 `workspace-write` 下,检出及其公共 Git 目录必须位于可写根之内,这排除了本身就是链接 worktree 的项目。worktree 必须在第一条消息的这一轮开始之前就存在,这样这一轮才会在其中运行,第一次请求也已经写明新目录。消息进入 Session 的收件箱时,面板的 `agent/inbox/inserted` 监听器在这次投递唤醒 Agent 之前启动 Agent 的维护任务;Agent 会暂存唤醒直到维护任务结束,面板在维护任务内创建并进入 worktree。创建的阶段以流的形式推送给输入框上方的卡片,卡片上的“取消”停止这次尝试。尝试被停止或失败后,消息继续在收件箱中等待,卡片保留失败信息,并提供用新分支名再试一次,或“不使用工作树”:撤回请求并让消息继续。一个 `agent/pre-step` 监听器仍会在请求未完成的 Session 的第一个步骤提议中创建 worktree,因为 Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留;它把工作目录通知移入本步提议的消息,失败时这一轮结束。`forgeWorktree` 投影忽略第一个 `turn/start` 之后的请求以及第一个 `step/start` 之后的创建,因此运行中的 Session 永远不会移动。标签页顶部一行写出 Session 所在的分支,会话列表标出在所创建 worktree 中工作的 Session。
Session 所在的 worktree 可能在 Session 之外被删除。每次检查都会确认所创建 worktree 的目录是否还在;Agent 空闲时进入的每条消息都会启动一次暂留并再确认一次,目录缺失期间消息等待、不开始这一轮。这次确认必须发生在这一轮之前:组装一个步骤的上下文时,工作目录服务会把目录已不存在的 Session 移回其项目,而这发生在任何 pre-step 监听器运行之前。“恢复”在记录的目录重新检出分支;分支也已删除时,在其基准提交重新创建分支。“在项目目录继续”把 Session 移回源检出。两者都在暂留内运行,之后放行等待中的消息。
面板不在现有 worktree 之间移动 Session。移除变更请求会追加一条 `dismiss` 记录,它的 toast 通过 `track` 提供“撤销”。
面板还通过变更视图展示 Session 工作背后的变更;变更视图是本包的一个资源标签页类型。Remote 在宿主上把每个 diff 解析成文件与 hunk,因此浏览器无需补丁解析器即可绘制,每次读取都受 `maxDiffBytes` 与 `maxUntrackedFiles` 约束。本地变更来自 Session 执行环境中受限的 `git diff`,范围由用户选择:默认是分支相对于分叉点的变更,也可以是未提交、未暂存或已暂存的变更;未跟踪文件经 Session 的文件系统读取,显示为新文件。变更请求的变更来自代码托管平台,取自绑定最新状态中的 head,经 forge seam 新增的两个提供方读取操作 `diff` 与 `fileText` 获得。Host 为每个变更请求保留最近读到的补丁,按 head 提交与目标分支区分,总量受 `maxCachedDiffBytes` 约束,因此在同一 head 下重新打开、刷新与导航都不会再次下载;托管平台或提供方因结果超过其大小上限而拒绝时报告 forge 失败 `too-large`,视图随之提供变更请求的网页而不是重试。hunk 之间未变更的行从文件的新版本展开,即工作树、暂存区或 head 提交,并且只在该文本仍与 hunk 吻合时展开。每次检查还会为变更卡片统计分支的变更规模;没有打开中的变更请求以当前检出分支为源分支时才显示该卡片。卡片上的“创建变更请求”向 Session 发送一条用户消息,由 Agent 用自己的工具创建变更请求,绑定像其他变更请求一样来自 `gh pr create`。
## Deferred
在左侧导航“插件”“自动化任务”旁边新增代码审查页、列出用户在所选提供方上的 pull request 或 merge request,这项工作推迟。它的接入不需要改动本包面向 Session 的界面:
- **提供方能力**——`ForgeProvider` 增加一个可选方法,例如 `listForViewer(host, filter, signal)`,返回变更请求引用;GitHub 用 `gh search prs --author @me` 或 GraphQL `viewer` 实现,注册表汇总已启用的提供方。
- **Remote**——一条类似 `follow` 的流,以提供方与筛选条件而不是 Session 为键,通过同一个 `ctx.forge.watch` 轮询。
- **客户端**——在本包中以 `sidebar.panellist` 条目加一个 `main` 页面实现(与 `ui-schedule` 相同),渲染接收变更请求、其快照与可选操作的 `ChangeRequestCard`。
- **设置**——在套件的配置页中选择提供方与主机。
变更视图目前还没有单个提交与上一轮的范围;两者将是经 `localDiff` 读取的更多 `ForgeLocalScope` 种类。
## Alternatives considered
**按平台使用不同名称。** 在 GitHub 上称 pull request、在 GitLab 上称 merge request,需要每个提供方声明自己的名称,所有文案也要随之变化;而 GitHub 是唯一的提供方,统一的术语让文案保持不变。
**通过 `gh pr merge` 合并。** 必需检查尚未完成时它会启用自动合并,基准分支要求合并队列时它会加入队列,因此一次点击可能安排了一次以后的合并而不是立即合并。REST 合并接口要么立即合并,要么拒绝。
**合并后删除 head 分支。** Session 的 worktree 可能就在这个分支上。
**为 Git 检查单独建一个宿主服务包。** 目前没有第二个使用方需要检出信息;出现模型工具时,可以再把它们移入共享服务。
**以宿主进程运行 Git。** 远程与受限的执行环境会显示宿主的文件而不是 Session 的文件,宿主还会读取 Session 无法读取的目录。
**勾选时立即创建 worktree。** 每次切换勾选或更换基准都会创建并丢弃一个检出,而且用户决定发送之前 Session 就已经移动。
**在创建 Session 的请求中携带该选项。** 这个请求由 Session 控制器拥有,字段是封闭的;插件记录让选项留在本包之内,并且在空白 Session 重新加载后仍然保留。
**在面板中切换现有 worktree。** 切换后的 Session 仍保留其项目的沙箱根,因此项目之外的 worktree 依然只读;对话中途移动还会让模型对文件的认识前后割裂。让 Session 从一开始就在自己的 worktree 中运行可以同时避免这两点。
**把选项放在输入框工具栏。** 发出第一条消息后工具栏依然存在,而那时该选项已不再适用;新会话那一行在 Session 开始后就会消失。那一行原先只提供单值 slot,因此 `ui-conversation` 增加了列表型 slot `conversation.hero.options`。
**在第一轮之内创建 worktree。** 第一个步骤提议运行时这一轮已经开始:上下文按旧目录组装,失败只能结束这一轮并丢弃已认领的消息,而工作目录服务已经把 Session 移出被删除的 worktree。它只作为没有被暂留的消息的兜底。
**在 Agent 接纳阶段暂留消息。** `agent-controller/admission` 阶段会在消息进入收件箱之前暂留它,因此 worktree 建好之前消息一直留在输入框里,失败时退回以便编辑。它需要在每个发送方的投递之前加一个控制器服务,单个面板不足以支撑这项核心改动;在收件箱插入时暂留只用现有的收件箱事件与维护任务,并覆盖所有发送方。
**把等待中的消息退回输入框。** 宿主接收消息之后,输入框没有办法把它收回,除非为一个插件在会话包里新增公共 API;消息改为在卡片的操作下继续等待。
**在 Web 输入框里设发送闸门。** 浏览器在宿主看到消息之前就拦住它,因此每次检查都要一次 Remote 调用,被拒绝消息的阶段只存在于浏览器中,其他发送方也会绕过它。在收件箱插入时暂留,让消息停在它进入 Agent 的位置,并把失败保留在面板状态中。
**把创建卡片放进对话记录。** 对话记录的节点不接受插件定义的类型;输入框上方的停靠区是最近的列表型 slot,而且它在新会话页也会渲染。
**手动粘贴 URL 跟踪。** 它与 Session 自己的命令记录下的绑定重复。绑定来自 `gh pr create`、操作单个变更请求的 `gh pr` 命令、推送以及检出所跟踪的分支,`track` 保留给“撤销”使用。
**在浏览器中解析补丁。** Remote 将传输不受限的补丁文本,浏览器也需要补丁解析器;宿主约束读取并只解析一次。
**复用代码审查标签页的文件对比。** 它属于另一个插件,本包在运行时不能导入;把它移入共享基础组件又会为一个使用方改动共享包。变更视图改用共享的高亮器、菜单、提示与 diff 颜色 token 构建。
**从本地仓库读取变更请求的 diff。** 当前检出分支可能领先或落后于托管平台,基准分支也可能没有拉取;评审者看到的是托管平台的 diff。视图改为在当前检出分支有超出 head 的提交时给出说明。
**在面板里根据失败信息识别 diff 过大。** 失败信息属于提供方及其托管平台,会随它们变化;失败代码让每个提供方报告自己的上限,面板只按代码分支。
**直接在卡片上创建变更请求。** 面板需要推送分支并撰写标题与描述;发送消息让 Agent 用自己的工具和用户的指示完成这两件事,绑定变更请求的方式也只保留一种。
## Consequences
面板只在打开时显示当前状态;没有任何界面挂载时检查结束不会有通知。Session 之外的改动要等到刷新或下一个工具结果才会出现。只有一个远端时,每次检查最多在 Session 的执行环境中启动十一个短暂的 Git 进程;为寻找分叉点每多尝试一个远端,就多启动两个。只有在第一步之前提出请求的 Session 才会有 worktree,失败或被取消的尝试可能留下它的分支或检出。Agent 正在运行一轮或其他维护任务时进入的消息不会被暂留,因此它的 worktree 在第一轮之内创建,已删除的 worktree 也不会被检查。消息等待期间,Agent 以空闲状态处于维护任务中:输入框不提供停止,等待 Agent 空闲的调用方也会一并等待。恢复会丢失从未提交的修改。要求合并队列的基准分支无法从面板合并。同时启用 Git Worktrees 时会存在两个互不影响的 worktree 服务;模型的工具看不到面板的服务。本地或变更请求的 diff 超过 `maxDiffBytes`,或被托管平台拒绝时,不显示任何文件。展开变更请求中未变更的行时,每个文件只从托管平台读取一次。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-09-session-change-request-bindings.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-10-09-session-change-request-bindings.md
/agent-note-session-change-request-bindings:
en: 89ec58adddf53fec
zh: 919e8557d9923fdb
/agent-note-session-change-request-bindings/problem:
en: e736740546fb8219
zh: 46a16a7a633716ec
/agent-note-session-change-request-bindings/decision:
en: 2bbd058f4edc8bb5
zh: 5cc12bb0aa0864e2
/agent-note-session-change-request-bindings/alternatives-considered:
en: 15fdaef4e432fc72
zh: 8d36c859bd89f403
/agent-note-session-change-request-bindings/consequences:
en: a2960e50ec52c9b6
zh: fd23f4fe1137787d
@@ -0,0 +1,31 @@
# Agent Note: Session change request bindings
Status: implemented
English | [中文](2026-10-09-session-change-request-bindings.zh.md)
## Problem
A Session that opens or updates pull requests leaves them only in its shell output. Neither the user nor later consumers have a list of the change requests the Session works on or a shared view of their status. Code hosts differ in URLs, command-line tools, and status models, and any association must survive fork and resume without changing what the model sees.
## Decision
The experimental forge capability seam has three roles. [`dsh-experimental-forge`](../../../../packages/experimental/forge/README.md) defines provider-neutral repository and change request references, a provider registry, and shared polling: watchers of one change request share one loop, each forge has one batched fetch in flight, and the cadence follows check, mergeability, and lifecycle state. [`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.md) is the first provider; it reads GitHub through the GitHub CLI on the host, so credentials stay in that tool's own store and never enter Session execution worlds. [`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.md) binds change requests to Sessions.
Bindings derive from the Session log instead of a separate store. A Session projection reads `bash` and `pwsh` calls: `gh pr create` results bind the URLs they print, `gh pr view`, `gh pr edit`, and `gh pr checkout` calls bind the change request they act on, and `git push` results record the branches they updated. Those three commands act on one change request, so only the URL given as their first argument and the URL their output prints for that change request count; links in a description name other change requests. Caller actions and discovery results are `plugin:forge-session/binding` records. Because every input is a log event, fork and resume carry bindings without new persistence, and the projection rebuilds them from any checkpoint. The printed URL or ref line is the evidence, not the exit status: an existing pull request reported by a failing `gh pr create` still binds, and a partially rejected push still records the branches it updated.
After each push result, and when a caller such as the forge panel reports the branch a Session's checkout tracks, the service looks up open change requests for that branch through the provider and records the unbound ones. A dismissed binding stays hidden from discovery and returns only through `track`. Polled status is not logged; it is process-local state that exists while a watcher displays the Session. The projection's client view therefore carries bindings without status.
## Alternatives considered
**A host-side binding database.** A separate store would need its own fork, resume, and deletion rules; plugin records and projections already provide them.
**A model tool that registers each change request.** The association would depend on the model calling an extra tool, and every request would carry its schema. Parsing the commands the model already runs costs no tokens.
**Logging polled status in the Session.** Status changes independently of the conversation. Logging it would grow the log with every poll and expose stale snapshots to later readers.
**Running `gh` through the Session's subprocess provider.** Remote and sandboxed execution worlds would need GitHub credentials, and status would depend on the world a Session uses. The host CLI keeps one credential owner.
## Consequences
Bindings appear without model cooperation for the common `gh pr create`, `gh pr view`, `gh pr edit`, `gh pr checkout`, and `git push` flows and add no model tokens. Commands run through scripts, aliases, or other command-line tools are not recognized, and a change request opened elsewhere for a branch appears only after another push, another report of that branch, or a `track`. Another provider adds its own access and URL forms behind the same provider contract without changing the binding rules or the polling. The host needs `gh` signed in for each GitHub host.
@@ -0,0 +1,31 @@
# Agent Note: Session 变更请求绑定
Status: implemented
[English](2026-10-09-session-change-request-bindings.md) | 中文
## Problem
打开或更新 pull request 的 Session 只在 shell 输出里留下它们。用户和后续消费方都无法得到该 Session 正在处理的变更请求(change request)列表,也无法共享查看其状态。各代码托管平台的 URL、命令行工具与状态模型各不相同,而这种关联必须在 fork 与恢复后保留,且不能改变模型看到的内容。
## Decision
实验性的 forge 能力 seam 由三个角色组成。[`dsh-experimental-forge`](../../../../packages/experimental/forge/README.zh.md) 定义提供方无关的仓库与变更请求引用、提供方注册表与共享轮询:同一变更请求的监听方共享一个循环,每个 forge 同时只有一次批量读取,轮询节奏随检查、可合并性与生命周期状态变化。[`dsh-experimental-forge-github`](../../../../packages/experimental/forge-github/README.zh.md) 是第一个提供方;它通过宿主上的 GitHub CLI 读取 GitHub,因此凭据保留在该工具自己的存储中,从不进入 Session 的执行环境。[`dsh-experimental-forge-session`](../../../../packages/experimental/forge-session/README.zh.md) 把变更请求绑定到 Session。
绑定从 Session 日志派生,而不是存放在单独的存储中。一个 Session 投影读取 `bash` 与 `pwsh` 调用:`gh pr create` 的结果绑定其输出的 URL,`gh pr view`、`gh pr edit` 与 `gh pr checkout` 调用绑定它们所操作的变更请求,`git push` 的结果记录其更新的分支。这三个命令只操作一个变更请求,因此只计入作为第一个参数给出的 URL 和输出为该变更请求打印的 URL;描述正文里的链接指向的是其他变更请求。调用方操作与发现结果是 `plugin:forge-session/binding` 插件记录。由于所有输入都是日志事件,fork 与恢复无需新的持久化即可带上绑定,投影也能从任意检查点重建它们。证据是输出中的 URL 或 ref 行,而不是退出状态:失败的 `gh pr create` 报告的已有 pull request 仍会绑定,部分被拒绝的推送仍会记录它已更新的分支。
每次推送结果之后,以及 forge 面板等调用方报告 Session 检出所跟踪的分支时,服务通过提供方为该分支查找打开状态的变更请求,并记录尚未绑定的那些。被隐藏的绑定不会被发现逻辑恢复,只能通过 `track` 恢复。轮询到的状态不写入日志;它是进程内状态,仅在有监听方展示该 Session 时存在。因此投影的客户端视图只带绑定,不带状态。
## Alternatives considered
**宿主侧绑定数据库。** 单独的存储需要自己的 fork、恢复与删除规则;插件记录与投影已经提供了这些规则。
**由模型工具登记每个变更请求。** 关联将取决于模型是否调用额外的工具,而且每个请求都要携带该工具的 schema。解析模型本来就会运行的命令不消耗 token。
**把轮询状态写入 Session 日志。** 状态的变化独立于对话。写入日志会让日志随每次轮询增长,并把过期快照暴露给后续读取方。
**通过 Session 的子进程提供方运行 `gh`。** 远端与沙箱执行环境将需要 GitHub 凭据,状态也会取决于 Session 使用的执行环境。宿主 CLI 让凭据只有一个所有者。
## Consequences
在常见的 `gh pr create`、`gh pr view`、`gh pr edit`、`gh pr checkout` 与 `git push` 流程中,绑定无需模型配合即可出现,且不增加模型 token。通过脚本、别名或其他命令行工具运行的命令无法识别;在别处为某个分支打开的变更请求,要等到下一次推送、再次报告该分支或 `track` 之后才会出现。新的提供方在同一提供方契约之后加入自己的访问方式与 URL 形式,无需改变绑定规则或轮询。宿主需要为每个 GitHub 主机登录 `gh`。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-20-forge-session-list.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-10-20-forge-session-list.md
/agent-note-forge-state-in-the-session-list:
en: b5876f7873857921
zh: 56fac53370de206d
/agent-note-forge-state-in-the-session-list/problem:
en: 07ba0c3bf73a2446
zh: 2413783a6697133e
/agent-note-forge-state-in-the-session-list/decision:
en: e1d03cf4e1afd5aa
zh: b2386cb7c17c58dd
/agent-note-forge-state-in-the-session-list/alternatives-considered:
en: 3b08440ee2bf7809
zh: a90e3fcb89cf3f74
/agent-note-forge-state-in-the-session-list/consequences:
en: 25a17eac43beb705
zh: c138f693ff07c91d
@@ -0,0 +1,39 @@
# Agent Note: Forge state in the Session list
Status: implemented
English | [中文](2026-10-20-forge-session-list.zh.md)
## Problem
The [forge panel](2026-10-09-forge-panel.md) named a Session's branch in a chip beside the Session title. The header's action row does not shrink, so a long branch overlapped other header entries such as the background task label. Finding which Sessions work in their own worktrees, and which of their change requests need attention, also meant opening each Session's tab. The Session list offers seats for row icons and a hover card section, `sidebar.session.row.trailing` and `sidebar.session.row.hover`, whose occupants must not load or bind Sessions. A row needs two kinds of fact: what the Session log records, which is the worktree the panel created and entered and the change requests the Session binds, and what only polling knows, which is each change request's state and checks.
## Decision
The chip beside the Session title is gone, and the tab's first row names the branch. A Session row shows, after its time, a branch glyph while the Session works in the worktree the panel created for it, and the glyph of its most urgent change request: one needing attention first, then open, draft, not yet polled, merged, and closed. An open change request's glyph keeps the row's subdued color with a dot for its checks; merged and closed ones keep their state colors. The row's hover card section names the worktree branch and what it started from, then up to three change requests with their state and checks, and how many more there are.
Log facts reach the list as projection client views, which the Session summary carries. The `forgeWorktree` projection also records the Session's directory from its header and each `working-directory/change`, and its view is the created worktree while that directory is the worktree or lies below it, else null. The `forgeChangeRequests` view lists the bindings without status, as the [binding decision](2026-10-09-session-change-request-bindings.md) intended. The projection cache keeps both for Sessions that are not loaded.
Polled states reach the list through the `forgePanel.changeRequestStates` stream. One Host run serves every stream and stops with the last one. It lists Sessions through `sessionQuery` without loading them, takes the visible bindings of loaded Sessions from `ctx.forgeSession` and those of other Sessions from their cached summary, follows binding changes of loaded Sessions, and subscribes to each bound change request through the shared `ctx.forge.watch`; closed requests use the idle polling cadence, and merged requests remain subscribed for explicit refresh. Every changed state is stored in the `forge_panel_change_requests` storage domain under the SHA-256 digest of its URL, so a restart shows the last known states before the next poll, and unbound stored states are dropped only when every listed Session has known bindings. A stream sends a `baseline` of the states its caller may read, then `changed` frames, filtered through Remote access.
The browser keeps one source of states for every row and hover section: the first subscriber opens the stream, the last one closes it, and a transport reset reopens it.
## Alternatives considered
**Leaving change requests to the tab.** The forge panel first rejected a Session-row hover section because, for one Session, it repeated the tab. The list compares every Session at once, which no tab does, and the hover section adds only the facts a row's glyphs summarize.
**Keeping the branch beside the Session title.** Truncating the chip would hide the branch name, and the header shows only the open Session; the list shows every Session, and the tab names the branch in full.
**Polled states in a projection.** Projections fold the Session log, and polled states are not logged. A projection would either log every poll, adding events that no model request needs, or depend on process-local state that a replay cannot reconstruct.
**Following every listed Session's panel.** A panel inspects its checkout with Git in the Session's execution world and probes sign-in; the list needs only bindings and change request states.
**Loading listed Sessions to read their bindings.** Listing would replay every Session log; the cached summary answers without loading.
**Keeping the states only in memory.** Every row would show unknown states after a restart until each change request is polled again. The stored states are derived data, so a record that fails its schema is set aside instead of failing the run.
**A branch line for a Session that works in its project directory.** Reading that branch needs Git in the Session's execution world, which the list must not reach without loading the Session.
## Consequences
The list shows every Session's worktree and change request states without loading a Session, a restart shows the last known states at once, and the header no longer overlaps. While a row is shown, every open change request that a listed Session binds is polled, so many Sessions with open change requests mean more requests to the code host; closed requests keep polling at the idle cadence, and merged ones stop automatic polling. A Session without a cached summary shows its worktree and change requests in the list only after it is opened, and one last used before this change shows its worktree only then, because the `forgeWorktree` state changed and older cached rows are discarded. Checks that finish while no row is shown produce no notification.
@@ -0,0 +1,39 @@
# Agent Note: 会话列表中的 forge 状态
Status: implemented
[English](2026-10-20-forge-session-list.md) | 中文
## Problem
[forge 面板](2026-10-09-forge-panel.zh.md)原先在 Session 标题旁的标签中写出 Session 的分支。标题栏的操作行不会收缩,因此较长的分支名会与后台任务标签等其他标题栏条目重叠。想知道哪些 Session 在自己的 worktree 中工作、它们的哪些变更请求需要处理,也必须逐个打开 Session 的标签页。会话列表为行图标与悬浮卡片提供了位置,即 `sidebar.session.row.trailing` 与 `sidebar.session.row.hover`,其中的条目不得加载或绑定 Session。一行需要两类事实:Session 日志记录的事实,即面板创建并进入的 worktree 以及 Session 绑定的变更请求;以及只有轮询才知道的事实,即每个变更请求的状态与检查。
## Decision
Session 标题旁的标签已移除,由标签页顶部一行写出分支。Session 在面板为它创建的 worktree 中工作时,会话行在时间之后显示分支图标;另有一个图标显示它最需要关注的变更请求,依次为需要处理、打开、草稿、尚未轮询、已合并、已关闭。打开中的变更请求图标保持会话行的浅色,并用圆点标出检查;已合并与已关闭的保留各自的状态颜色。会话行悬浮卡片中的部分写出 worktree 分支及其起点,再列出最多三个变更请求的状态与检查,并说明还有多少个。
日志事实经投影的客户端视图进入列表,由 Session 摘要携带。`forgeWorktree` 投影还从 Session 头部与每个 `working-directory/change` 记录 Session 的目录;该目录就是所创建的 worktree 或位于其下时,视图为该 worktree,否则为 null。`forgeChangeRequests` 视图列出绑定但不含状态,与[绑定决策](2026-10-09-session-change-request-bindings.zh.md)的设想一致。投影缓存为未加载的 Session 保留这两项。
轮询到的状态经 `forgePanel.changeRequestStates` 流进入列表。宿主用一次运行服务所有流,最后一条流结束时运行停止。它经 `sessionQuery` 列出 Session 而不加载它们,已加载 Session 的可见绑定取自 `ctx.forgeSession`,其他 Session 的取自缓存的摘要;它跟随已加载 Session 的绑定变化,并经共享的 `ctx.forge.watch` 订阅每个已绑定的变更请求;已关闭的按空闲间隔轮询,已合并的保留订阅以接收显式刷新结果。每个变化了的状态以其 URL 的 SHA-256 摘要为键存入 `forge_panel_change_requests` 存储域,因此重启后在下次轮询之前就能显示最近已知的状态;仅在所有已列出 Session 的绑定均已知时删除无人绑定的已存状态。每条流先发送调用方可读状态的 `baseline`,之后发送 `changed` 帧,均经 Remote 访问控制过滤。
浏览器为所有会话行与悬浮卡片部分只保留一个状态来源:第一个订阅方打开流,最后一个关闭它,传输重置后重新打开。
## Alternatives considered
**把变更请求只留给标签页。** forge 面板最初否决了 Session 行悬浮分区,因为对单个 Session 而言它与标签页重复。列表能同时比较所有 Session,这是任何标签页都做不到的;悬浮部分也只补充会话行图标所概括的事实。
**保留 Session 标题旁的分支。** 截断标签会遮住分支名,而且标题栏只显示当前打开的 Session;列表显示每个 Session,标签页则完整写出分支。
**把轮询状态放进投影。** 投影折叠的是 Session 日志,而轮询到的状态不写入日志。放进投影要么为每次轮询记录事件,而这些事件没有任何模型请求需要;要么让投影依赖重放无法重建的进程内状态。
**为每个已列出的 Session 跟随其面板。** 面板会在 Session 的执行环境中用 Git 检查检出并检查登录状态;列表只需要绑定与变更请求状态。
**加载已列出的 Session 来读取绑定。** 列表将重放每个 Session 的日志;缓存的摘要无需加载就能回答。
**只把状态保存在内存中。** 重启后每一行都会显示未知状态,直到每个变更请求再次被轮询。已存状态是派生数据,因此不符合 schema 的记录会被放到一边,而不是让运行失败。
**为在项目目录中工作的 Session 显示分支一行。** 读取该分支需要在 Session 的执行环境中运行 Git,而列表不加载 Session 就无法到达那里。
## Consequences
列表无需加载 Session 就能显示每个 Session 的 worktree 与变更请求状态,重启后立即显示最近已知的状态,标题栏也不再重叠。有会话行显示期间,已列出 Session 绑定的每个打开中的变更请求都会被轮询,因此有大量带打开变更请求的 Session 时,对代码托管平台的请求会增多;已关闭的继续按空闲间隔轮询,已合并的停止自动轮询。没有缓存摘要的 Session 要打开之后才会在列表中显示其 worktree 与变更请求;此变更之前最后使用的 Session 也要到那时才显示其 worktree,因为 `forgeWorktree` 的状态已变化,旧的缓存行会被丢弃。没有会话行显示期间结束的检查不会有通知。
@@ -0,0 +1,19 @@
# Bilingual-pair consistency record for 2026-10-22-listed-model-badges.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-10-22-listed-model-badges.md
/agent-note-model-selector-badges-come-from-the-model-listing:
en: cc4b8fee78e75a98
zh: 3cf21a37bea4410c
/agent-note-model-selector-badges-come-from-the-model-listing/problem:
en: ed011e743511ef25
zh: 2389e339f1a6dcbf
/agent-note-model-selector-badges-come-from-the-model-listing/decision:
en: 84b99f80d8824117
zh: 94cd96f59caf5b43
/agent-note-model-selector-badges-come-from-the-model-listing/alternatives-considered:
en: 079466e0dd56584e
zh: 8e33049c422d77cb
/agent-note-model-selector-badges-come-from-the-model-listing/consequences:
en: 3ba39c2a109e49cd
zh: 3eb16676854230f9
@@ -0,0 +1,47 @@
# Agent Note: Model selector badges come from the model listing
Status: implemented
English | [中文](2026-10-22-listed-model-badges.zh.md)
## Problem
The Web model selector showed `1017` beside `DeepSeek-V4.1-Flash` through a dedicated client package that matched two DeepSeek providers, one model id, and one exact catalog name. Changing or retiring that release marker, or labeling another model, required a client release, while both DeepSeek routes already read model metadata from the service's `GET /models` listing.
## Decision
- Each `/models` entry may declare `badge: { text, tone }` under `client_metadata["deepseek-harness"]`, keyed like `client_requirements` by the User-Agent product token. `client_metadata` holds client-specific data that every build may ignore, so the public model fields stay unchanged and later DeepSeek Harness fields join the same object. `text` uses the package-metadata `LocalizedText` form: one string, or translations keyed by lowercase language id that include `en`. Each text is trimmed and holds 1 to 12 user-perceived characters on one line; controls, Unicode line separators, bidi controls, and text made only of whitespace or default-ignorable characters are rejected.
- `tone` is `neutral`, `info`, `success`, or `warning`: the `Tag` palettes for a plain fact, a highlight, a benefit, and attention. The shipped release marker used `info`; the surveyed selectors draw plan and capability tags neutral, new features with an accent, and usage hints in a warning color. `danger` and `solid` keep their failure and selection meanings.
- [`parseModelListing`](../../../../packages/llm/llm-deepseek/src/listing.ts) decodes the badge beside the entry's other declarations, and [`resolveListedCatalog`](../../../../packages/llm/llm-deepseek/src/listed-catalog.ts) keeps the badges of offered models; both routes attach them through `catalogModelInfo`. An absent or `null` value at any level of that path shows no badge, and an absent or unrecognized tone shows `neutral`. A level that is not an object or an unreadable badge drops the badge, which is reported with the listing's other problems and never hides its model.
- `LlmModelInfo.badge` and `ModelCatalogModel.badge` carry the badge from discovery through the Host model catalog to the browser. `resolveModelInfo` omits it, and no model request, Session event, or model-facing tool reads it.
- [`ui-model-selection`](../../../../packages/client/ui-model-selection/README.md) renders the badge before the catalog name in the trigger and after it in the root-menu value and each option, resolving its text with `ctx.locale.resolveText`. A `conversation.input.model.label` contribution replaces the name and badge together and receives the badge in its owner props.
- The build ships no badge of its own: a route before its first listing shows none. The API-key route does not follow listings for customized catalogs or non-official endpoints; the account route follows the origin of its configured `baseURL`, including custom endpoints. The fixed `ui-deepseek-model-label-1017` package and its Web bundle row are removed.
- After first use, the credential-scoped cache refreshes automatically at `modelsMaxAgeMs` (ten minutes by default) and publishes changed catalogs through `llm/models-updated`. Successful listings replace all badge metadata, so omission or `null` clears an old badge. Failed refreshes retain the last successful listing, including badges: a failed request does not establish that the server withdrew a badge. This avoids independent polling in every browser. A zero interval preserves access-only refresh.
## Alternatives considered
**A top-level `badge` field.** The public `/models` fields describe model capabilities and limits for every client, while the badge is DeepSeek Harness release and campaign copy. A top-level field reads as a model attribute that other clients could start rendering, after which the service could no longer change or retire it freely, and two clients could not show different badges.
**`badge` inside `client_requirements["deepseek-harness"]`.** It adds no top-level field, and released builds ignore an extra key there. But an unreadable requirement hides its model on every build, released ones included, so a serializer that writes the emptied object as `null` after a badge is retired would hide the model. Requirements gate availability; display data must stay ignorable.
**A narrower map such as `client_presentation` or `client_annotations`.** These names limit the object to display data or descriptive notes, so the next client-specific field of another kind, such as a default, would need another top-level field; `client_metadata` takes any field a build may ignore.
**A semantic badge kind mapped to client dictionaries.** OpenCode, ZCode, and DeepSeek-Reasonix translate a closed set of client-owned tag kinds, and none renders text a server sends. A fixed vocabulary cannot express a release code such as `1017`, and each new wording would need the client release the listing exists to avoid. Listing text is provider data, which the [locale-owned copy decision](../architecture/2026-08-23-locale-owned-client-ui-copy.md) renders verbatim; the `LocalizedText` form still lets the service supply translations.
**Accept colors, CSS, or markup from the listing.** No surveyed product accepts styling from a catalog, and arbitrary colors bypass the theme's light and dark palettes and their contrast. A closed tone set maps onto the existing `Tag` palettes, and an unrecognized tone degrades to `neutral`, so a value a newer service adds never removes the text.
**Keep `1017` as a built-in default.** A bundled badge would keep the marker offline and on custom endpoints. Under the listing rule that an absent field keeps the build's value, the service could then remove it only with a special empty value, and old builds would keep the stale marker wherever no listing loads. Shipping no badge makes the listing the only source, so omitting the field removes the badge.
**An expiry or start time on the badge.** The listing cache lives in memory and refreshes on access after `modelsMaxAgeMs`, so a badge the service drops disappears within one refresh. Client-side time windows would add dependence on the client clock without removing reachable staleness.
**Drop the whole entry or listing for a bad badge.** OpenCode drops a model whose catalog status it cannot decode, and Codex fails its catalog decode on one unknown enum value. A badge is presentation only, so a bad one is dropped by itself.
**A DeepSeek-specific badge Remote beside the catalog.** Keeping the badge out of `LlmModelInfo` would need a second Remote, a second client fetch, and agreement between two caches about which models exist. One optional field on the existing catalog reaches every placement in one load.
## Consequences
- The service changes, localizes, or removes a badge without a client release; a running client shows the change after its next listing refresh and catalog reload.
- A condition that older builds must enforce belongs in `client_requirements`; every field under `client_metadata` must be safe for a build that does not read it.
- `deepseek-flash` shows no badge until the service declares one and a listing loads. Customized catalogs and non-official endpoints on the API-key route do not receive listed badges.
- The `/model` popup, the trigger's accessible name, and the Models settings page keep plain names.
- A profile patch that still targets `ui-deepseek-model-label-1017` matches no row and boots with a warning ([upgrade guide](../../../../docs/upgrade-guide/v0.2.1-rc.2/model-badge-package-removal/guide.md)).
@@ -0,0 +1,47 @@
# Agent Note: 模型选择器角标来自模型列表
Status: implemented
[English](2026-10-22-listed-model-badges.md) | 中文
## 问题
Web 模型选择器通过一个专门的客户端包在 `DeepSeek-V4.1-Flash` 旁显示 `1017`,该包写死匹配两个 DeepSeek 提供方、一个模型 id 和一个确切的目录名称。修改或撤下这个版本标记、给其他模型加标签,都要发一次客户端版本;而两条 DeepSeek 路由早已从服务端的 `GET /models` 列表读取模型元数据。
## 决策
- 每个 `/models` 条目可以在 `client_metadata["deepseek-harness"]` 下声明 `badge: { text, tone }`,与 `client_requirements` 一样以 User-Agent 的产品名为键。`client_metadata` 存放所有版本都可以忽略的客户端专属数据,因此公开的模型字段保持不变,以后新增的 DeepSeek Harness 字段也放进同一个对象。`text` 采用包元数据的 `LocalizedText` 形式:单个字符串,或以小写语言 id 为键且包含 `en` 的译文。每段文字去掉首尾空白后须为单行,长度为 1 到 12 个用户可见字符;拒绝控制字符、Unicode 行分隔符、双向控制符及仅由空白或默认可忽略字符组成的文字。
- `tone` 取 `neutral`、`info`、`success` 或 `warning`,分别是 `Tag` 表示普通事实、强调、利好和需要注意的配色。已发布的版本标记使用 `info`;调研的选择器把套餐和能力标签画成中性色,新功能用强调色,用量提示用警示色。`danger` 和 `solid` 保留各自表示失败和当前选中的含义。
- [`parseModelListing`](../../../../packages/llm/llm-deepseek/src/listing.ts) 与条目的其他声明一起解码角标,[`resolveListedCatalog`](../../../../packages/llm/llm-deepseek/src/listed-catalog.ts) 保留已提供模型的角标;两条路由都通过 `catalogModelInfo` 附上角标。这条路径上任何一层缺省或为 `null` 时不显示角标;`tone` 缺省或无法识别时按 `neutral` 显示。某一层不是对象或角标无法读取时,角标被丢弃,并与列表的其他问题一起报告,但不会隐藏所属模型。
- `LlmModelInfo.badge` 与 `ModelCatalogModel.badge` 把角标从模型发现经 Host 模型目录送到浏览器。`resolveModelInfo` 不返回它,模型请求、Session 事件和面向模型的工具都不读取它。
- [`ui-model-selection`](../../../../packages/client/ui-model-selection/README.zh.md) 在触发按钮中把角标放在目录名称之前,在根菜单当前值和各选项中仍放在名称之后,并用 `ctx.locale.resolveText` 解析其文字。`conversation.input.model.label` 的扩展会同时替换名称与角标,并从 owner props 收到角标。
- 本版本不内置任何角标:路由加载到第一份列表之前不显示角标。API key 路由在自定义目录或非官方端点上不跟随列表;账号路由跟随配置的 `baseURL` 的源,包括自定义端点。写死的 `ui-deepseek-model-label-1017` 包及其 Web bundle 条目已移除。
- 按凭据区分的缓存首次使用后按 `modelsMaxAgeMs`(默认十分钟)自动刷新,通过 `llm/models-updated` 发布目录变化。成功加载会替换所有角标元数据,因此省略或 `null` 都会清除旧角标。刷新失败时保留上次成功加载的列表及其角标:请求失败并不能证明服务端撤下了角标。这样也避免了每个浏览器独立轮询。间隔为零时仍只在访问时刷新。
## 考虑过的替代方案
**顶层 `badge` 字段。** 公开的 `/models` 字段为所有客户端描述模型的能力与限制,而角标是 DeepSeek Harness 的发版和运营文案。放在顶层会被当作模型属性,其他客户端可能开始显示它,此后服务端就无法随意修改或撤下;两个客户端也无法显示不同的角标。
**把 `badge` 放进 `client_requirements["deepseek-harness"]`。** 这样不增加顶层字段,已发布版本也会忽略其中多出的键。但版本要求无法读取时,所有版本都会隐藏该模型,已发布的版本也不例外;撤下角标后,序列化器若把清空的对象写成 `null`,模型就会被隐藏。版本要求决定模型能否使用,展示数据必须可以忽略。
**范围更窄的映射,例如 `client_presentation` 或 `client_annotations`。** 这些名称把对象限定为展示数据或附加说明,下一个其他类型的客户端专属字段(例如默认值)又要新开一个顶层字段;`client_metadata` 可以容纳任何版本都能忽略的字段。
**用语义化的角标类别映射到客户端字典。** OpenCode、ZCode 和 DeepSeek-Reasonix 都翻译一组封闭的、由客户端持有的标签类别,没有一家直接渲染服务端发来的文字。固定词表表达不了 `1017` 这样的版本代号,每出现一种新说法都要发一次客户端版本,而列表正是为了免去这一步。列表文字属于提供方数据,按[客户端文案归 locale 管理的决定](../architecture/2026-08-23-locale-owned-client-ui-copy.zh.md)原样渲染;`LocalizedText` 形式仍允许服务端提供译文。
**接受列表下发的颜色、CSS 或标记。** 调研的产品都不接受目录下发的样式;任意颜色会绕过主题的明暗配色及其对比度。封闭的语调集合直接对应现有 `Tag` 配色;无法识别的语调降级为 `neutral`,因此较新的服务端新增的取值不会让文字消失。
**把 `1017` 保留为内置默认值。** 内置角标能让标记在离线和自定义端点下继续显示。但按照“字段缺失时沿用本版本的值”的列表规则,服务端只能用特殊的空值才能去掉它,而且旧版本在加载不到列表时会一直显示过时的标记。不内置角标,列表就是唯一来源,省略字段即可去掉角标。
**给角标加过期时间或生效时间。** 列表缓存只在内存中,超过 `modelsMaxAgeMs` 后在访问时刷新,服务端撤下的角标在一次刷新内就会消失。客户端时间窗口会引入对客户端时钟的依赖,却消除不了实际可能出现的陈旧数据。
**角标有误时丢弃整个条目或整份列表。** OpenCode 会丢掉目录状态无法解码的模型,Codex 遇到一个未知枚举值就让整份目录解码失败。角标只用于展示,有误时只丢弃角标本身。
**在目录之外为 DeepSeek 单独提供角标 Remote。** 让角标不进入 `LlmModelInfo`,就需要第二个 Remote、客户端的第二次请求,以及两份缓存对“有哪些模型”保持一致。在现有目录上增加一个可选字段,一次加载即可送到所有显示位置。
## 影响
- 服务端无需客户端发版即可修改、翻译或撤下角标;运行中的客户端在下一次列表刷新和目录重新加载后显示变化。
- 旧版本必须执行的条件放在 `client_requirements`;`client_metadata` 下的每个字段都必须保证不读取它的版本也能安全运行。
- 服务端声明并加载到列表之前,`deepseek-flash` 不显示角标。API key 路由的自定义目录与非官方端点不接收列表角标。
- `/model` 弹窗、触发按钮的无障碍名称和模型设置页仍只显示名称。
- 仍指向 `ui-deepseek-model-label-1017` 的 profile patch 不再匹配任何条目,启动时会给出警告([升级指南](../../../../docs/upgrade-guide/v0.2.1-rc.2/model-badge-package-removal/guide.zh.md))。
@@ -9,14 +9,14 @@
en: 46916488210ba493
zh: 98107c3805a22b2c
/agent-note-dual-release-install-layout-work-budget/decision:
en: c432b68c4ad1b3c1
zh: a11e958fa68dc2f3
en: b900469750b27f81
zh: d5380d7751332e9c
/agent-note-dual-release-install-layout-work-budget/what-the-resolution-costs:
en: 36d3e3ef39cbba2f
zh: 2480acf6260817b3
en: 463df1fae15ae2fc
zh: 733c9ddb4900e3fc
/agent-note-dual-release-install-layout-work-budget/alternatives-considered:
en: 29987b7634c84d28
zh: a1469e4b6aaa46e2
/agent-note-dual-release-install-layout-work-budget/consequences:
en: 8dffc0c3fa699980
zh: 99ec25001e5aa716
en: 4226e2216abe042d
zh: db8f308ef40f47e9
@@ -10,14 +10,16 @@ The `Dependency layout` lane packs two incompatible synthetic DSH releases into
## Decision
`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 875_000`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant.
`scripts/verify-npm-install-layout.ts` decides growth with `assertResolutionWorkBudget`, which bounds `dshPackagesPerVersion * checkedDshEdges` against `MAX_RESOLUTION_WORK_UNITS = 1_134_400`. Both counts come from the summary `assertDualDshInstallLayout` returns, so the budget applies to the graph npm actually produced rather than to a prediction of it. A graph above the budget fails with its own counts, the budget, and the instruction to measure the new resolution cost and raise the constant.
The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (910 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart.
The npm child keeps a wall-clock limit, `NPM_HANG_GUARD_MS`, derived as `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` (about 1,180 s). It is a hang guard, not the growth criterion: a runner up to four times slower than the measured host cannot decide the gate's outcome, and deriving it from the budget keeps the two values from drifting apart.
This restores the [published dependency faces](2026-08-26-published-dependency-faces.md) decision that `verify-npm-install-layout` does not enforce resolver duration; the 300 s deadline had contradicted it.
## What the resolution costs
Measured 2026-10-26 on an idle Apple M5 Pro with Node 24.17.0 and npm 11.13.0, using `buildDualDshRegistry`, `resolveNpmPackageLock`, and `assertDualDshInstallLayout` sequentially with fresh temporary consumers: the #5960 graph (315 packages per release, 2782 internal edges, 876,330 work units) resolved in 75.27 s; the #5961 graph (320 packages, 2836 edges, 907,520 units) resolved in 71.03 s, with a second verification at 97.73 s. All placements passed, shared one Cordis installation, and requested no archives. The measurements calibrate graph admission and do not establish a runtime improvement. The current budget is 1.25 times its 907,520 units; the earlier, slower per-unit timing remains the conservative hang-guard calibration.
Measured 2026-09-28 on an idle M-series host, against `origin/master` (277 DSH packages per release, 2524 internal edges), by running the lane's own npm invocation with `--timing` and `--cpu-prof`:
| Phase | Cost |
@@ -47,4 +49,4 @@ Repeated runs of the same graph on one host span 137.8 s to 204.8 s of npm time,
## Consequences
The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to 910 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires.
The lane's failure now depends on the graph, so a pull request that adds a few DSH packages passes on any runner, and a graph that grows the packages-times-edges product by more than about a quarter — about 12% in packages and internal edges together — fails deterministically with its measured counts. The budget is a ratchet: raising it is a deliberate edit that must come with a new resolution measurement, and the constant's comment records the counts it was derived from. A graph inside the budget still spends its full resolution time, because the change moves the decision and not the work; nothing here reduces the 153 s npm needs to place the dual release. A genuine npm hang now consumes up to about 1,180 s of the lane before failing, where the old deadline capped it at 300 s. Growth that changes the shape of the graph without changing packages times edges stays inside the budget and is caught only if the hang guard expires.
@@ -10,14 +10,16 @@ Status: implemented
## Decision
`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 875_000` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。
`scripts/verify-npm-install-layout.ts` 用 `assertResolutionWorkBudget` 判定规模增长,它以 `dshPackagesPerVersion * checkedDshEdges` 为工作量单位,与 `MAX_RESOLUTION_WORK_UNITS = 1_134_400` 比较。两个计数都来自 `assertDualDshInstallLayout` 返回的汇总,因此预算作用于 npm 实际产出的图,而不是对图的预测。超过预算的图会带着自己的计数、预算值,以及「测量新的解析开销并调高该常量」的指示失败。
npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(910 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。
npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION_WORK_UNITS * SECONDS_PER_WORK_UNIT` 推导(约 1,180 秒)。它是挂起保护,不是增长判据:比实测主机慢四倍以内的 runner 无法决定该门禁的结果,而由预算推导可避免两个数值彼此漂移。
这恢复了[发布依赖门面](2026-08-26-published-dependency-faces.zh.md)中「`verify-npm-install-layout` 不限制 resolver 耗时」的决定;300 秒期限与该决定相矛盾。
## What the resolution costs
2026-10-26 在空闲 Apple M5 Pro 上使用 Node 24.17.0 与 npm 11.13.0,通过 `buildDualDshRegistry`、`resolveNpmPackageLock` 和 `assertDualDshInstallLayout`,对独立的新建临时消费者目录依次测量:#5960 的依赖图(每个发布 315 个包、2782 条内部边、876,330 个工作量单位)用 75.27 秒解析;#5961 的依赖图(320 个包、2836 条边、907,520 个工作量单位)用 71.03 秒解析,第二次验证耗时 97.73 秒。所有布局校验均通过,共享一个 Cordis 安装,未请求包归档。这些测量用于校准依赖图的准入规模,不代表运行时性能提升。当前预算为其 907,520 个工作量单位的 1.25 倍;挂起保护仍采用此前较慢的单位工作量耗时作为保守校准。
2026-09-28 在空闲的 M 系列主机上,针对 `origin/master`(每个发布 277 个 DSH 包、2524 条内部边),用 `--timing` 和 `--cpu-prof` 运行 lane 自身的 npm 调用测得:
| 阶段 | 开销 |
@@ -47,4 +49,4 @@ npm 子进程仍保留墙钟上限 `NPM_HANG_GUARD_MS`,由 `4 * MAX_RESOLUTION
## Consequences
lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多 910 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。
lane 的失败判定如今取决于依赖图,因此新增少量 DSH 包的 pull request 在任何 runner 上都能通过,而「包数乘边数」增长超过约四分之一的图——即包数与内部边数同步增长约 12%——会带着实测计数确定性地失败。预算是棘轮:调高它是一次刻意修改,必须附带新的解析实测,该常量注释里记录了推导所用的计数。预算内的图仍会花掉完整的解析时间,因为这次改动移动的是判定位置而不是工作本身;这里没有任何东西能减少 npm 摆放双发布所需的 153 s。真正挂起的 npm 现在会消耗该 lane 最多约 1,180 秒才失败,而旧期限把它限制在 300 秒。若图的增长改变了形状却没有改变「包数乘边数」,它仍留在预算内,只有在挂起保护到期时才被发现。
@@ -21,11 +21,11 @@
en: f07bb6fbbdd01fe4
zh: 9313a0ef2a22af12
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/decision/performance-gate:
en: fcc52be9892b2bfc
zh: ac259aba407efcbc
en: 37faaffb4662fd54
zh: df0a4bf016393be9
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/alternatives-considered:
en: 168bda1a4e9e0325
zh: af9cf7b804db84b7
en: 559a8037caa9e3aa
zh: 4f3719ed09445cc2
/agent-note-linear-credential-matching-and-performance-gates-for-user-text/consequences:
en: 8f9d25b72d3e7660
zh: e5c6ae0194905876
@@ -43,7 +43,7 @@ English | [中文](2026-10-11-user-text-credential-matching.zh.md)
| Item | Content |
|---|---|
| Workloads | Without credentials: ordinary messages, and long prose, code, base64, Markdown, log, JSON, and shell text. With credentials: messages each carrying one kind (model key, GitHub token, cloud key ID, JWT, address password, private-key block), and batches of `.env` keys. Adversarial: repeated input shaped against each format's prefix, length, and separator rules, at two sizes. All generated from constants; credential samples are assembled at run time |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Timing | Ten unmeasured pairs warm each workload with the same evaluation count as measured pairs. Scaling inputs share one group: every warm-up and measured round evaluates both sizes adjacently and alternates which size goes first, limiting drift between sizes. Five measured pairs alternate baseline-first and matcher-first order; each evaluation builds a fresh registry, document, and matcher instance and times evaluation only. A single-document sample averages twenty evaluations, or forty for the half-sized input of a scaling pair, so both sizes process the same total keys or characters per sample; a 5,000-message sample evaluates the batch once. Garbage collection during evaluation remains included. Each side selects its minimum sample mean, with the mean process CPU time from that sample. The worker JSON in CI logs includes every measured pair and averaging count; the HTML report shows the selected with-credential mean. Registry disposal completes outside the timer before the next evaluation, preventing cleanup and retained registries from accumulating. Warm-up retains no matcher memo |
| Allocation | A separate process with `--expose-gc` and a fixed semi-space larger than any evaluation; after warm-up and GC, the matcher alone answers every query once and the young-generation growth is taken; GC event timestamps confirm no GC ran inside the interval |
| Assertions | Without credentials: absolute budgets and a cap on the with/without time ratio; with credentials: absolute budgets and the time ratio when the key count doubles; adversarial: absolute budgets and doubling ratios; allocation: a floor plus caps per credential, per line break inside masks, and per masked character |
| Excluded | Cold code compilation, React rendering, browser paint and layout; allocation results are asserted and printed to the console, not added to the HTML report |
@@ -54,6 +54,7 @@ The reference constants are calibrated for minima of five single evaluations. Mi
## Alternatives considered
- **Finishing one scaling size before measuring the other**: separate phases compare different load and V8 tiering conditions. Identical code measured JWT doubling at 1.91× in one CI run and 2.70× in another; a local Node 24 run also exceeded the unchanged 2.5× limit on a different input. Adjacent, alternating size rounds retain the existing sample counts, minimum-of-five aggregation, GC inclusion, workloads, and limits. A controlled extra evaluation cost on only the 128 KiB input must still fail the doubling assertion.
- **Equal evaluation counts at both scaling sizes**: the larger input allocates twice as much per sample, so its fastest sample can include a collection while the smaller input's fastest sample includes none. Equal total input per sample makes the per-evaluation means compare similar allocation volumes without excluding GC or changing the doubling limit.
- **Unwarmed, separate baseline and matcher batches**: sub-millisecond baseline measurements can reach optimized code before the matcher’s newly exercised paths do, and separate batches amplify clock-speed drift. Per-workload warm-up, adjacent pairs, and fixed batches compare warmed code while limiting noise in short intervals and preserving fresh documents and matcher state.
- **One quantified pattern per format, run from every query**: patterns without a hit scan to the end of the text each time, so cost grows quadratically with credentials, and length quantifiers backtrack on failure.
@@ -43,7 +43,7 @@ Status: implemented
| 项 | 内容 |
|---|---|
| 负载 | 无凭据:普通消息,以及散文、代码、base64、Markdown、日志、JSON、shell 长文本。含凭据:每条消息含一种凭据(模型密钥、GitHub 令牌、云密钥 ID、JWT、地址密码、私钥块),以及成批的 `.env` 密钥。对抗:针对每种格式的前缀、长度与分隔规则构造的重复输入,两种长度。全部由常量生成,凭据样本运行时拼接 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 计时 | 每个负载先用十组未计入结果的配对预热,每组的求值次数与测量配对相同。翻倍比较的两个输入属于同一组:每轮预热和测量相邻求值两个尺寸,并交替先测的尺寸,减少尺寸之间的漂移。五组测量交替以基线或凭据匹配器开头;每次求值新建注册表、文档和匹配器实例,只计求值。单文档样本取二十次求值的平均值;翻倍比较中的小输入取四十次,使两种大小的每个样本处理相同总量的密钥或字符;5,000 条消息的样本则整批求值一次。求值期间的垃圾回收仍计入耗时。两侧各取样本均值的最小值,并保留对应样本的平均进程 CPU 时间。CI 日志中的 worker JSON 包含全部测量配对和求平均值的次数,HTML 报告展示选中的带凭据匹配器均值。每次求值在计时结束后等待注册表清理完成,再开始下一次,避免清理工作和未释放注册表积累。预热不保留匹配器记忆 |
| 分配 | 单独进程,`--expose-gc`,semi-space 固定为大于任何一次求值的大小;预热后 GC,再单独驱动匹配器回答全部查询一遍,取新生代增量;用 GC 事件的时间戳确认区间内没有 GC |
| 断言 | 无凭据:绝对预算,以及"有/无凭据匹配器"的耗时比上限;含凭据:绝对预算,密钥数翻倍时的耗时比;对抗:绝对预算与翻倍耗时比;分配:底噪加每个凭据、每个遮挡内换行和每个遮挡字符的上限 |
| 排除 | 冷代码编译、React 渲染、浏览器绘制与布局;分配结果只用于断言并输出到控制台,不进 HTML 报告 |
@@ -54,6 +54,7 @@ Status: implemented
## 考虑过的替代方案
- **测完一个翻倍尺寸后再测另一个**:两个阶段比较的负载和 V8 编译层级状态不同。同一份代码的 JWT 翻倍比在两轮 CI 中分别为 1.91 倍和 2.70 倍;本地 Node 24 也在另一个输入上超过既有 2.5 倍上限。相邻、交替的尺寸轮次保留既有样本数、五组取最小值的汇总方式、GC 计入规则、负载与上限。仅给 128 KiB 输入增加受控求值开销时,翻倍断言仍须失败。
- **两种大小使用相同求值次数**:大输入每个样本的分配量翻倍,因而其最快样本可能包含一次垃圾回收,而小输入的最快样本没有。让每个样本的输入总量相等,使单次求值均值比较相近的分配量,无需排除垃圾回收或修改翻倍上限。
- **不预热、分批测量基线和匹配器**:亚毫秒基线测量可能先进入优化后的代码,而匹配器新触及的路径还未完成优化;分批测量也会放大时钟频率漂移。逐负载预热、相邻配对和固定批次比较预热后的代码,降低短区间测量的噪声,同时保持文档和匹配器状态全新。
- **每次查询对每个格式各跑一个带量词的正则**:没有命中的正则每次都扫到文末,凭据多时平方增长;长度量词在失败时回溯。
@@ -12,17 +12,17 @@
en: 34e5bde1535a2bb1
zh: 559deb25b2ec50ba
/agent-note-domain-kv-storage-capability-seam-and-the-workspace-entity/proposal/dsh-storage-the-storage-hub:
en: 191afc01a150e4a0
zh: da3fbc33875a4f60
en: 5eb0b18c2adf5111
zh: 856ee7b1464b8bfc
/agent-note-domain-kv-storage-capability-seam-and-the-workspace-entity/proposal/dsh-storage-json:
en: a18ec1e534a5488b
zh: 2f5beb445e30554b
/agent-note-domain-kv-storage-capability-seam-and-the-workspace-entity/proposal/dsh-storage-sqlite:
en: 6a26dd7ef357db74
zh: 951b31136e2415f9
en: 1a4bebbbf52db7a2
zh: 425c32b0dc17b49e
/agent-note-domain-kv-storage-capability-seam-and-the-workspace-entity/proposal/dsh-domain-the-domain-data-form:
en: 2ee24f36cd93f7dd
zh: 45c83bd745de41f9
en: b99e07359dc78dc8
zh: 4af881d0b6d5b628
/agent-note-domain-kv-storage-capability-seam-and-the-workspace-entity/proposal/future-work-session-side-deletion-design-settled-not-implemented-this-phase:
en: a3824326587b6539
zh: 9736e3060bd57651
@@ -44,9 +44,9 @@ A backend is one **medium owner** (a file-tree root / one db file) exposing prim
The backend contract (asserted clause by clause by the shared conformance suite, one suite for both backends):
1. `open` creates when the medium holds nothing (lazy materialization allowed: may defer to the first write, but `loadAll` must immediately serve empty tables); loads when the medium exists.
2. A stored version ≠ descriptor.version → `StorageError('version-mismatch')`; no migration, no rebuild.
2. Unit-wide checks accept `descriptor.version` and the owner's `compatibleVersions`; other stamps reject with `StorageError('version-mismatch')`. Ordinary compatible reads preserve saved stamps and values. [Domain versioning](../../../../docs/subsystems/storage.md#domain-versioning) defines write stamping and JSON per-record exceptions.
3. Durability: after a write primitive resolves, a process crash followed by a re-open must observe the write in `loadAll`.
4. The backend does not promise write ordering within a unit — **the caller serializes**; the backend only guarantees each single call is atomic (JSON whole-file replace / SQLite single statement).
4. The backend does not promise write ordering within a unit — **the caller serializes**; whole-unit mutations atomically publish their data and current stamp (JSON whole-file replacement / SQLite transaction for batch deletion or advancing the stamp); per-record batches can be partially applied if interrupted before resolution.
5. `deleteRecord` is idempotent; `putRecord` overwrites.
6. Any string key / any JSON value is safe (keys never reach file paths, a structural property).
7. `close` is idempotent; any operation after close → `StorageError('closed')`.
@@ -88,8 +88,8 @@ CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" (
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = record JSON document
```
- Unit versions live in `units` rows; a descriptor mismatch → `version-mismatch`. Row granularity is document-per-row, preserving precise per-key durable updates (the path left open for high-frequency point-update tables like the session sidecar); when query needs appear, JSON1 reads the value column directly.
- Write primitives are single statements and thus atomic; no cross-statement transactions needed (the domain layer has no cross-table transactions, see the out-of-scope list).
- Unit versions live in `units` rows; an unaccepted version rejects with `version-mismatch`. Row granularity is document-per-row, preserving precise per-key durable updates (the path left open for high-frequency point-update tables like the session sidecar); when query needs appear, JSON1 reads the value column directly.
- Current-version single-record writes use single statements. Batch deletes and the first mutation of an accepted older unit use one transaction; the first mutation commits its data and current stamp together. This does not add cross-table domain transactions.
### `dsh-domain`: the domain data form
@@ -117,6 +117,7 @@ export interface DomainTableSpec<K extends string, V> { readonly valueSchema: Zo
export interface DomainSpec {
readonly name: string // ^[a-z][a-z0-9_]*$
readonly version: number
readonly compatibleVersions?: readonly number[]
readonly global?: DomainGlobalSpec<unknown>
readonly tables: Record<string, DomainTableSpec<string, unknown>>
}
@@ -157,8 +158,8 @@ Rules:
- **Single-level mapping**: key → record, no nested tables; hierarchical needs use composite keys or fields inside the value. The two backends stay isomorphic as a result (one JSON object level ↔ one SQLite row).
- **Records are plain data**: immutable, directly JSON-serializable POJOs; values returned by `get`/`entries` must not be mutated in place (TypeScript readonly projection, no runtime freezing). Behavior-carrying domain objects belong to consumer packages.
- **Serialized writes**: one promise chain per domain; `put`/`delete`/`update`/`global.set` all queue on it; `update`'s fn runs on the chain, so concurrency cannot interleave. No active-record (pulling out a mutable object that auto-persists — uncontrollable persist timing, in conflict with the whole-unit atomic-rewrite model).
- **Version fails loud**: a stored version differing from the spec throws outright; no migration, no rebuild (the data is not regenerable; pre-release rejects old formats).
- **Serialized writes**: one promise chain per domain; `put`/`delete`/`deleteMany`/`update`/`global.set` all queue on it; `update`'s fn runs on the chain, so concurrency cannot interleave. No active-record (pulling out a mutable object that auto-persists — uncontrollable persist timing, in conflict with the whole-unit atomic-rewrite model).
- **Version compatibility**: the domain owner declares readable older versions through `compatibleVersions` and decodes historical values with its current schemas. Storage does not convert record values; [domain versioning](../../../../docs/subsystems/storage.md#domain-versioning) defines stamp publication and mixed-generation requirements.
- **Change events**: after each write's durability resolves, emit `domain/changed` (`@mode emit`), one per record, no old value (matching the repository's "new snapshot + operation discriminant" convention, template `goal/changed`); the payload `DomainChanged` is a put/deleted discriminated union — domain + table + key (both `''` for global changes) + operation, with the put branch carrying the new snapshot value and the deleted branch carrying none (`packages/storage/storage-domain/src/events.ts`). This is next phase's RPC push-frame event source. The error vocabulary is `DomainError`, codes: `already-open` / `facet-unsupported` / `invalid-record` (with `{ table, key }`) / `missing-key` / `closed`.
### Future work: session-side deletion (design settled, not implemented this phase)
@@ -44,9 +44,9 @@ host 侧唯一的持久化面是 session 事件日志(`packages/session/sessio
后端约定(共享约定测试逐条断言,两后端同套件):
1. `open` 对不存在的介质创建(懒物化允许:可延迟到首写,但 `loadAll` 立即可用返回空表);对已存在介质载入。
2. 介质上版本 ≠ descriptor.version → `StorageError('version-mismatch')`,不迁移不重建。
2. 单元级检查接受 `descriptor.version` 和拥有方声明的 `compatibleVersions`;其他版本戳以 `StorageError('version-mismatch')` 拒绝。普通兼容读取保留已存版本戳和值。[领域版本](../../../../docs/subsystems/storage.zh.md#domain-versioning)定义写入盖戳和 JSON 逐记录例外。
3. 持久性:写原语 resolve 后进程崩溃再 open,`loadAll` 必须反映该写入。
4. 后端不承诺 unit 内写并发序——**调用方负责串行**;后端只保证单次调用原子(JSON 整文件替换 / SQLite 单语句)。
4. 后端不承诺 unit 内写并发序——**调用方负责串行**;整单元修改原子发布其数据和当前版本戳(JSON 整文件替换 / SQLite 在批量删除或推进版本戳时使用事务);逐记录批次在完成前中断时可能只应用一部分。
5. `deleteRecord` 幂等;`putRecord` 覆写。
6. 任意字符串 key / 任意 JSON 值安全(key 不进文件路径,结构性质)。
7. `close` 幂等;close 后任何操作 → `StorageError('closed')`。
@@ -88,8 +88,8 @@ CREATE TABLE IF NOT EXISTS "u_<unit>_<table>" (
key TEXT PRIMARY KEY, value TEXT NOT NULL) STRICT; -- value = record JSON document
```
- unit 版本存 `units` 行,descriptor 不符 → `version-mismatch`。行粒度 document-per-row,保住按 key 精确落盘更新(为 session sidecar 这类高频点更新大表留路);查询需求出现时 JSON1 直查 value 列。
- 写原语单语句即原子,无跨语句事务需求(domain 层无跨表事务,见不做清单)。
- unit 版本存 `units` 行,未接受的版本以 `version-mismatch` 拒绝。行粒度 document-per-row,保住按 key 精确落盘更新(为 session sidecar 这类高频点更新大表留路);查询需求出现时 JSON1 直查 value 列。
- 当前版本的单记录写入使用单条语句。批量删除和对已接受旧单元的首次修改使用一个事务;首次修改一同提交数据和当前版本戳。这不增加跨表领域事务。
### `dsh-domain`:领域数据形式
@@ -117,6 +117,7 @@ export interface DomainTableSpec<K extends string, V> { readonly valueSchema: Zo
export interface DomainSpec {
readonly name: string // ^[a-z][a-z0-9_]*$
readonly version: number
readonly compatibleVersions?: readonly number[]
readonly global?: DomainGlobalSpec<unknown>
readonly tables: Record<string, DomainTableSpec<string, unknown>>
}
@@ -157,8 +158,8 @@ export interface KvTable<K extends string, V> {
- **一级 mapping**:key → 记录,不做嵌套表;层级需求用复合 key 或值内字段。两后端因此同构(JSON object 一层 ↔ SQLite 一行)。
- **记录是纯数据**:可直接 JSON 序列化的不可变 POJO;`get`/`entries` 返回值不得原地改(TypeScript readonly 投影,不做运行时冻结)。带行为的领域对象属于消费方包。
- **写串行**:域内一条 promise 链,`put`/`delete`/`update`/`global.set` 全排队;`update` 的 fn 在链上执行,并发不交错。不做 active-record(取出可变对象自动落盘——落盘时机不可控,与整域原子覆写冲突)。
- **版本 fail loud**:盘上版本与 spec 不符直接报错,不迁移不重建(数据不可再生,pre-release 拒绝旧格式)。
- **写串行**:域内一条 promise 链,`put`/`delete`/`deleteMany`/`update`/`global.set` 全排队;`update` 的 fn 在链上执行,并发不交错。不做 active-record(取出可变对象自动落盘——落盘时机不可控,与整域原子覆写冲突)。
- **版本兼容**:领域拥有方通过 `compatibleVersions` 声明可读的旧版本,并用当前 schema 解码历史值。Storage 不转换记录值;[领域版本](../../../../docs/subsystems/storage.zh.md#domain-versioning)定义版本戳发布和混合代际要求。
- **变更事件**:每次写落盘 resolve 后 emit `domain/changed`(`@mode emit`),逐条发、不带旧值(对齐仓库"新快照 + 操作判别"惯例,范本 `goal/changed`);payload `DomainChanged` 是 put/deleted 判别联合——域名 + 表名 + key(global 变更两者为 `''`)+ operation,put 支带新快照 value、deleted 支无 value(`packages/storage/storage-domain/src/events.ts`)。此为下期 RPC 推帧的事件源。错误词汇 `DomainError`,码表:`already-open` / `facet-unsupported` / `invalid-record`(带 `{ table, key }`)/ `missing-key` / `closed`。
### Future work:session 侧删除(设计定案,本期不实施)
+4
View File
@@ -67,6 +67,10 @@ Use [dsh-responsive-ui](../dsh-responsive-ui/SKILL.md) for responsive layouts, m
- Review every changed region for gaps: nothing sits flush against its neighbour without an intentional gap, and adjacent icons and elements keep the region's established spacing.
- Prefer symmetry for parallel elements; an unexplained one-off gap or offset usually signals a missed shared value.
- **For complex pages and containers, establish shared horizontal and vertical alignment axes before adjusting local spacing.** Consider hover, selected, and expanded/collapsed states, as well as the overall relationship with adjacent panels and other page elements. Choose from the following references according to layout similarity:
- **Sidebar:** align corresponding text starts and leading icons across vertically stacked rows. Align hover and selected row backgrounds with the buttons above the list to maintain visual balance.
- **Plugins page:** keep content left-aligned along a shared edge. List-row hover backgrounds may extend beyond that edge while the content stays in place.
- **Conversation and right-sidebar headers:** align the first header rows across both panels and match their heights. Apply the same rule to the second rows, and align corresponding underlines and dividers on shared horizontal baselines.
## Design review escalation
@@ -13,6 +13,8 @@ Keep recoverable session facts in Projection and expose them through their ownin
2. Do not expose projection definitions or handles as production read APIs. Keep the ready handle private; consumers use the owning service's domain methods. A handle is a process-local reader, recreated during activation. Require `sessionProjections` when the service depends on it; optional registration is only for optional contributions. Do not hide a missing required dependency with another recovery path.
A runtime that needs several projected states receives one typed object of callbacks created with `handle.readerFor(session)`. Add properties instead of a constructor parameter or feature-specific reader for each state. Each callback has type `() => S`; single-purpose collaborators receive it directly. These host-state readers are separate from wire snapshots and live Cordis Context; they own no registration, disposal, cache or I/O. Registration, Session preparation and handle lifetimes stay with their owners. Rule 9 defines synchronous API compatibility.
3. State recovery through `init` and `apply` is synchronous and deterministic. Do not perform I/O or trigger Cordis hooks, models, or tools. Runtime hooks own those effects. Replay must not repeat them. Return the same state reference for unrelated events. Keep views synchronous and preserve their references when visible values do not change.
4. Persist JSON state that its schema accepts. Follow the README's state-version and checkpoint-watermark rules. Restoring a checkpoint and replaying its suffix must match full replay. Change the version when state fields or recovery semantics change.
+2
View File
@@ -4,4 +4,6 @@ Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. Native Win
The manual [Windows packaging workflow](workflows/windows-package.yml) uses the dedicated self-hosted Windows x64 label `dsh-win-package-trial` under native `pwsh`; preserve this routing separately from the automatic CI lanes.
The merge checks run from three workflows named `Policy`, which run on `pull_request_target` so their definitions come from master, and call the `deepseek-harness-check` action directly: `pr-check-on-head-branch.yml` on pull-request updates gives `Policy / Master Change` and `Policy / Pre-Merge Check`, `pr-check-after-approve.yml` on pull-request updates and submitted or dismissed reviews gives `Policy / Approve Check` and `Policy / Reviewer Evaluate`, and `pr-check-on-base-branch.yml` on master pushes re-runs the head-branch checks as `Policy / Recheck`. Branch rules require those check names, so keep the job names and the workflow name. Each check reads its mode from a repository variable: `MERGE_POLICY_MASTER_DIFF`, `MERGE_POLICY_PRE_MERGE`, `MERGE_POLICY_HEAD_DIFF`, and `MERGE_POLICY_REVIEW`, each `off` (skip), `report` (run without failing the job), or `block` (fail the job); a missing value or an unknown one counts as `report`. `Policy / Pre-Merge Check` merges the commit `Policy / Master Change` reports, so it runs only after that job and stays off while `MERGE_POLICY_MASTER_DIFF` is `off`; a merge it cannot do and a build that fails both follow its own mode, so `report` warns and skips the build while `block` fails the job. `Policy / Pre-Merge Check` runs `scripts/pre-merge-check.ts` under plain Node and knows nothing else about the merged tree: that script checks the released manifests against the root version, installs, and builds the Host and Client contracts, so change it in one pull request together with the build it orchestrates.
The manual Desktop publication workflow selects a successful package run and attempt, then routes each retained build through `DESKTOP_PUBLISH_RUNNERS` to its original machine's unique label. Keep Windows run steps on native PowerShell and Mac run steps on the default POSIX shell; shared entrypoints are Node scripts. Test and production jobs use separate `desktop-test` and `desktop-production` approval Environments after a credential-free preparation job.
+3 -2
View File
@@ -14,6 +14,7 @@ on:
- larger-runner-benchmark
- consolidated-runner-benchmark
- ssh-helper
- wine
# New master pushes and manual runs replace obsolete checks in this workflow/ref.
# Standby drills share cancellation; use completed runs as readiness evidence.
@@ -49,9 +50,9 @@ jobs:
secrets:
DEEPSEEK_API_KEY_EXTERNAL: ${{ secrets.DEEPSEEK_API_KEY_EXTERNAL }}
# Wine runs once here, independently of the native serial standby aggregates.
# Wine runs on master pushes or an explicit suite=wine dispatch, independently of native standbys.
windows:
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
if: (github.event_name == 'push' && github.ref == 'refs/heads/master') || (github.event_name == 'workflow_dispatch' && inputs.suite == 'wine')
runs-on: ubuntu-latest
name: windows node 24 / wine
timeout-minutes: 15
+161
View File
@@ -0,0 +1,161 @@
name: Package Desktop · macOS · Intel
on:
workflow_dispatch:
inputs:
source_ref:
description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本'
type: string
default: master
required: true
preset:
description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境'
type: choice
options: [test, production]
default: test
required: true
update_channel:
description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道'
type: choice
options: [nightly, latest]
default: nightly
required: true
version_mode:
description: '版本模式:default 为 test 自动编号、production 跟随产品;product 保留原版本,auto 自动编号,manual 手填'
type: choice
options: [default, product, auto, manual]
default: default
required: true
build_version:
description: '手动版本号:仅 manual 时填写完整版本;须为产品原版本或受支持的预发布后缀'
type: string
default: ''
required: false
test_release_id:
description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值'
type: string
default: ''
required: false
compression_level:
description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强'
type: choice
options: ['3', '7']
default: '3'
required: true
skip_notarization:
description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选'
type: boolean
default: false
required: true
permissions:
contents: read
concurrency:
group: macos-desktop-package
cancel-in-progress: false
jobs:
package:
runs-on: [self-hosted, macOS, ARM64, dsh-mac-package-trial]
timeout-minutes: 180
defaults:
run:
shell: bash
env:
DSH_DESKTOP_TARGET_PLATFORM: darwin
DSH_DESKTOP_TARGET_ARCH: x64
PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.compression_level }}
PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }}
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
PACKAGE_VERSION_MODE: ${{ inputs.version_mode }}
DSH_DESKTOP_UPDATE_CHANNEL: ${{ inputs.update_channel }}
DSH_TELEMETRY_DISABLED: '1'
DSH_PACKAGING_PRESET: ${{ inputs.preset }}
PACKAGE_TEST_RELEASE_ID: ${{ inputs.test_release_id }}
steps:
- uses: actions/checkout@v7.0.1
id: checkout
with:
persist-credentials: false
clean: true
ref: ${{ inputs.source_ref }}
- name: Validate selected source and adapter support
run: >-
node -e "const fs=require('node:fs'); const ref=process.env.PACKAGE_SOURCE_REF;
const cp=require('node:child_process'); const preset=process.env.DSH_PACKAGING_PRESET;
if(!['test','production'].includes(preset)) throw Error('Unknown packaging preset');
const tag=/^dsh-v[0-9][0-9A-Za-z.-]*$/.test(ref);
if(preset==='production' && ref!=='master' && !tag) throw Error('Production source must be master or dsh-v*');
if(tag) {
const v=JSON.parse(fs.readFileSync('package.json','utf8')).version;
if(ref!=='dsh-v'+v) throw Error('Source tag must match product version');
const tagged=cp.execFileSync('git',['rev-parse','--verify','refs/tags/'+ref+'^{commit}'],{encoding:'utf8'}).trim();
const head=cp.execFileSync('git',['rev-parse','HEAD'],{encoding:'utf8'}).trim();
if(tagged!==head) throw Error('Checkout does not match source tag');
} else {
const branch=cp.execFileSync('git',['rev-parse','--abbrev-ref','HEAD'],{encoding:'utf8'}).trim();
if(branch==='HEAD' || branch!==ref || ref.startsWith('refs/')) throw Error('Select a branch in this repository, not a SHA or PR ref');
}
if(!fs.readFileSync('apps/desktop/scripts/prepare-macos-ci.mjs','utf8').includes('resolveDesktopBuildTarget')) throw Error('Source lacks Intel macOS CI support; select a newer source');
if(!fs.readFileSync('apps/desktop/scripts/desktop-ci-version.mjs','utf8').includes('PACKAGE_VERSION_MODE')) throw Error('Source lacks Desktop channel/version CI support; select a newer release');"
- uses: pnpm/action-setup@v5.0.0
with:
dest: ${{ runner.temp }}/setup-pnpm-${{ github.run_id }}-${{ github.run_attempt }}
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
- name: Configure persistent package caches
env:
DSH_DESKTOP_PACKAGE_CACHE_DIR: ${{ runner.tool_cache }}/dsh-desktop-package-cache
run: |
echo "DSH_DESKTOP_PACKAGE_CACHE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR" >> "$GITHUB_ENV"
echo "PNPM_CONFIG_STORE_DIR=$DSH_DESKTOP_PACKAGE_CACHE_DIR/workspace-pnpm-store" >> "$GITHUB_ENV"
- name: Check Rosetta execution
run: arch -x86_64 /usr/bin/true
- name: Install locked dependencies
run: pnpm install --frozen-lockfile
- name: Validate preset and resolve build version
id: settings
env:
PACKAGE_BUILD_VERSION: ${{ inputs.build_version }}
run: node apps/desktop/scripts/prepare-macos-ci.mjs
- name: Check packaging configuration and toolchain
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: |
args=()
if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi
pnpm run package:desktop:mac:x64 --check --build-version "$PACKAGE_VERSION" "${args[@]}"
- name: Build signed installers
env:
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: |
args=()
if [[ "$PACKAGE_SKIP_NOTARIZATION" = true ]]; then args+=(--skip-notarization); fi
pnpm run package:desktop:mac:x64 --build-version "$PACKAGE_VERSION" "${args[@]}"
- name: Retain complete package outside the checkout
id: retained
env:
PACKAGE_SOURCE_SHA: ${{ steps.checkout.outputs.commit }}
PACKAGE_VERSION: ${{ steps.settings.outputs.version }}
run: node --import tsx apps/desktop/scripts/retain-desktop-ci.ts
- name: Retain packaging diagnostics
if: always() && steps.checkout.outcome == 'success'
uses: actions/upload-artifact@v7.0.1
with:
name: macos-x64-package-logs-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
path: apps/desktop/.desktop-build/packaging-runs/
if-no-files-found: warn
retention-days: 14
- name: Retain installers for the selected mode
if: success()
uses: actions/upload-artifact@v7.0.1
with:
name: macos-x64-${{ steps.settings.outputs.mode }}-${{ steps.settings.outputs.version }}-${{ github.run_id }}-${{ github.run_attempt }}
include-hidden-files: true
path: ${{ steps.retained.outputs.directory }}/*
compression-level: 0
if-no-files-found: error
retention-days: 14
+25 -23
View File
@@ -1,35 +1,19 @@
name: Package Desktop · macOS
name: Package Desktop · macOS · Apple Silicon
on:
workflow_dispatch:
inputs:
source_ref:
description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本'
type: string
default: master
required: true
preset:
description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境'
type: choice
options: [test, production]
default: test
required: true
zip_compression_level:
description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强'
type: choice
options: ['3', '7']
default: '3'
required: true
skip_notarization:
description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选'
type: boolean
default: false
required: true
test_release_id:
description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值'
type: string
default: ''
required: false
source_ref:
description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本'
type: string
default: master
required: true
update_channel:
description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道'
type: choice
@@ -47,6 +31,22 @@ on:
type: string
default: ''
required: false
test_release_id:
description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值'
type: string
default: ''
required: false
compression_level:
description: 'ZIP 压缩等级:只影响 ZIP 和自动升级下载,不影响 DMG;3 打包较快,7 压缩更强'
type: choice
options: ['3', '7']
default: '3'
required: true
skip_notarization:
description: '跳过公证和 stapling:仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请勿勾选'
type: boolean
default: false
required: true
permissions:
contents: read
@@ -63,7 +63,9 @@ jobs:
run:
shell: bash
env:
PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.zip_compression_level }}
DSH_DESKTOP_TARGET_PLATFORM: darwin
DSH_DESKTOP_TARGET_ARCH: arm64
PACKAGE_MACOS_ZIP_COMPRESSION_LEVEL: ${{ inputs.compression_level }}
PACKAGE_SKIP_NOTARIZATION: ${{ inputs.skip_notarization }}
PACKAGE_SOURCE_REF: ${{ inputs.source_ref }}
PACKAGE_VERSION_MODE: ${{ inputs.version_mode }}
@@ -0,0 +1,94 @@
name: Policy
on:
pull_request_target:
branches: [master]
types: [opened, synchronize, reopened, ready_for_review]
pull_request_review:
types: [submitted, dismissed]
permissions:
contents: read
pull-requests: read
concurrency:
group: pr-check-after-approve-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
approve:
name: Approve Check
# Each check runs only while its repository variable is neither empty nor `off`; otherwise the job is skipped.
if: ${{ vars.MERGE_POLICY_HEAD_DIFF != '' && vars.MERGE_POLICY_HEAD_DIFF != 'off' }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: read
steps:
# The head checkout is only the Git repository the check fetches into; it needs no files and runs no code.
- uses: actions/checkout@v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1
filter: blob:none
sparse-checkout: README.md
sparse-checkout-cone-mode: false
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
package-manager-cache: false
- uses: deepseek-harness/deepseek-harness-check@master
env:
CHECK_MODE: ${{ vars.MERGE_POLICY_HEAD_DIFF }}
with:
check: head-diff
base-branch: ${{ github.event.pull_request.base.ref }}
head-sha: ${{ github.event.pull_request.head.sha }}
pr-number: ${{ github.event.pull_request.number }}
pr-author: ${{ github.event.pull_request.user.login }}
review:
name: Reviewer Evaluate
if: ${{ vars.MERGE_POLICY_REVIEW != '' && vars.MERGE_POLICY_REVIEW != 'off' }}
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
pull-requests: read
# The check posts its verdict as a check run of its own.
checks: write
steps:
# The head checkout is only the Git repository the check fetches into; it needs no files and runs no code.
- uses: actions/checkout@v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1
filter: blob:none
sparse-checkout: README.md
sparse-checkout-cone-mode: false
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
package-manager-cache: false
# The check app's token makes the review check run its own row instead of joining this workflow's suite.
- id: token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
client-id: ${{ vars.DSH_APPROVAL_APP_CLIENT_ID }}
private-key: ${{ secrets.DSH_APPROVAL_APP_PRIVATE_KEY }}
owner: deepseek-harness
repositories: deepseek-harness
permission-checks: write
- uses: deepseek-harness/deepseek-harness-check@master
env:
CHECK_MODE: ${{ vars.MERGE_POLICY_REVIEW }}
CHECK_TOKEN: ${{ steps.token.outputs.token }}
with:
check: review
base-branch: ${{ github.event.pull_request.base.ref }}
head-sha: ${{ github.event.pull_request.head.sha }}
pr-number: ${{ github.event.pull_request.number }}
pr-author: ${{ github.event.pull_request.user.login }}
@@ -0,0 +1,30 @@
name: Policy
on:
push:
branches: [master]
workflow_dispatch:
permissions: {}
concurrency:
group: pr-check-on-base-branch-${{ github.ref_name }}
jobs:
recheck:
name: Recheck
# Re-runs the head-branch checks, so it follows their variables.
if: ${{ vars.MERGE_POLICY_MASTER_DIFF != '' && vars.MERGE_POLICY_MASTER_DIFF != 'off' || vars.MERGE_POLICY_PRE_MERGE != '' && vars.MERGE_POLICY_PRE_MERGE != 'off' }}
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
actions: write
contents: read
pull-requests: read
steps:
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
package-manager-cache: false
- uses: deepseek-harness/deepseek-harness-check@master
with:
check: recheck
base-branch: ${{ github.ref_name }}
recheck-label-token: ${{ secrets.DSH_RECHECK_LABEL_TOKEN }}
@@ -0,0 +1,129 @@
name: Policy
on:
pull_request_target:
branches: [master]
types: [opened, synchronize, reopened, ready_for_review, review_requested, auto_merge_enabled, labeled]
permissions:
contents: read
concurrency:
group: pr-check-on-head-branch-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
master:
name: Master Change
# Each check runs only while its repository variable is neither empty nor `off`; otherwise the job is skipped.
if: ${{ vars.MERGE_POLICY_MASTER_DIFF != '' && vars.MERGE_POLICY_MASTER_DIFF != 'off' && !github.event.pull_request.draft }}
runs-on: ubuntu-latest
timeout-minutes: 15
outputs:
base_sha: ${{ steps.check.outputs.base_sha }}
since: ${{ steps.check.outputs.since }}
steps:
# The head checkout is only the Git repository the check fetches into; it needs no files and runs no code.
- uses: actions/checkout@v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1
filter: blob:none
sparse-checkout: README.md
sparse-checkout-cone-mode: false
- uses: actions/setup-node@v7.0.0
with:
node-version: '24'
package-manager-cache: false
- id: check
uses: deepseek-harness/deepseek-harness-check@master
env:
CHECK_MODE: ${{ vars.MERGE_POLICY_MASTER_DIFF }}
with:
check: master-diff
base-branch: ${{ github.event.pull_request.base.ref }}
head-sha: ${{ github.event.pull_request.head.sha }}
pre-merge:
name: Pre-Merge Check
needs: master
if: ${{ vars.MERGE_POLICY_PRE_MERGE != '' && vars.MERGE_POLICY_PRE_MERGE != 'off' && needs.master.result == 'success' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 60
# This job installs and compiles the pull request: it reads contents only and receives no secret.
permissions:
contents: read
steps:
- uses: actions/checkout@v7.0.1
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 1
# Merging is the precondition of the build below, so a failure here warns and skips it in `report` mode and fails
# in `block` mode.
- id: merge
name: Merge the checked base-branch commit
env:
CHECK_MODE: ${{ vars.MERGE_POLICY_PRE_MERGE }}
BASE_SHA: ${{ needs.master.outputs.base_sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
SINCE: ${{ needs.master.outputs.since }}
run: |
stop() {
if [ "$CHECK_MODE" = block ]; then
echo "::error title=Pre-Merge Check::$1"
exit 1
fi
echo "::warning title=Pre-Merge Check::$1"
exit 0
}
if [[ ! "$BASE_SHA" =~ ^[0-9a-f]{40}$ || ! "$SINCE" =~ ^[0-9]+$ ]]; then
stop "the master diff check reported no base commit, so nothing was merged or compiled"
fi
# A shallow fetch starts a background maintenance run that rewrites .git/shallow, and the second shallow fetch
# below then fails with "shallow file has changed since we read it".
fetch() {
git -c maintenance.auto=false -c gc.auto=0 fetch --no-tags --no-write-fetch-head "$@" origin "$HEAD_SHA" "$BASE_SHA"
}
fetch --shallow-since="$SINCE" ||
stop "the base commit $BASE_SHA could not be fetched, so nothing was merged or compiled"
# A time window records a merge commit itself as shallow when one of its parents predates the window, which
# drops the other, in-window parent too; a depth bound keeps both parents of every commit it covers.
if ! git merge-base "$HEAD_SHA" "$BASE_SHA" >/dev/null; then
fetch --depth=20 --filter=blob:none ||
stop "the base commit $BASE_SHA could not be fetched, so nothing was merged or compiled"
fi
git -c user.name=pr-check -c user.email=pr-check@users.noreply.github.com merge --no-ff --no-edit -m "Merge $BASE_SHA into $HEAD_SHA" "$BASE_SHA" ||
stop "the pull request cannot merge $BASE_SHA; merge master into it and push, then this check runs again"
echo "merged=true" >> "$GITHUB_OUTPUT"
- uses: pnpm/action-setup@v5.0.0
if: steps.merge.outputs.merged == 'true'
- uses: actions/setup-node@v7.0.0
if: steps.merge.outputs.merged == 'true'
with:
node-version: '24'
package-manager-cache: false
- name: Run the tree's pre-merge check
if: steps.merge.outputs.merged == 'true'
env:
CHECK_MODE: ${{ vars.MERGE_POLICY_PRE_MERGE }}
run: |
# The check names one file and knows nothing else about how the merged tree builds itself.
if [ ! -f scripts/pre-merge-check.ts ]; then
if [ "$CHECK_MODE" = block ]; then
echo "::error title=Pre-Merge Check::this tree has no scripts/pre-merge-check.ts, so nothing was installed, compiled, or checked"
exit 1
fi
echo "::warning title=Pre-Merge Check::this tree has no scripts/pre-merge-check.ts, so nothing was installed, compiled, or checked"
exit 0
fi
if node scripts/pre-merge-check.ts; then
exit 0
fi
# A check that crashes reports nothing, so the step reports its exit the way the mode asks.
if [ "$CHECK_MODE" = block ]; then
echo "::error title=Pre-Merge Check::scripts/pre-merge-check.ts exited without reporting, so the merged tree is unchecked"
exit 1
fi
echo "::warning title=Pre-Merge Check::scripts/pre-merge-check.ts exited without reporting, so the merged tree is unchecked"
+10 -4
View File
@@ -1,12 +1,18 @@
# Publish the dsh release sequence to npm. This workflow is manual-only
# (workflow_dispatch) and intentionally does not listen to pull_request or push:
# publication must always be an explicit, reviewed act from a dsh-v* tag, and it
# must never appear as a PR check. It repacks the current tree before publishing
# publication is an explicit, reviewed act; dsh-v* tag validation is enabled by
# default and may be skipped through the dispatch input. It never appears as a PR check. It repacks the current tree before publishing
# so the bytes uploaded are exactly what this dispatch produced.
name: Release publish (dsh)
on:
workflow_dispatch:
inputs:
skip-tag-check:
description: Skip release tag validation for the selected ref; other publication checks still apply.
type: boolean
required: false
default: false
permissions:
contents: read
@@ -55,10 +61,10 @@ jobs:
- name: Verify release version
env:
RELEASE_PUBLISH: 'true'
run: pnpm run release:verify --family dsh
run: pnpm run release:verify --family dsh ${{ inputs.skip-tag-check && '--skip-tag-check' || '' }}
- name: Build
run: pnpm run build:official
run: pnpm run build:official --serial
- name: Pack release tarballs
run: pnpm run release:pack --family dsh --out dist/npm
+23 -23
View File
@@ -1,30 +1,19 @@
name: Package Desktop · Windows
name: Package Desktop · Windows · x64
on:
workflow_dispatch:
inputs:
source_ref:
description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本'
type: string
default: master
required: true
preset:
description: '打包环境:选择机器上的 test 或 production 配置;同时决定后续发布环境'
type: choice
options: [test, production]
default: test
required: true
signing:
description: '签名方式:unsigned 仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请选择 signed(EV 硬件令牌)'
type: choice
options: [signed, unsigned]
default: signed
required: true
test_release_id:
description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值'
type: string
default: ''
required: false
source_ref:
description: '打包源码:test 支持仓库内分支或 dsh-v* tag;production 仅支持 master 或 dsh-v* tag;源码须包含 CI 适配脚本'
type: string
default: master
required: true
update_channel:
description: '升级通道:nightly 或 latest(须为稳定版本);自动更新不会切换通道'
type: choice
@@ -42,6 +31,23 @@ on:
type: string
default: ''
required: false
test_release_id:
description: '测试 release ID:32 位小写十六进制随机前缀(非鉴权);留空使用机器默认值,连续升级测试须保持相同值'
type: string
default: ''
required: false
compression_level:
description: '安装器压缩等级:3 打包较快,7 压缩更强;覆盖本轮机器配置'
type: choice
options: ['3', '7']
default: '3'
required: true
signing:
description: '签名方式:unsigned 仅限内部调试,禁止发布到 test / production 或用于自动更新;需要发布请选择 signed(EV 硬件令牌)'
type: choice
options: [signed, unsigned]
default: signed
required: true
cache_namespace:
description: '缓存测量命名空间:留空使用共享缓存;新名称用于冷缓存,再次使用同名测热缓存,不清除现有缓存'
type: string
@@ -52,12 +58,6 @@ on:
type: string
default: ''
required: false
compression_level:
description: '安装器压缩等级:3 打包较快,7 压缩更强;覆盖本轮机器配置'
type: choice
options: ['3', '7']
default: '3'
required: true
permissions:
contents: read
+4
View File
@@ -134,6 +134,7 @@ External packages installed for runtime use or embedded in distributed Host or b
| [`semver`](https://github.com/npm/node-semver) | ISC |
| [`serve-static`](https://github.com/expressjs/serve-static) | MIT |
| [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 |
| [`shell-quote`](http://github.com/ljharb/shell-quote) | MIT |
| [`sherpa-onnx-node`](https://github.com/csukuangfj/sherpa-onnx) | Apache-2.0 |
| [`shiki`](https://github.com/shikijs/shiki) | MIT |
| [`simple-icons`](https://github.com/simple-icons/simple-icons) | CC0-1.0 |
@@ -163,6 +164,8 @@ pnpm applies local patches to the following packages at install time, so shipped
- `app-builder-lib@26.17.0` — [`patches/app-builder-lib@26.17.0.patch`](patches/app-builder-lib@26.17.0.patch)
- `exceljs@4.4.0` — [`patches/exceljs@4.4.0.patch`](patches/exceljs@4.4.0.patch)
- `lexical@0.49.0` — [`patches/lexical@0.49.0.patch`](patches/lexical@0.49.0.patch)
- `mdast-util-gfm-autolink-literal@2.0.1` — [`patches/mdast-util-gfm-autolink-literal@2.0.1.patch`](patches/mdast-util-gfm-autolink-literal@2.0.1.patch)
- `micromark-extension-gfm-autolink-literal@2.1.0` — [`patches/micromark-extension-gfm-autolink-literal@2.1.0.patch`](patches/micromark-extension-gfm-autolink-literal@2.1.0.patch)
- `node-pty@1.2.0-beta.15` — [`patches/node-pty@1.2.0-beta.15.patch`](patches/node-pty@1.2.0-beta.15.patch)
## Chrome DevTools frontend
@@ -267,6 +270,7 @@ External packages **directly declared** for development, tests, types, or toolin
| [`selfsigned`](https://github.com/jfromaniello/selfsigned) | MIT |
| [`smol-toml`](https://github.com/squirrelchat/smol-toml) | BSD-3-Clause |
| [`spdx-expression-parse`](https://github.com/jslicense/spdx-expression-parse.js) | MIT |
| [`tencentcloud-sdk-nodejs-teo`](https://github.com/tencentcloud/tencentcloud-sdk-nodejs) | Apache-2.0 |
| [`tsdown`](https://github.com/rolldown/tsdown) | MIT |
| [`typescript-language-server`](https://github.com/typescript-language-server/typescript-language-server) | Apache-2.0 |
| [`vite`](https://github.com/vitejs/vite) | MIT |
+4 -2
View File
@@ -1,7 +1,7 @@
{
"name": "@deepseek-ai/dsh",
"description": "dsh CLI: profile launch, plugin management, and configuration inspection",
"version": "0.2.1-rc.3",
"version": "0.2.2-alpha.2",
"publishConfig": {
"access": "public"
},
@@ -104,11 +104,13 @@
"@deepseek-ai/dsh-experimental-ralph-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-session-titles-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-terminal-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-liveshare": "workspace:*",
"@deepseek-ai/dsh-experimental-session-search": "workspace:*",
"@deepseek-ai/dsh-experimental-tool-worktree": "workspace:*",
"@deepseek-ai/dsh-cua-bundle": "workspace:*",
"@deepseek-ai/dsh-experimental-plugin-market-bundle": "workspace:*",
"@deepseek-ai/dsh-personalized-recommendations": "workspace:*"
"@deepseek-ai/dsh-personalized-recommendations": "workspace:*",
"@deepseek-ai/dsh-experimental-forge-bundle": "workspace:*"
},
"devDependencies": {
"@deepseek-ai/dsh-skill-badge": "workspace:*",
@@ -0,0 +1,27 @@
- insert:
- id: preset-delegate-a
name: '@deepseek-ai/dsh-agent-preset'
config:
id: delegate-a
plugins:
- name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
allowedAgentPresets: [standard, ptc, minimal]
- name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
- id: preset-delegate-b
name: '@deepseek-ai/dsh-agent-preset'
config:
id: delegate-b
plugins:
- name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: spawn
allowedAgentPresets: [standard, ptc, minimal]
- name: '@deepseek-ai/dsh-tool-subagent'
config:
provider: fork
toolName: subagent_fork
+78
View File
@@ -355,6 +355,49 @@ describe('the shipped Web composition', () => {
}
})
it.each(['standard', 'ptc'] as const)('retains delegation tools after repeated preset round trips ending in %s', async (target) => {
const adapter = new MinimalCompactionAdapter()
const unregister = ctx.llm.registerAdapter(['preset-round-trip'], adapter)
onTestFinished(unregister)
const direct = await ctx.agents.create({
sessionId: SessionId(`preset-direct-${target}-${randomUUID()}`),
agentOptions: { provider: 'preset-round-trip', model: 'preset-round-trip' },
setup: agentCtx => ctx.agentPresets.mount(agentCtx, target).then(() => undefined),
})
onTestFinished(() => direct.dispose())
const handle = await ctx.agents.create({
sessionId: SessionId(`preset-round-trip-${target}-${randomUUID()}`),
agentOptions: { provider: 'preset-round-trip', model: 'preset-round-trip' },
setup: agentCtx => ctx.agentPresets.mount(agentCtx, target).then(() => undefined),
})
onTestFinished(() => handle.dispose())
const { agent } = handle
const other = target === 'standard' ? 'ptc' : 'standard'
for (const preset of [other, target, other, target]) {
await ctx.agentPresets.select(agent, preset)
expect(toolNames(ctx, agent)).toEqual(expect.arrayContaining(['subagent', 'subagent_fork']))
}
expect(ctx.tools.schemas(agent)).toEqual(ctx.tools.schemas(direct.agent))
const assembly = await ctx.systemPrompt.assemble({ scope: agent })
if (target === 'ptc') {
const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? ''
expect(sdk).toContain('subagent:')
expect(sdk).toContain('subagent_fork:')
}
direct.agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Reply with Ready.' }], source: { kind: 'user' } }))
await direct.agent.whenIdle()
const directRequest = adapter.requests.find(request => request.purpose === undefined)
expect(directRequest).toBeDefined()
adapter.requests.length = 0
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Reply with Ready.' }], source: { kind: 'user' } }))
await agent.whenIdle()
const request = adapter.requests.find(request => request.purpose === undefined)
expect(request).toBeDefined()
expect(request?.tools).toEqual(directRequest?.tools)
const header = agent.session.snapshotEvents().find(event => event.type === 'request/header')
expect(header?.data.header.tools).toEqual(request?.tools)
})
it('composes the exact RL prompt and persistent shell from `minimal`', async () => {
const handle = await ctx.agents.create({
sessionId: SessionId('preset-minimal'),
@@ -683,6 +726,41 @@ describe('the shipped Web composition', () => {
})
})
describe('preset-selected delegation through the Web Loader', () => {
it('exposes configured delegation tools when each preset switch resolves', async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-delegation-switch-'))
try {
const configured = await bootWeb(root, loadOverlayPatches('delegation-switch',
join(REPO_ROOT, 'apps/cli/tests/profiles/subagent-preset-switch.overlay.yml')))
try {
const adapter = new MinimalCompactionAdapter()
configured.llm.registerAdapter(['preset-switch'], adapter)
const { agent } = await configured.agents.create({
sessionId: SessionId(`configured-switch-${randomUUID()}`),
agentOptions: { provider: 'preset-switch', model: 'preset-switch' },
setup: owner => configured.agentPresets.mount(owner, 'delegate-a').then(() => undefined),
})
const views: Array<{ preset: string; names: string[] }> = []
for (const preset of ['delegate-b', 'delegate-a', 'delegate-b', 'delegate-a', 'ptc', 'standard', 'delegate-a']) {
await configured.agentPresets.select(agent, preset)
views.push({ preset, names: toolNames(configured, agent) })
}
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'Reply with Ready.' }], source: { kind: 'user' } }))
await agent.whenIdle()
for (const { preset, names } of views) {
expect(names, `tools after selecting ${preset}`).toEqual(expect.arrayContaining(['subagent', 'subagent_fork']))
}
const request = adapter.requests.find(request => request.purpose === undefined)
expect(request?.tools?.map(tool => tool.name)).toEqual(expect.arrayContaining(['subagent', 'subagent_fork']))
expect(request?.tools?.find(tool => tool.name === 'subagent'))
.toHaveProperty('parameters.properties.agent_preset.enum', ['standard', 'ptc', 'minimal'])
const header = agent.session.snapshotEvents().find(event => event.type === 'request/header')
expect(header?.data.header.tools).toEqual(request?.tools)
} finally { await configured.fiber.dispose() }
} finally { await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) }
})
})
describe.each(['web', 'desktop'] as const)('%s native subagent bundles', (surface) => {
const products = ['codex', 'claude-code'] as const
type Product = typeof products[number]
+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.1-rc.3",
"version": "0.2.2-alpha.2",
"private": true,
"license": "MIT",
"type": "module",
+4
View File
@@ -56,3 +56,7 @@ APPLE_API_ISSUER=
# DOWNLOAD_PROD_COS_BUCKET=
# DOWNLOAD_PROD_COS_SECRET_ID=
# DOWNLOAD_PROD_COS_SECRET_KEY=
# Production fixed downloads require teo:CreatePurgeTask permission on the COS identity.
# Required for upload:latest:* and purge:latest; configure the download domain's EO zone.
# DOWNLOAD_PROD_EO_ZONE_ID=
+4
View File
@@ -41,3 +41,7 @@ DSH_DESKTOP_WINDOWS_COMPRESSION_LEVEL=3
# DOWNLOAD_PROD_COS_BUCKET=
# DOWNLOAD_PROD_COS_SECRET_ID=
# DOWNLOAD_PROD_COS_SECRET_KEY=
# Production fixed downloads require teo:CreatePurgeTask permission on the COS identity.
# Required for upload:latest:* and purge:latest; configure the download domain's EO zone.
# DOWNLOAD_PROD_EO_ZONE_ID=
+5 -3
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.1-rc.3",
"version": "0.2.2-alpha.2",
"private": true,
"license": "MIT",
"author": {
@@ -43,7 +43,8 @@
"upload:win:x64": "tsx scripts/upload-target.ts win-x64",
"upload:latest:mac:arm64": "tsx scripts/upload-target.ts mac-arm64 --latest",
"upload:latest:mac:x64": "tsx scripts/upload-target.ts mac-x64 --latest",
"upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest"
"upload:latest:win:x64": "tsx scripts/upload-target.ts win-x64 --latest",
"purge:latest": "tsx scripts/upload-target.ts --latest --purge-only"
},
"dependencies": {
"electron-updater": "^6.8.9",
@@ -86,6 +87,7 @@
"@deepseek-ai/dsh-client-product-analytics": "workspace:*",
"@deepseek-ai/dsh-plugin-manager": "workspace:*",
"zod": "4.4.3",
"@deepseek-ai/dsh-util-file-drag": "workspace:*"
"@deepseek-ai/dsh-util-file-drag": "workspace:*",
"tencentcloud-sdk-nodejs-teo": "4.1.330"
}
}
+4 -4
View File
@@ -24,11 +24,11 @@
en: 5b4f0f80a117f202
zh: ff05be196c181087
/desktop-packaging/windows-actions-packaging:
en: 364223725374d9ac
zh: c4d4cc58d7cbafbd
en: 9dd2227f2878519e
zh: cd95dd6efd48298f
/desktop-packaging/macos-self-hosted-packaging:
en: bb1dcdfef3f3a113
zh: 9c151800a61e2505
en: ee628e830d849ae9
zh: 44313f5fb842f042
/desktop-packaging/unsigned-windows-test-installer:
en: f429bc0c280e2dfa
zh: 4adbcc9e906bdf80
+4 -2
View File
@@ -109,7 +109,7 @@ Each target owns its packed package inputs, prepared runtime, package set, dsh t
For supervised runner startup and recovery, follow [Windows runner machine setup](../windows-signing/README.md#machine-setup).
The `Package Desktop · Windows` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `signed` (default) or `unsigned`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged.
The `Package Desktop · Windows · x64` [manual workflow](../../../.github/workflows/windows-package.yml) selects the dedicated Windows x64 runner label `dsh-win-package-trial` and the machine preset `test` or `production`. Choose `signed` (default) or `unsigned`; signed mode uses the existing EV token preflight, signature cache and failure interlock. It does not publish or upload a release. `build_version` defaults to `auto`, using the CI identifier in [Release versions](#release-versions); an explicit value may be the checkout's product version or an extended build version such as `0.2.1-alpha.1.20261009.1` for product `0.2.1-alpha.1`. The version prefix must match the selected checkout; validation errors show its current example. Manifests remain unchanged.
Select the workflow in Actions, choose the source branch, preset, signing mode and version, then click Run workflow. Before merging input changes, test the pushed branch with `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto`; verify the web form after merging. There is no push or PR trigger. Logs and successful EXEs are retained for 14 days. Inspect the retained logs and installer before relying on packaging; installation is separate. Before checkout, Node removes the runner-owned source directory without following dependency junctions; the workflow validates its location and rejects linked ancestors. Never point it at a development checkout. Runs share one concurrency group without cancelling an active build; only one run remains pending, and a newer dispatch replaces it. Diagnostics upload requires a successful clean checkout. Machine settings stay outside the checkout and are never uploaded as artifacts.
@@ -127,7 +127,9 @@ Installer uploads store the already-compressed release files without ZIP recompr
## macOS self-hosted packaging
The `Package Desktop · macOS` [manual workflow](../../../.github/workflows/macos-package.yml) targets an Apple Silicon runner labeled `dsh-mac-package-trial`. It selects `test` or `production`, resolves `build_version=auto` from the UTC date, run number and attempt, and uses the existing signing, notarization and packaged-runtime checks. Installer upload disables redundant ZIP compression; diagnostic logs retain normal compression. Successful DMG/ZIP files and diagnostic logs are retained for 14 days; this workflow does not publish a release. The operator must register the runner and verify a complete Actions run before relying on it. The `zip_compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only the ZIP and automatic-update downloads, not the DMG.
The [Apple Silicon workflow](../../../.github/workflows/macos-package.yml) and [Intel workflow](../../../.github/workflows/macos-intel-package.yml) produce separate arm64 and x64 packages on the Apple Silicon runner labeled `dsh-mac-package-trial`. Both share one concurrency group. The Intel workflow checks Rosetta execution before dependency installation; its selected source must include the target-aware macOS CI adapter. Both select `test` or `production` and use the existing signing, notarization and packaged-runtime checks. Successful DMG/ZIP files and diagnostic logs are retained for 14 days without publishing a release. Installer uploads disable redundant ZIP compression. The operator must verify a complete Actions run and installation on the target architecture before release qualification. The `compression_level` dropdown defaults to `3` and also offers `7`; it overrides the machine preset for this run and affects only ZIP and automatic-update downloads, not DMG.
All three platform forms put source, environment, update channel, version mode, manual version, test release ID and compression level first, with matching defaults. macOS notarization and Windows signing, cache measurement and isolated-workspace options follow these common fields.
Run `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` to prepare private `runner/`, `packaging/{presets,secrets,certificates}` and `cache/macos-signatures` directories without overwriting existing files; append `--check` to inspect them. Fill the [preset](../config/macos-packaging/test.env.example) and [signing](../config/macos-packaging/macos-signing.env.example) templates outside the checkout. Set secret and certificate files to mode `0600`. `DSH_PACKAGING_CONFIG_DIR` selects the absolute `packaging` directory and `DSH_PACKAGING_PRESET` selects its preset; missing files fail without falling back to `.env.macos`. Signed and preparation-only Mac builds read `secrets/macos-signing.env`; relative credential paths resolve beside that file. Public presets reject signing credentials, and signing files cannot override public settings. Local unsigned Mac builds retain their configuration-free behavior.
+4 -2
View File
@@ -109,7 +109,7 @@ macOS 正式发布和本地未签名 DMG 都通过 [electron-builder 配置](../
runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-signing/README.zh.md#machine-setup)。
`Package Desktop · Windows` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `signed`(默认)或 `unsigned`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。
`Package Desktop · Windows · x64` [手动工作流](../../../.github/workflows/windows-package.yml) 选择专用 Windows x64 runner 标签 `dsh-win-package-trial`,并使用机器 preset `test` 或 `production`。选择 `signed`(默认)或 `unsigned`;签名模式复用现有 EV token 预检、签名缓存与失败 interlock,不发布或上传 release。`build_version` 默认为 `auto`,使用[发布版本](#release-versions)中的 CI 标识;显式值可以是源码产品版本,或扩展构建版本,例如产品 `0.2.1-alpha.1` 对应 `0.2.1-alpha.1.20261009.1`。版本前缀必须匹配所选源码;校验错误显示其当前示例。manifest 保持不变。
在 Actions 选择工作流、源码分支、preset、签名模式和版本,再点击 Run workflow。输入变更合并前,用 `gh workflow run windows-package.yml --ref <branch> -f preset=test -f signing=unsigned -f build_version=auto` 测试已推送分支;合并后验证网页表单。它不监听 push 或 PR。日志和成功生成的 EXE 保留 14 天。依赖打包流程前检查日志和安装包;安装是独立操作。检出前,Node 删除 runner 专用源码目录,不跟随依赖 junction;工作流校验目标位置并拒绝链接祖先目录。不得指向日常开发目录。运行共享一个并发组,不取消正在进行的构建;最多保留一个待运行任务,新触发会替换它。仅在成功完成干净检出后上传诊断。机器配置位于 checkout 之外,不作为产物上传。
@@ -127,7 +127,9 @@ runner 的受监督启动和恢复见 [Windows runner 机器准备](../windows-s
## macOS 自托管打包
`Package Desktop · macOS` [手动工作流](../../../.github/workflows/macos-package.yml) 使用带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner。它选择 `test` 或 `production`,以 UTC 日期、运行编号和重试次数解析 `build_version=auto`,并复用现有签名、公证和打包运行时检查。安装包上传关闭重复 ZIP 压缩;诊断日志保留普通压缩。成功的 DMG/ZIP 与诊断日志保留 14 天;工作流不发布 release。操作人员须注册 runner,并验收完整 Actions 运行后再依赖该流程。 `zip_compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
[Apple Silicon 工作流](../../../.github/workflows/macos-package.yml)与 [Intel 工作流](../../../.github/workflows/macos-intel-package.yml)在带有 `dsh-mac-package-trial` 标签的 Apple Silicon runner 上分别生成 arm64 和 x64 安装包,两者共用一个并发组。Intel 工作流在安装依赖前检查 Rosetta 能否执行;所选源码必须包含按目标架构校验的 macOS CI 适配脚本。两者均选择 `test` 或 `production`,并复用现有签名、公证和打包运行时检查。成功的 DMG/ZIP 与诊断日志保留 14 天,不发布 release;安装包上传关闭重复 ZIP 压缩。操作人员须验收完整 Actions 运行及目标架构上的安装,才能确认可交付。`compression_level` 下拉选项默认为 `3`,也可选择 `7`;本次运行以该选项覆盖机器预设,只影响 ZIP 和自动升级下载,不影响 DMG。
三个平台的表单均依次展示源码、环境、升级通道、版本模式、手动版本号、测试 release ID 和压缩等级,默认值一致。macOS 公证选项和 Windows 签名、缓存测量、独立构建目录选项位于公共字段之后。
运行 `node apps/desktop/scripts/setup-macos-packaging.mjs "$HOME/dsh-ci"` 创建私有 `runner/`、`packaging/{presets,secrets,certificates}` 和 `cache/macos-signatures` 目录,不覆盖已有文件;追加 `--check` 仅检查。在 checkout 外填写 [preset](../config/macos-packaging/test.env.example) 和[签名](../config/macos-packaging/macos-signing.env.example)模板,将秘密和证书文件权限设为 `0600`。`DSH_PACKAGING_CONFIG_DIR` 指定 `packaging` 的绝对目录,`DSH_PACKAGING_PRESET` 选择其中的 preset;缺少文件立即失败,不回退到 `.env.macos`。Mac 签名和仅准备构建读取 `secrets/macos-signing.env`,相对凭据路径以该文件所在目录为基准。公开 preset 拒绝签名凭据,签名文件不能覆盖公开配置。本地未签名 Mac 构建保留无需配置的行为。
+6 -6
View File
@@ -18,17 +18,17 @@
en: b407de63118c9bf9
zh: de19f1ba9feacbbf
/desktop-publication/deployment-and-test-release-identity:
en: 405588e021bcfb60
zh: d9f9700b0b6db6f8
en: eed7c4ed0d4e2758
zh: 026fe6076e11018e
/desktop-publication/publish-installers-and-update-feeds:
en: 3c3e3a09573c4386
zh: c851ae6a94f2284a
/desktop-publication/fixed-installer-downloads:
en: 38054a5f89590cfa
zh: 9e115f9bdeeab213
en: ce8296f8f1ee881e
zh: 749ab63bd6ed4248
/desktop-publication/upload-credentials:
en: 1c5689525753b08d
zh: 93089de765806733
en: 47442fb912fa4d5d
zh: a21a0d67cd01724b
/desktop-publication/upload-audit-and-recovery:
en: ec85f43ece09a5e3
zh: 2de5bf801c9c9e39
+7 -3
View File
@@ -33,7 +33,7 @@ Publication requires completed release artifacts and explicit operator authoriza
`DSH_DESKTOP_AUTO_UPDATE_ENV` selects `test` or `production` for both the URL embedded during packaging and the later COS upload; an absent value selects `test`. Test packaging requires its HTTPS origin in `DOWNLOAD_TEST_ORIGIN`; production uses `https://download.deepseek.com`. Upload requires the selected bucket in `DOWNLOAD_TEST_COS_BUCKET` or `DOWNLOAD_PROD_COS_BUCKET`. Production feeds use `dsh-desk/feeds/<target>/` and binaries use `dsh-desk/bin/<target>/`. Test releases require `DOWNLOAD_TEST_RELEASE_ID`: 32 lowercase hexadecimal characters inserted as `dsh-desk/<release-id>/feeds/<target>/` and `dsh-desk/<release-id>/bin/<target>/`. YAML references, stable-channel aliases, and blockmaps stay inside that release directory. Targets are `mac-arm64`, `mac-x64`, and `win-x64`.
The update destination and upload credentials follow the selected deployment:
The update destination and upload credentials follow the selected deployment; production fixed downloads also require the [EdgeOne settings](#fixed-downloads):
| Environment | Public origin | COS bucket | COS credentials |
|---|---|---|---|
@@ -69,13 +69,17 @@ Windows NSIS uploads require the generated, nonempty `.exe.blockmap` beside the
## Fixed installer downloads
Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Deployment infrastructure must configure revalidation or purge cached fixed downloads after replacement. Local loopback tests cover the upload; release operators own real COS and CDN acceptance.
Fixed installer downloads use `pnpm run upload:latest:mac:arm64`, `pnpm run upload:latest:mac:x64`, or `pnpm run upload:latest:win:x64` from the repository root. They upload the completed target's DMG or EXE to `desktop/dsh-latest-macos-arm64.dmg`, `desktop/dsh-latest-macos-x64.dmg`, or `desktop/dsh-latest-windows-x64.exe`. Production URLs start with `https://download.deepseek.com/`; test URLs use `DOWNLOAD_TEST_ORIGIN`. Both deployments reuse the target dotenv's deployment, bucket, and credentials, all normal release validation, and the upload audit below. Each command replaces one fixed object without publishing feeds, blockmaps, or release tags. Test fixed downloads are shared across release IDs in the test bucket; the packaged update URL still requires its original release ID. Publication is explicit, including prerelease versions. Production commands then submit one EdgeOne URL purge and report its task ID; they do not query or wait for cache completion. Test uploads retain deployment-managed cache policy. Local loopback tests cover the upload; release operators own real COS and CDN acceptance.
Production fixed downloads require `DOWNLOAD_PROD_EO_ZONE_ID` in the target dotenv and `teo:CreatePurgeTask` permission for that zone on the existing `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` identity. The release operator must confirm that permission before use; no additional CI Secret is required. Required settings are resolved before uploading. The submission has a 30-second request timeout and is never retried automatically.
The command reports submission acceptance, not completed cache invalidation. EO results go to the console without separate purge record files; the existing upload audit records only COS writes. A rejected or unconfirmed submission exits unsuccessfully; the uploaded installer stays published. Check EO task history before submitting again after a connection loss or missing response. To submit a purge without uploading, use `pnpm run purge:latest <target>` with production settings and credentials; no local installer is needed. DPAPI users reuse the original credential launcher and add `-PurgeOnly` to their `-Upload -Latest` invocation. Serialize uploads and cache-only submissions per target. The CI publisher does not publish fixed downloads or run this refresh.
<a id="credentials"></a>
## Upload credentials
Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
Windows operators can keep a CLIXML object with DPAPI-encrypted `SecretId` and `SecretKey` SecureString fields outside the repository. The [credential launcher](../scripts/upload-with-credentials.ps1) requires an explicit `-CredentialFile` and `-Environment production` or `test`; without `-Upload`, it only verifies decryption and injection into a local Node child, with no network request. It requires Node on `PATH` and the Windows user and machine that encrypted the file. Plaintext, empty, and whitespace-only fields fail. The parent environment is unchanged; the child receives only the selected COS pair after unrelated secrets and Node preload options are removed. Raw child stderr is suppressed and credential values in stdout are redacted. This check does not prove COS authorization. An explicit upload additionally requires `-Upload -Target <target> -Bucket <bucket>` and the normal completed-release prerequisites below; actual cloud upload remains release-operator qualification. Add `-Latest` to that upload invocation to replace the fixed installer without publishing update feeds or a release tag. For a cache-only submission, add `-PurgeOnly` to `-Upload -Latest -Environment production`. Deployment and bucket must still match the dotenv, while package artifacts are not read. This launcher supports permanent keys, not STS credentials. An explicit upload requires the selected deployment and bucket to match the target dotenv and completed package record before network writes; it uses the DPAPI credential pair even if the dotenv contains other COS keys.
<a id="audit-and-recovery"></a>
+7 -3
View File
@@ -33,7 +33,7 @@ description: "将完整 Desktop 安装包与更新清单发布到选定下载服
`DSH_DESKTOP_AUTO_UPDATE_ENV` 同时选择打包写入的 URL 与后续 COS 上传环境,可取 `test` 或 `production`;缺省为 `test`。测试打包通过 `DOWNLOAD_TEST_ORIGIN` 提供 HTTPS origin;生产使用 `https://download.deepseek.com`。上传通过 `DOWNLOAD_TEST_COS_BUCKET` 或 `DOWNLOAD_PROD_COS_BUCKET` 提供所选 bucket。生产清单位于 `dsh-desk/feeds/<target>/`,安装包位于 `dsh-desk/bin/<target>/`。测试发布必须配置 `DOWNLOAD_TEST_RELEASE_ID`:32 位小写十六进制字符,分别插入路径 `dsh-desk/<release-id>/feeds/<target>/` 和 `dsh-desk/<release-id>/bin/<target>/`。YAML 引用、稳定通道别名和 blockmap 都位于该发布目录内。目标为 `mac-arm64`、`mac-x64` 和 `win-x64`。
更新目标与上传凭据都与所选环境对应:
更新目标与上传凭据都与所选环境对应;生产固定下载还需配置 [EdgeOne 设置](#fixed-downloads):
| 环境 | 公开 origin | COS bucket | COS 凭据 |
|---|---|---|---|
@@ -69,13 +69,17 @@ Windows NSIS 上传要求安装包旁存在生成的非空 `.exe.blockmap`。blo
## 固定安装包下载
固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。部署基础设施必须配置重新验证,或在覆盖后清除固定下载的缓存。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。
固定安装包下载在仓库根目录使用 `pnpm run upload:latest:mac:arm64`、`pnpm run upload:latest:mac:x64` 或 `pnpm run upload:latest:win:x64`。它们把已完成目标的 DMG 或 EXE 上传到 `desktop/dsh-latest-macos-arm64.dmg`、`desktop/dsh-latest-macos-x64.dmg` 或 `desktop/dsh-latest-windows-x64.exe`。生产 URL 以 `https://download.deepseek.com/` 开头;测试 URL 使用 `DOWNLOAD_TEST_ORIGIN`。两个部署环境均复用目标 dotenv 中的部署、bucket 和凭据、全部常规发布校验以及下述上传审计。每个命令覆盖一个固定对象,不发布更新清单、blockmap 或发布标签。测试固定下载在测试 bucket 中跨发布 ID 共享;包内更新 URL 仍要求原有发布 ID。发布由操作人员显式执行,也允许预发布版本。生产命令随后提交一次 EdgeOne URL 缓存清除任务并报告任务 ID,不查询或等待缓存清除完成。测试上传仍由部署基础设施管理缓存策略。本地回环测试覆盖上传行为;真实 COS 和 CDN 验收由发布操作人员负责。
生产固定下载要求在目标 dotenv 中配置 `DOWNLOAD_PROD_EO_ZONE_ID`,且既有 `DOWNLOAD_PROD_COS_SECRET_ID` / `DOWNLOAD_PROD_COS_SECRET_KEY` 身份具备对应站点的 `teo:CreatePurgeTask` 权限。发布操作人员须在使用前确认该权限;无需新增 CI Secret。上传前解析必需配置。提交请求超时为 30 秒,不自动重试。
命令报告提交已接受,不代表缓存清除已完成。EO 结果输出到控制台,不另建刷新记录文件;既有上传审计仅记录 COS 写入。提交被拒绝或未确认时,命令以失败状态退出,已上传安装包保持发布。连接中断或未收到响应时,先检查 EO 任务历史再重新提交。使用 `pnpm run purge:latest <target>` 可只提交刷新,无需上传;需要生产配置和凭据,无需本地安装包。DPAPI 用户复用原凭据启动器,在 `-Upload -Latest` 命令上添加 `-PurgeOnly`。按目标串行执行上传和单独刷新。CI 发布器不发布固定下载,也不执行此刷新。
<a id="credentials"></a>
## 上传凭据
Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。
Windows 操作人员可以在仓库外保存 CLIXML 对象,其中 `SecretId` 和 `SecretKey` 是经 DPAPI 加密的 SecureString 字段。[凭据启动器](../scripts/upload-with-credentials.ps1)要求显式提供 `-CredentialFile` 和 `-Environment production` 或 `test`;不指定 `-Upload` 时,只验证解密以及向本地 Node 子进程注入凭据,不发起网络请求。它要求 `PATH` 中有 Node,并使用加密该文件时的 Windows 用户和机器。明文、空字段及纯空白字段都会失败。父进程环境保持不变;子进程先清除无关密钥与 Node 预加载选项,再仅接收所选 COS 凭据对。原始子进程 stderr 不会显示,stdout 中的凭据值会被遮盖。此检查不能证明 COS 授权有效。显式上传还要求 `-Upload -Target <target> -Bucket <bucket>` 及下述常规发布完成前提;真实云端上传仍需发布操作人员验收。在该上传调用中添加 `-Latest`,即可替换固定安装包,而不发布更新清单或发布标签。仅提交缓存刷新时,在 `-Upload -Latest -Environment production` 上添加 `-PurgeOnly`。部署环境和 bucket 仍须匹配 dotenv,但不读取安装包产物。此启动器支持长期密钥,不支持 STS 凭据。显式上传要求所选部署环境和 bucket 与目标 dotenv 文件及已完成的打包记录一致,才会发起网络写入;即使 dotenv 文件含有其他 COS 密钥,也使用 DPAPI 凭据对。
<a id="audit-and-recovery"></a>
+2 -2
View File
@@ -21,8 +21,8 @@
en: 0478d8e35f9a3d55
zh: 0aa3fd828bc02691
/desktop-ci-publication/machine-setup:
en: f80388c2b1ffef4c
zh: f242ab2316bf38a1
en: 6e7b928af17f4377
zh: 7c559078306df3fd
/desktop-ci-publication/package-and-publish:
en: f7c12d343891e547
zh: da596ee1366c2046
+1 -1
View File
@@ -56,7 +56,7 @@ Keep `DSH_PACKAGING_CONFIG_DIR` pointed at the existing machine packaging direct
Create `publishing/test.json` and, when authorized, `publishing/production.json` below the configuration directory. Each declares `bucket`, HTTPS `origin` without a path, and an absolute `credentialsFile`. The credential file contains only `secretId` and `secretKey`. macOS also requires `macOS.signingIdentity` and `macOS.teamId`; Windows requires `windows.certificateThumbprint`. Production requires the exact origin `https://download.deepseek.com` and `githubApp.clientId` plus an absolute `githubApp.privateKeyFile`. These files stay outside Git. The uploader supports permanent COS credentials, not STS session credentials. Administrators must keep test and production buckets unversioned (neither enabled nor suspended). Publication assumes this deployment prerequisite and does not call `GetBucketVersioning` or require its permission. Immutable binary PUTs retain `x-cos-forbid-overwrite`; their protection depends on administrators maintaining the bucket configuration.
Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`. Assign that label to exactly that runner. Windows uses target `win-x64`. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other.
Set the repository variable `DESKTOP_PUBLISH_RUNNERS` to a JSON mapping of registered runner names to unique labels and targets, for example `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`. Assign that label to exactly that runner. A target may be a single name or a nonempty list; the Mac entry above authorizes both packaging workflows on the same machine. Windows uses target `win-x64`. The retained target must match the selected packaging workflow. The routing job checks the original successful job, and the publisher checks its own runner name against the recorded name. Machines need no shared filesystem or LAN connection to each other.
An administrator must create `desktop-test` and `desktop-production`, each with required reviewers and an allowed `master` deployment branch. The workflow checks reviewers for either deployment. Test can allow self-approval. The GitHub App needs installation access to this repository and Contents write permission, plus permission under the `desktop-v*` tag ruleset to create release tags. Reviewer approval does not transfer the reviewer's tag permissions to the App. Missing configuration stops publication; there is no personal-token fallback. The App action creates and later revokes a short-lived token. Protect the local private key and COS credential files with the publishing account's filesystem permissions. The token action accepts key contents, so the masked PEM also passes through the runner's temporary `GITHUB_OUTPUT` file during the job; masking hides logs, not local file access.
+1 -1
View File
@@ -56,7 +56,7 @@ job 排队时,先对照 job 页面请求的标签与 **Settings → Actions
在配置目录下创建 `publishing/test.json`,获得授权后再创建 `publishing/production.json`。每份配置声明 `bucket`、不带路径的 HTTPS `origin` 和绝对路径 `credentialsFile`。凭据文件只包含 `secretId` 和 `secretKey`。macOS 还需 `macOS.signingIdentity` 和 `macOS.teamId`;Windows 需要 `windows.certificateThumbprint`。production 必须使用 origin `https://download.deepseek.com`,并提供 `githubApp.clientId` 和绝对路径 `githubApp.privateKeyFile`。这些文件不进入 Git。上传器支持长期 COS 凭据,不支持 STS 会话凭据。管理员必须维持 test 和 production bucket 未启用版本控制(不能是已启用或已暂停状态)。发布流程以此为部署前提,不调用 `GetBucketVersioning`,也不要求该权限。二进制 PUT 仍携带 `x-cos-forbid-overwrite`;防覆盖保证依赖管理员维持桶配置。
将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":"mac-arm64"}}`。该标签只能分配给这台 runner。Windows 使用目标 `win-x64`。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。
将仓库变量 `DESKTOP_PUBLISH_RUNNERS` 配置为已注册 runner 名称到唯一标签和目标的 JSON 映射,例如 `{"mini":{"label":"dsh-mini-publish","target":["mac-arm64","mac-x64"]}}`。该标签只能分配给这台 runner。目标可为单个名称或非空列表;上述 Mac 条目允许同一台机器处理两种架构的打包工作流。Windows 使用目标 `win-x64`。留存包的目标必须与所选打包工作流一致。路由 job 检查原构建成功的 job,发布器再检查自身 runner 名称与记录一致。机器之间不需要共享文件系统或局域网连接。
管理员必须创建 `desktop-test` 和 `desktop-production`,分别设置必需审批人并允许 `master` 部署分支。工作流会检查两种环境的审批人配置。test 可允许发起者自行审批。GitHub App 需要安装到本仓库、具有 Contents write 权限,并获准在 `desktop-v*` tag ruleset 下创建发布 tag。审批不会把审批人的 tag 权限传给 App。缺少配置会停止发布,不回退到个人 token。App action 创建短期 token,并在结束后撤销。使用发布账户的文件系统权限保护本机私钥及 COS 凭据文件。token action 需要密钥内容,因此已屏蔽日志的 PEM 在 job 期间也会经过 runner 临时 `GITHUB_OUTPUT` 文件;日志屏蔽不限制本地文件访问。
@@ -7,6 +7,13 @@ export type DesktopAutoUpdateEnvironment = 'test' | 'production'
/** Directory name of one supported Desktop release target. */
export type DesktopAutoUpdateTarget = 'mac-arm64' | 'mac-x64' | 'win-x64'
/**
* Name the fixed installer object shared by uploading and cache refresh.
* @param target Supported Desktop target.
* @returns Fixed download filename.
*/
export function desktopLatestInstallerFilename(target: DesktopAutoUpdateTarget): string
/** Public updater URL and object prefixes for one release target. */
export interface DesktopAutoUpdateConfig {
readonly environment: DesktopAutoUpdateEnvironment
@@ -98,3 +105,11 @@ export function resolveDesktopUploadConfig(
* @returns Validated channel.
*/
export function resolveDesktopUpdateChannel(env: NodeJS.ProcessEnv): 'nightly' | 'latest'
/**
* Read a required nonempty deployment setting.
* @param env Packaging or upload settings.
* @param name Setting name included in missing-value diagnostics.
* @returns Trimmed setting value.
*/
export function requiredEnvironmentValue(env: NodeJS.ProcessEnv, name: string): string
@@ -35,6 +35,17 @@ const UPDATE_ENVIRONMENTS = {
const UPDATE_TARGETS = new Set(['mac-arm64', 'mac-x64', 'win-x64'])
/**
* Name the fixed installer object shared by uploading and cache refresh.
* @param {'mac-arm64' | 'mac-x64' | 'win-x64'} target Supported Desktop target.
* @returns {string} Fixed download filename.
*/
export function desktopLatestInstallerFilename(target) {
if (!UPDATE_TARGETS.has(target)) throw new Error(`desktop upload: unsupported target ${target}`)
const windows = target === 'win-x64'
return `dsh-latest-${windows ? 'windows' : 'macos'}-${target === 'mac-arm64' ? 'arm64' : 'x64'}.${windows ? 'exe' : 'dmg'}`
}
/**
* Resolve the update deployment, defaulting local release work to test.
* @param {NodeJS.ProcessEnv} env - Packaging or upload environment.
@@ -99,7 +110,7 @@ export function desktopUpdateMetadataFilename(version, platform, channel = 'nigh
* @param {string} name - Environment variable to read.
* @returns {string} Trimmed setting.
*/
function requiredEnvironmentValue(env, name) {
export function requiredEnvironmentValue(env, name) {
const value = env[name]?.trim()
if (value === undefined || value === '') {
throw new Error(`desktop auto-update: ${name} must be set to a non-empty value`)
+6 -4
View File
@@ -75,10 +75,12 @@ export function desktopTargetBuildPaths(target, environment = process.env) {
*/
export function desktopTargetPlatform(target) {
assertSupportedTarget(target)
return {
platform: /** @type {'darwin' | 'win32'} */ (target === 'win-x64' ? 'win32' : 'darwin'),
arch: /** @type {'arm64' | 'x64'} */ (target === 'mac-arm64' ? 'arm64' : 'x64'),
}
const targets = /** @type {const} */ ({
'mac-arm64': { platform: 'darwin', arch: 'arm64' },
'mac-x64': { platform: 'darwin', arch: 'x64' },
'win-x64': { platform: 'win32', arch: 'x64' },
})
return targets[target]
}
/**
+73
View File
@@ -0,0 +1,73 @@
/** Submit one production fixed-installer cache purge to EdgeOne. */
import type { teo } from 'tencentcloud-sdk-nodejs-teo'
import type { DesktopPackageTargetName } from './package-target.ts'
import { desktopLatestInstallerFilename, requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
import { desktopTargetPlatform } from './desktop-build-paths.mjs'
/** Validated production destination and credentials; never serialize this value. */
export interface DesktopPurgeConfig {
readonly zoneId: string
readonly publicUrl: string
readonly secretId: string
readonly secretKey: string
}
/** The single SDK operation needed to submit a purge. */
export type DesktopPurgeClient = Pick<InstanceType<typeof teo.v20220901.Client>, 'CreatePurgeTask'>
/**
* Resolve production purge settings before uploading.
* @param env Target dotenv settings, including any credential-launcher replacement.
* @param target Fixed installer selected by the operator.
* @param publicUrl Uploaded object URL; omitted derives the URL for a cache-only submission.
* @returns Validated destination and credentials.
*/
export function resolveDesktopPurgeConfig(
env: NodeJS.ProcessEnv, target: DesktopPackageTargetName, publicUrl?: string,
): DesktopPurgeConfig {
if (resolveDesktopAutoUpdateEnvironment(env) !== 'production') throw new DesktopPurgeError('desktop purge: production is required')
const { platform, arch } = desktopTargetPlatform(target)
const update = resolveDesktopUploadConfig(env, platform, arch)
const zoneId = requiredEnvironmentValue(env, 'DOWNLOAD_PROD_EO_ZONE_ID')
if (!/^zone-[a-z0-9]+$/u.test(zoneId)) throw new DesktopPurgeError('desktop purge: DOWNLOAD_PROD_EO_ZONE_ID must name one EO zone')
return {
zoneId, publicUrl: publicUrl ?? `${update.origin}/desktop/${desktopLatestInstallerFilename(target)}`,
secretId: requiredEnvironmentValue(env, update.secretIdEnvName), secretKey: requiredEnvironmentValue(env, update.secretKeyEnvName),
}
}
/** A purge submission failure containing only safe CLI diagnostics. */
export class DesktopPurgeError extends Error {}
function safeField(value: unknown, config: DesktopPurgeConfig): string | undefined {
if (typeof value !== 'string' || !/^[\w.-]{1,128}$/u.test(value)
|| value.includes(config.secretId) || value.includes(config.secretKey)) return undefined
return value
}
/**
* Submit once and report the accepted task ID, without querying cache completion.
* @param config Validated production destination and credentials.
* @param client Optional caller-owned SDK client; omitted lazily creates one with a 30-second request timeout.
* @returns Resolves when EO accepts the submission; this does not confirm cache completion.
* @throws DesktopPurgeError on rejection or an unconfirmed submission; never retries or changes uploaded bytes.
*/
export async function purgeDesktopInstaller(config: DesktopPurgeConfig, client?: DesktopPurgeClient): Promise<void> {
let response: Awaited<ReturnType<DesktopPurgeClient['CreatePurgeTask']>>
try {
client ??= new (await import('tencentcloud-sdk-nodejs-teo')).teo.v20220901.Client({
credential: { secretId: config.secretId, secretKey: config.secretKey }, region: '',
profile: { httpProfile: { endpoint: 'teo.tencentcloudapi.com', reqTimeout: 30 } },
})
response = await client.CreatePurgeTask({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] })
} catch (error) {
const code = safeField(typeof error === 'object' && error !== null && 'code' in error ? error.code : undefined, config)
throw new DesktopPurgeError(`desktop purge: submission not confirmed (${code ?? 'request failed'}); check EO task history and teo:CreatePurgeTask permission before submitting again.`)
}
if ((response.FailedList?.length ?? 0) > 0) {
throw new DesktopPurgeError('desktop purge: EO rejected the URL; inspect EO before submitting again.')
}
const jobId = safeField(response.JobId, config)
if (!jobId) throw new DesktopPurgeError('desktop purge: EO returned no task ID; check EO task history before submitting again.')
process.stdout.write(`desktop purge: submitted ${config.publicUrl}; task ${jobId}. Cache completion is not checked.\n`)
}
@@ -15,7 +15,7 @@ import { resolveWindowsPackageSettings } from './windows-package-settings.mjs'
import { resolveDesktopTargetBuildPaths } from './desktop-build-paths.mjs'
const APP_ROOT = fileURLToPath(new URL('..', import.meta.url))
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
const SHARED_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|NPM_REGISTRY|MANDATORY_UPDATE_(?:CONFIG|(?:TEST|PROD)_ORIGIN))|DOWNLOAD_TEST_RELEASE_ID|DOWNLOAD_PROD_EO_ZONE_ID|DOWNLOAD_(?:TEST|PROD)_(?:ORIGIN|COS_BUCKET|COS_SECRET_ID|COS_SECRET_KEY))$/u
const WINDOWS_SETTING = /^DSH_DESKTOP_WINDOWS_(?:CER_FILE|SIGNTOOL|KEY_CONTAINER|TOKEN_PIN|SIGNATURE_CACHE_DIR|SIGNATURE_CACHE_CONCURRENCY|PACK_CONCURRENCY|COMPRESSION_LEVEL)$/u
const MACOS_SETTING = /^(?:DSH_DESKTOP_MACOS_(?:SIGNING_IDENTITY|TEAM_ID|SIGNATURE_CACHE_DIR|PACK_CONCURRENCY|ZIP_COMPRESSION_LEVEL|DOWNLOAD_PROXY|NOTARIZATION_PROXY)|APPLE_(?:API_KEY|API_KEY_ID|API_ISSUER|ID|APP_SPECIFIC_PASSWORD|TEAM_ID|KEYCHAIN|KEYCHAIN_PROFILE)|CSC_(?:LINK|KEY_PASSWORD))$/u
const AMBIENT_RELEASE_SETTING = /^(?:DSH_DESKTOP_(?:APP_ID|AUTO_UPDATE_ENV|MANDATORY_UPDATE_.*|WINDOWS_.*|MACOS_.*)|APPLE_.*|(?:WIN_)?CSC_.*|DOWNLOAD_(?:TEST|PROD)_.*)$/iu
@@ -1,5 +1,6 @@
/** Validate a completed packaging attempt and route publication back to its original runner. */
import { z } from 'zod'
import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop-publish-targets.ts'
import { desktopPublishQualificationError } from './desktop-publish-qualification.mjs'
const positive = z.number().int().positive()
@@ -12,12 +13,11 @@ 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']),
runnerName: z.string(), target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']),
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),
'Production source must be master or dsh-v*') })
const jobsSchema = z.array(z.object({ name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable() }))
const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) }))
/** Validated values handed to the privileged publisher; no caller-selected shell fragments. */
export interface DesktopPublishRoute {
@@ -28,7 +28,7 @@ export interface DesktopPublishRoute {
readonly channel: 'nightly' | 'latest'
readonly runnerName: string
readonly runnerLabel: string
readonly target: 'mac-arm64' | 'win-x64'
readonly target: 'mac-arm64' | 'mac-x64' | 'win-x64'
readonly preset: 'test' | 'production'
}
@@ -49,8 +49,8 @@ export function resolveDesktopPublishRoute(
const diagnostic = desktopPublishQualificationError(source.qualification)
if (diagnostic) throw new Error(diagnostic)
const jobs = jobsSchema.parse(jobsValue)
const routes = routesSchema.parse(routesValue)
const workflow = source.target === 'win-x64' ? '.github/workflows/windows-package.yml' : '.github/workflows/macos-package.yml'
const routes = desktopPublishRunnersSchema.parse(routesValue)
const workflow = desktopPackageWorkflows[source.target]
if (run.repository.full_name !== repository || source.repository !== repository || run.path !== workflow
|| source.runId !== String(run.id) || source.attempt !== String(run.run_attempt)
|| source.workflowCommit !== run.head_sha
@@ -58,7 +58,7 @@ export function resolveDesktopPublishRoute(
throw new Error('desktop publish: request does not match the successful packaging attempt')
}
const route = routes[source.runnerName]
if (!route || route.target !== source.target) throw new Error('desktop publish: original machine has no configured publishing route')
if (!route || !route.target.includes(source.target)) throw new Error('desktop publish: original machine has no configured publishing route')
if (new Set(Object.values(routes).map(value => value.label)).size !== Object.keys(routes).length) {
throw new Error('desktop publish: each configured machine needs its own unique runner label')
}
@@ -0,0 +1,18 @@
/** Approved packaging workflows and machine publication targets. */
import { z } from 'zod'
/** Each target is produced by its own manually dispatched workflow. */
export const desktopPackageWorkflows = {
'mac-arm64': '.github/workflows/macos-package.yml',
'mac-x64': '.github/workflows/macos-intel-package.yml',
'win-x64': '.github/workflows/windows-package.yml',
} as const
const targetSchema = z.enum(['mac-arm64', 'mac-x64', 'win-x64'])
/** Machine labels are unique; a scalar target is shorthand for a one-element target list. */
export const desktopPublishRunnersSchema = z.record(z.string(), z.object({
label: z.string().regex(/^dsh-[a-z0-9-]+$/u),
target: z.union([targetSchema, z.array(targetSchema).min(1)])
.transform(value => typeof value === 'string' ? [value] : value),
}))
+2 -1
View File
@@ -8,6 +8,7 @@ import { dump, load } from 'js-yaml'
import type { DesktopPackageTargetName } from './package-target.ts'
import {
desktopBuildRecordFilename,
desktopLatestInstallerFilename,
desktopUpdateMetadataFilename,
resolveDesktopUploadConfig,
} from './desktop-auto-update-environment.mjs'
@@ -277,7 +278,7 @@ export async function createDesktopUploadPlan(
contents: dump(published),
}
artifacts.push(channelArtifact)
const latestFilename = `dsh-latest-${target.platform === 'darwin' ? 'macos' : 'windows'}-${target.arch}.${target.platform === 'darwin' ? 'dmg' : 'exe'}`
const latestFilename = desktopLatestInstallerFilename(targetName)
const latestKey = `desktop/${latestFilename}`
return {
environment: update.environment,
@@ -29,7 +29,7 @@ export function preflightDesktopPublish(repository, inputs, api) {
for (const { name } of pages.flatMap(page => page.artifacts)) {
if (!name.endsWith(`-${inputs.package_run_id}-${attempt}`)) continue
const qualification = name.startsWith('windows-x64-unsigned-') ? 'unsigned'
: name.startsWith('macos-arm64-unnotarized-debug-') ? 'unnotarized' : ''
: /^macos-(?:arm64|x64)-unnotarized-debug-/u.test(name) ? 'unnotarized' : ''
const diagnostic = desktopPublishQualificationError(qualification)
if (diagnostic) throw new Error(diagnostic)
}
+5 -2
View File
@@ -2,9 +2,12 @@
import { appendFileSync, readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { execFileSync } from 'node:child_process'
import { desktopTargetPlatform, resolveDesktopBuildTarget } from './desktop-build-paths.mjs'
import { desktopCiVersion } from './desktop-ci-version.mjs'
import { loadDesktopPackageEnvironment, validateDesktopPackageEnvironment } from './desktop-package-environment.mjs'
const target = desktopTargetPlatform(resolveDesktopBuildTarget(process.env))
if (target.platform !== 'darwin') throw new Error('macOS CI requires a macOS target')
const appRoot = resolve(import.meta.dirname, '..')
const product = JSON.parse(readFileSync(resolve(appRoot, 'package.json'), 'utf8'))
const version = desktopCiVersion(process.env, product.version)
@@ -18,8 +21,8 @@ for (const [name, value] of Object.entries(environment)) {
process.stdout.write(`::add-mask::${escaped}\n`)
}
}
validateDesktopPackageEnvironment(environment, { platform: 'darwin', arch: 'arm64' }, { skipNotarization })
validateDesktopPackageEnvironment(environment, target, { skipNotarization })
if (!process.env.GITHUB_OUTPUT || !process.env.GITHUB_STEP_SUMMARY) throw new Error('Run this adapter from GitHub Actions')
appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\nartifacts=${skipNotarization ? 'unnotarized-artifacts' : 'artifacts'}\nmode=${skipNotarization ? 'unnotarized-debug' : 'signed'}\n`)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS arm64 signed installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
appendFileSync(process.env.GITHUB_STEP_SUMMARY, `macOS ${target.arch} signed installer\n\nVersion: ${version}\n\nCommit: ${execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n\nChannel: ${process.env.DSH_DESKTOP_UPDATE_CHANNEL}\n\nSource: ${process.env.PACKAGE_SOURCE_REF}\n\nPreset: ${process.env.DSH_PACKAGING_PRESET}\n`)
if (skipNotarization) appendFileSync(process.env.GITHUB_STEP_SUMMARY, '\nWARNING: signed but NOT notarized or stapled. Internal debugging only; Gatekeeper may block downloaded copies. Not qualified for distribution or update publishing.\n')
+1 -1
View File
@@ -18,7 +18,7 @@ import { desktopPublishQualificationError } from './desktop-publish-qualificatio
const routeSchema = z.object({ runId: z.string(), attempt: z.string(), commit: z.string(), runnerName: z.string(),
sourceRef: z.string(), channel: z.enum(['nightly', 'latest']),
target: z.enum(['mac-arm64', 'win-x64']), preset: z.enum(['test', 'production']) })
target: z.enum(['mac-arm64', 'mac-x64', 'win-x64']), preset: z.enum(['test', 'production']) })
/**
* Check retained bytes, original machine, destination and signatures before reading any upload secret.
@@ -3,17 +3,17 @@ import { execFileSync } from 'node:child_process'
import { appendFile, readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { z } from 'zod'
import { desktopPublishRunnersSchema, desktopPackageWorkflows } from './desktop-publish-targets.ts'
const positiveId = z.string().regex(/^[1-9]\d*$/u)
const inputsSchema = z.object({ package_run_id: positiveId, package_run_attempt: z.union([positiveId, z.literal('')]).optional() })
const runSchema = z.object({ id: z.number().int().positive(), run_attempt: z.number().int().positive(),
head_sha: z.string().regex(/^[a-f0-9]{40}$/u), status: z.literal('completed'), conclusion: z.literal('success'),
event: z.literal('workflow_dispatch'), path: z.enum(['.github/workflows/macos-package.yml', '.github/workflows/windows-package.yml']),
event: z.literal('workflow_dispatch'), path: z.enum(Object.values(desktopPackageWorkflows)),
repository: z.object({ full_name: z.string() }) })
const jobsSchema = z.object({ jobs: z.array(z.object({
name: z.string(), runner_name: z.string().nullable(), conclusion: z.string().nullable(),
})) })
const routesSchema = z.record(z.string(), z.object({ label: z.string().regex(/^dsh-[a-z0-9-]+$/u), target: z.enum(['mac-arm64', 'win-x64']) }))
/**
* Read GitHub evidence for one explicit attempt, or the latest attempt without falling back after failure.
@@ -37,10 +37,11 @@ export function selectDesktopPackage(repository: string, inputsValue: unknown, r
const packages = jobs.filter(job => job.name === 'package' && job.conclusion === 'success' && job.runner_name)
if (packages.length !== 1) throw new Error('desktop publish: expected one successful package job')
const runnerName = packages[0]!.runner_name!
const routes = routesSchema.parse(routesValue)
const routes = desktopPublishRunnersSchema.parse(routesValue)
const route = routes[runnerName]
const target = run.path.endsWith('/macos-package.yml') ? 'mac-arm64' : 'win-x64'
if (!route || route.target !== target) throw new Error('desktop publish: original machine has no configured publishing route')
const target = (Object.keys(desktopPackageWorkflows) as Array<keyof typeof desktopPackageWorkflows>)
.find(name => desktopPackageWorkflows[name] === run.path)!
if (!route || !route.target.includes(target)) throw new Error('desktop publish: original machine has no configured publishing route')
if (new Set(Object.values(routes).map(value => value.label)).size !== Object.keys(routes).length) {
throw new Error('desktop publish: each configured machine needs its own unique runner label')
}
@@ -17,6 +17,8 @@ export const DESKTOP_RUNTIME_PATCHES: Readonly<Record<string, DesktopRuntimePatc
'app-builder-lib@26.17.0': { scope: 'workspace-only', reason: 'DMG format validation executes on the build host.' },
'exceljs@4.4.0': { scope: 'workspace-only', reason: 'The patched parser is embedded in the client bundle and worker.' },
'lexical@0.49.0': { scope: 'workspace-only', reason: 'The patched editor is embedded in the client bundle.' },
'mdast-util-gfm-autolink-literal@2.0.1': { scope: 'workspace-only', reason: 'The patched Markdown parser is embedded in the client bundle.' },
'micromark-extension-gfm-autolink-literal@2.1.0': { scope: 'workspace-only', reason: 'The patched Markdown parser is embedded in the client bundle.' },
'node-pty@1.2.0-beta.15': { scope: 'shared', reason: 'Runtime terminals need helper resolution and Windows worker cleanup.' },
}
+73 -25
View File
@@ -1,31 +1,28 @@
/** Upload validated Desktop update artifacts or a fixed installer to Tencent COS. */
/** Upload Desktop artifacts to COS and refresh production fixed downloads through EdgeOne. */
import { resolve } from 'node:path'
import { parseArgs } from 'node:util'
import type { DesktopPackageTargetName } from './package-target.ts'
import { createDesktopCos } from './desktop-cos.ts'
import { resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
import { requiredEnvironmentValue, resolveDesktopAutoUpdateEnvironment, resolveDesktopUploadConfig } from './desktop-auto-update-environment.mjs'
import { desktopTargetPlatform } from './desktop-build-paths.mjs'
import { loadDesktopPackageEnvironment } from './desktop-package-environment.mjs'
import { createDesktopUploadPlan, type DesktopUploadPlan } from './desktop-upload-plan.ts'
import { desktopReleaseTag, tagDesktopRelease } from './desktop-release-tag.ts'
import { uploadDesktopRelease } from './desktop-upload-run.ts'
import { DesktopPurgeError, purgeDesktopInstaller, resolveDesktopPurgeConfig } from './desktop-edgeone.ts'
const SUPPORTED_TARGETS = new Set<DesktopPackageTargetName>(['mac-arm64', 'mac-x64', 'win-x64'])
function targetName(value: string): DesktopPackageTargetName {
if (!SUPPORTED_TARGETS.has(value as DesktopPackageTargetName)) {
throw new Error(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`)
throw new DesktopUploadUsageError(`desktop upload: unsupported target ${JSON.stringify(value)}; expected ${[...SUPPORTED_TARGETS].join(', ')}`)
}
return value as DesktopPackageTargetName
}
function requiredEnvironmentValue(environment: NodeJS.ProcessEnv, name: string): string {
const value = environment[name]?.trim()
if (value === undefined || value === '') {
throw new Error(`desktop upload: ${name} must be set to a non-empty value`)
}
return value
}
/** Invalid command arguments or release configuration, reported before network writes. */
class DesktopUploadUsageError extends Error {}
/**
* Use the credential launcher's selected deployment only when it matches the packaged release destination.
@@ -41,15 +38,14 @@ export function resolveCredentialUploadEnvironment(
selected: 'test' | 'production', bucket: string,
target: DesktopPackageTargetName,
): NodeJS.ProcessEnv {
const platform = target === 'win-x64' ? 'win32' : 'darwin'
const arch = target === 'mac-arm64' ? 'arm64' : 'x64'
const { platform, arch } = desktopTargetPlatform(target)
const destination = resolveDesktopUploadConfig(fileEnvironment, platform, arch)
if (destination.environment !== selected || destination.bucket !== bucket) {
throw new Error('desktop upload: credential launcher deployment or bucket differs from the packaged release destination')
throw new DesktopUploadUsageError('desktop upload: credential launcher deployment or bucket differs from the packaged release destination')
}
if (injectedEnvironment.DSH_DESKTOP_AUTO_UPDATE_ENV !== selected
|| injectedEnvironment[`${selected === 'test' ? 'DOWNLOAD_TEST' : 'DOWNLOAD_PROD'}_COS_BUCKET`] !== bucket) {
throw new Error('desktop upload: credential launcher environment differs from its explicit arguments')
throw new DesktopUploadUsageError('desktop upload: credential launcher environment differs from its explicit arguments')
}
return {
...fileEnvironment,
@@ -58,40 +54,74 @@ export function resolveCredentialUploadEnvironment(
}
}
function purgeConfig(environment: NodeJS.ProcessEnv, target: DesktopPackageTargetName, publicUrl?: string) {
try {
return resolveDesktopPurgeConfig(environment, target, publicUrl)
} catch (error) {
if (error instanceof DesktopPurgeError) throw error
throw new DesktopUploadUsageError('Invalid production purge settings; check DOWNLOAD_PROD_COS_BUCKET, DOWNLOAD_PROD_COS_SECRET_ID, DOWNLOAD_PROD_COS_SECRET_KEY and DOWNLOAD_PROD_EO_ZONE_ID in the target dotenv.')
}
}
/**
* Upload the selected target and record a release tag only for production update uploads.
* Upload the selected target, refresh production fixed downloads, or submit only their cache purge.
* @param args Target and upload options from the command line.
* @returns Resolves after upload and any release-tag attempt complete.
* @returns Resolves after upload, any required EO submission, and any release-tag attempt complete.
* @throws DesktopPurgeError when purge submission fails; a completed upload is not repeated or rolled back.
*/
export async function uploadDesktopTarget(args: string[]): Promise<void> {
const { positionals, values } = parseArgs({ args, allowPositionals: true, options: {
latest: { type: 'boolean' },
'purge-only': { type: 'boolean' },
'credential-launcher': { type: 'boolean' }, environment: { type: 'string' }, bucket: { type: 'string' },
} })
const target = positionals[0]
if (target === undefined || positionals.length !== 1) {
throw new Error('desktop upload: expected exactly one target')
throw new DesktopUploadUsageError('desktop upload: expected exactly one target')
}
const name = targetName(target)
const fileEnvironment = loadDesktopPackageEnvironment(name === 'win-x64' ? 'win32' : 'darwin')
if (values['purge-only'] && !values.latest) {
throw new DesktopUploadUsageError('desktop upload: --purge-only requires --latest')
}
let fileEnvironment: NodeJS.ProcessEnv
try {
fileEnvironment = loadDesktopPackageEnvironment(desktopTargetPlatform(name).platform)
} catch (error) {
throw new DesktopUploadUsageError('Cannot load target dotenv; check the target dotenv file and release settings.')
}
const launcher = values['credential-launcher'] === true
if (launcher ? values.environment === undefined || values.bucket === undefined
: values.environment !== undefined || values.bucket !== undefined) {
throw new Error('desktop upload: credential launcher requires an explicit environment and bucket')
throw new DesktopUploadUsageError('desktop upload: credential launcher requires an explicit environment and bucket')
}
if (values.environment !== undefined && values.environment !== 'test' && values.environment !== 'production') {
throw new Error('desktop upload: credential launcher environment must be test or production')
throw new DesktopUploadUsageError('desktop upload: credential launcher environment must be test or production')
}
const environment = launcher
? resolveCredentialUploadEnvironment(fileEnvironment, process.env, values.environment as 'test' | 'production', values.bucket!, name)
: fileEnvironment
try {
resolveDesktopAutoUpdateEnvironment(environment)
} catch (error) {
throw new DesktopUploadUsageError('DSH_DESKTOP_AUTO_UPDATE_ENV must be "test" or "production".')
}
const recordsRoot = resolve(import.meta.dirname, '../.desktop-build/upload-records')
if (values['purge-only']) {
await purgeDesktopInstaller(purgeConfig(environment, name))
return
}
const plan = await createDesktopUploadPlan(name, { environment, latest: values.latest === true })
const purge = plan.environment === 'production' && values.latest ? purgeConfig(environment, name, plan.publicUrl) : undefined
const cos = createDesktopCos({
secretId: requiredEnvironmentValue(environment, plan.secretIdEnvName),
secretKey: requiredEnvironmentValue(environment, plan.secretKeyEnvName),
})
process.stdout.write(`desktop upload: ${plan.target} ${plan.version} -> ${plan.publicUrl}\n`)
await uploadDesktopRelease(plan, cos, resolve(import.meta.dirname, '../.desktop-build/upload-records'))
await uploadDesktopRelease(plan, cos, recordsRoot)
if (purge !== undefined) {
process.stdout.write('desktop upload: installer uploaded; submitting cache purge.\n')
await purgeDesktopInstaller(purge)
}
if (plan.environment === 'production' && !values.latest) recordProductionRelease(plan)
}
@@ -121,9 +151,27 @@ function recordProductionRelease(plan: DesktopUploadPlan): void {
process.stdout.write(`desktop upload: ${result.tag} ${result.status === 'created' ? 'records' : 'already recorded'} ${plan.commit}\n`)
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
uploadDesktopTarget(process.argv.slice(2)).catch(() => {
process.stderr.write('desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.\n')
/**
* Report command failures without exposing raw storage or SDK exceptions.
* @param args Target and options forwarded by the package command or credential launcher.
* @returns Resolves after reporting any failure and setting the process exit status.
*/
export async function runDesktopUploadCommand(args: string[]): Promise<void> {
try {
await uploadDesktopTarget(args)
} catch (error) {
const usage = error instanceof DesktopUploadUsageError
|| (error instanceof Error && 'code' in error && typeof error.code === 'string' && error.code.startsWith('ERR_PARSE_ARGS_'))
const message = error instanceof DesktopPurgeError ? error.message
: usage ? `desktop upload/purge: ${error instanceof DesktopUploadUsageError ? error.message : 'invalid arguments'}. Usage: upload-target.ts <target> [--latest [--purge-only]]; targets: mac-arm64, mac-x64, win-x64.`
: 'desktop upload: failed; inspect the printed record directory if allocated. No automatic retry; reconcile remote state before another upload.'
// The DPAPI launcher suppresses raw stderr; these diagnostics contain no SDK details.
const output = args.includes('--credential-launcher') ? process.stdout : process.stderr
output.write(`${message}\n`)
process.exitCode = 1
})
}
}
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
void runDesktopUploadCommand(process.argv.slice(2))
}
@@ -18,6 +18,8 @@ COS bucket for an explicit upload. Not needed for the local credential check.
Publish only the installer at its fixed download URL. Requires Upload.
.PARAMETER Upload
Authorize the existing target upload entry. Omit to check credentials locally.
.PARAMETER PurgeOnly
Submit a purge for the production fixed download without uploading. Requires Upload and Latest.
#>
[CmdletBinding(DefaultParameterSetName = 'Check')]
param(
@@ -36,7 +38,9 @@ param(
[Parameter(ParameterSetName = 'Publish', Mandatory = $true)]
[switch]$Upload,
[Parameter(ParameterSetName = 'Publish')]
[switch]$Latest
[switch]$Latest,
[Parameter(ParameterSetName = 'Publish')]
[switch]$PurgeOnly
)
$ErrorActionPreference = 'Stop'
@@ -49,6 +53,11 @@ $credentials = $null
$startInfo = $null
$stage = 'decrypt-file'
if ($PurgeOnly -and (-not $Latest -or $Environment -ne 'production')) {
Write-Output 'desktop credentials: invalid arguments; PurgeOnly requires Latest and production.'
exit 1
}
try {
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) {
throw 'Windows DPAPI is required.'
@@ -92,7 +101,12 @@ try {
$startInfo.EnvironmentVariables["${prefix}_BUCKET"] = $Bucket
$startInfo.Arguments = "--import tsx/esm apps/desktop/scripts/upload-target.ts $Target --credential-launcher --environment $Environment --bucket $Bucket"
if ($Latest) { $startInfo.Arguments += " --latest" }
Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes."
if ($PurgeOnly) { $startInfo.Arguments += " --purge-only" }
if ($PurgeOnly) {
Write-Output "desktop credentials: submitting a cache purge for $Target in $Environment; no installer upload."
} else {
Write-Output "desktop credentials: uploading $Target to $Environment; release validation runs before network writes."
}
} else {
$probe = "const id=process.env.${prefix}_SECRET_ID;const key=process.env.${prefix}_SECRET_KEY;process.exit(id?.trim()&&key?.trim()?0:1)"
$startInfo.Arguments = "-e `"$probe`""
@@ -49,6 +49,8 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
...Object.keys(bundleDependencies).map(name => ({ name, version })),
{ name: providerName, version, os: native.os, files: native.files },
]
const generation = 'a'.repeat(64)
const nativePayload = `native/bin/darwin-arm64/${generation}/DSH Computer Use.app/Contents/MacOS/dsh-cua-native`
const packages = new Map<string, { manifest: Record<string, unknown>; bytes: Buffer }>()
for (const manifest of manifests) {
const name = String(manifest.name)
@@ -56,18 +58,20 @@ it('fetches the macOS optional tarball only for Darwin, with an empty cache for
await mkdir(directory)
await writeFile(join(directory, 'package.json'), JSON.stringify(manifest))
if (name === providerName) {
await mkdir(join(directory, 'native/bin/darwin-arm64'), { recursive: true })
await mkdir(join(directory, nativePayload, '..'), { recursive: true })
for (const file of ['.gitignore', '.npmignore']) {
await writeFile(join(directory, 'native', file), await readFile(join(repository, 'packages/experimental/cua-macos/native', file)))
}
await writeFile(join(directory, 'native/bin/darwin-arm64/dsh-cua-native'), 'inert native payload')
await writeFile(join(directory, 'native/bin/darwin-arm64/current.json'), JSON.stringify({ bundle: generation }))
await writeFile(join(directory, nativePayload), 'inert native payload')
}
await command(['pack', '--out', 'package.tgz'], directory)
const bytes = await readFile(join(directory, 'package.tgz'))
packages.set(name, { manifest, bytes })
if (name === providerName) {
const result = await execa('tar', ['-tzf', join(directory, 'package.tgz')])
expect(result.stdout).toContain('package/native/bin/darwin-arm64/dsh-cua-native')
expect(result.stdout).toContain('package/native/bin/darwin-arm64/current.json')
expect(result.stdout).toContain(`package/${nativePayload}`)
}
}
const requests: string[] = []
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { teo } from 'tencentcloud-sdk-nodejs-teo'
import { purgeDesktopInstaller, resolveDesktopPurgeConfig } from '../scripts/desktop-edgeone.ts'
const environment = {
DSH_DESKTOP_AUTO_UPDATE_ENV: 'production', DOWNLOAD_PROD_COS_BUCKET: 'fixture-bucket',
DOWNLOAD_PROD_COS_SECRET_ID: 'fixture-secret-id', DOWNLOAD_PROD_COS_SECRET_KEY: 'fixture-secret-key',
DOWNLOAD_PROD_EO_ZONE_ID: 'zone-fixture',
}
const config = resolveDesktopPurgeConfig(environment, 'mac-arm64')
afterEach(() => vi.restoreAllMocks())
describe('production EdgeOne configuration', () => {
it.each([
[{ DSH_DESKTOP_AUTO_UPDATE_ENV: 'test' }, /production is required/u],
[{ DSH_DESKTOP_AUTO_UPDATE_ENV: 'prod' }, /must be "test" or "production"/u],
[{ DOWNLOAD_PROD_EO_ZONE_ID: '' }, /DOWNLOAD_PROD_EO_ZONE_ID/u],
[{ DOWNLOAD_PROD_EO_ZONE_ID: 'invalid' }, /must name one EO zone/u],
[{ DOWNLOAD_PROD_COS_SECRET_KEY: '' }, /DOWNLOAD_PROD_COS_SECRET_KEY/u],
] as const)('rejects invalid settings %j', (patch, message) => {
expect(() => resolveDesktopPurgeConfig({ ...environment, ...patch }, 'mac-arm64')).toThrow(message)
})
it('uses the upload plan URL verbatim and derives only cache-only destinations', () => {
const url = 'https://download.deepseek.com/changed/installer.dmg'
expect(resolveDesktopPurgeConfig(environment, 'mac-arm64', url).publicUrl).toBe(url)
expect(resolveDesktopPurgeConfig(environment, 'win-x64').publicUrl).toBe('https://download.deepseek.com/desktop/dsh-latest-windows-x64.exe')
expect(resolveDesktopPurgeConfig(environment, 'mac-x64').publicUrl).toBe('https://download.deepseek.com/desktop/dsh-latest-macos-x64.dmg')
})
})
describe('EdgeOne purge submission', () => {
it('submits once with the pinned SDK and reports acceptance without querying completion', async () => {
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
const submit = vi.spyOn(teo.v20220901.Client.prototype, 'CreatePurgeTask')
.mockImplementation(async function (this: InstanceType<typeof teo.v20220901.Client>) {
expect(this.profile.httpProfile).toMatchObject({ reqMethod: 'POST', protocol: 'https://', reqTimeout: 30 })
return { JobId: 'job-1' }
})
const query = vi.spyOn(teo.v20220901.Client.prototype, 'DescribePurgeTasks').mockRejectedValue(new Error('must not query'))
await purgeDesktopInstaller(config)
expect(submit).toHaveBeenCalledExactlyOnceWith({ ZoneId: config.zoneId, Type: 'purge_url', Targets: [config.publicUrl] })
expect(query).not.toHaveBeenCalled()
expect(output).toHaveBeenCalledExactlyOnceWith(
`desktop purge: submitted ${config.publicUrl}; task job-1. Cache completion is not checked.\n`,
)
})
it('reports a rejected URL without printing the raw response or claiming acceptance', async () => {
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
const submit = vi.fn().mockResolvedValue({ JobId: 'job-1', FailedList: [{ Targets: [config.publicUrl], Reason: config.secretKey }] })
await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow(
'desktop purge: EO rejected the URL; inspect EO before submitting again.',
)
expect(submit).toHaveBeenCalledOnce()
expect(output).not.toHaveBeenCalled()
})
it.each(['ECONNRESET', 'UnauthorizedOperation', undefined, config.secretKey])('does not retry or expose SDK errors (%s)', async (code) => {
const submit = vi.fn().mockRejectedValue(Object.assign(new Error(config.secretKey), { code }))
await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow(
`desktop purge: submission not confirmed (${code === undefined || code === config.secretKey ? 'request failed' : code}); check EO task history and teo:CreatePurgeTask permission before submitting again.`,
)
expect(submit).toHaveBeenCalledOnce()
})
it.each([undefined, 'unsafe value', config.secretId])('reports an unconfirmed submission without a usable task ID (%s)', async (jobId) => {
const output = vi.spyOn(process.stdout, 'write').mockReturnValue(true)
const submit = vi.fn().mockResolvedValue({ JobId: jobId })
await expect(purgeDesktopInstaller(config, { CreatePurgeTask: submit })).rejects.toThrow(
'desktop purge: EO returned no task ID; check EO task history before submitting again.',
)
expect(submit).toHaveBeenCalledOnce()
expect(output).not.toHaveBeenCalled()
})
})
@@ -121,3 +121,17 @@ it('resolves an old locally retained package with publishRequested=false without
await expect(resolveRetainedDesktopPublication(env, api)).rejects.toThrow()
} finally { await rm(root, { recursive: true, force: true }) }
})
it('routes both Mac architectures to one machine without authorizing an unlisted target', () => {
const f = fixture()
const routes = { mini: { label: 'dsh-mini', target: ['mac-arm64', 'mac-x64'] } }
for (const path of ['macos-package.yml', 'macos-intel-package.yml']) {
f.run.path = `.github/workflows/${path}`
expect(selectDesktopPackage('owner/repo', { package_run_id: '42' }, routes, f.api))
.toMatchObject({ runnerName: 'mini', runnerLabel: 'dsh-mini' })
}
expect(() => selectDesktopPackage('owner/repo', { package_run_id: '42' }, f.routes, f.api)).toThrow(/no configured/u)
for (const target of [[], ['mac-x64', 'linux-x64']]) {
expect(() => selectDesktopPackage('owner/repo', { package_run_id: '42' }, { mini: { label: 'dsh-mini', target } }, f.api)).toThrow()
}
})

Some files were not shown because too many files have changed in this diff Show More