From d5ad3baeb5d531e31da5e586c3ec3ebedd825169 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 01:38:29 +0800 Subject: [PATCH 01/20] fix(sandbox): confine Windows deletes with a Low integrity label The write-restricted token's capability-SID intersection only covers the write bits of the object's own access check. Windows also authorizes a write or a delete from the parent directory's FILE_DELETE_CHILD right, and no restricting SID has to co-sign that authority: the confined child could therefore delete any file its ambient user SIDs control. `cmd /c del` outside the workspace succeeded under BOTH modes, and .NET File.Delete/DeleteFileW did too wherever the ambient DACL held Modify. Lower the restricted token to Low integrity and apply a Low no-write-up mandatory label to every granted directory in the same SetNamedSecurityInfoW call that adds its capability ACE. The kernel evaluates the integrity policy inside the access check whichever right supplied the authority, so the granted roots stay writable while everything else becomes delete-proof as well as write-proof. The label inherits to new children and follows the existing standing/revocable grant lifecycle. Also closes the Everyone-Modify ambient write boundary, and pins the delete paths (cmd, .NET, Remove-Item, libuv) in runner.spec.ts. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 29 +- .../sandbox/sandbox-windows-acl/README.zh.md | 25 +- .../sandbox/sandbox-windows-acl/src/acl.ts | 154 +++++-- .../sandbox/sandbox-windows-acl/src/ffi.ts | 6 +- .../sandbox/sandbox-windows-acl/src/grant.ts | 59 +-- .../sandbox/sandbox-windows-acl/src/index.ts | 27 +- .../sandbox/sandbox-windows-acl/src/token.ts | 29 ++ .../sandbox-windows-acl/src/win32-abi.ts | 27 ++ .../tests/acl-failure-paths.spec.ts | 379 ++++++++++++------ .../sandbox-windows-acl/tests/acl.spec.ts | 93 ++++- .../tests/grant-failure-paths.spec.ts | 24 +- .../tests/index-failure-paths.spec.ts | 17 +- .../sandbox-windows-acl/tests/runner.spec.ts | 73 +++- .../tests/token-failure-paths.spec.ts | 37 +- 18 files changed, 753 insertions(+), 246 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 596033c717..aa10470b1a 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 9834e9b559f0a8045b0d36dd2dcfba864238996b -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: d349b504ca6a1054237d5ca6aa61547c2a0dcdad +2026-08-08-windows-acl-restricted-token-sandbox.md: 1218d39271908cd09440646608088deb3ca64ed4 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 8b78fbe7132064f81a1f12f2c3a4e177b13337a1 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 9834e9b559..1218d39271 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Because Everyone remains, an external object granting Everyone write access clears both checks; because NTFS ACLs belong to file objects, a granted workspace hard link also grants an external alias. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The landstrip evaluation was rejected before implementation (not battle-tested; ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be path-confined by this token shape; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../bug-fix/2026-09-16-windows-subprocess-console-visibility.md) preserves console inheritance); standing workspace ACE mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets still writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; `whoami` and token-inspection cmdlets failing under the restricted token; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../bug-fix/2026-09-16-windows-subprocess-console-visibility.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. ## Testing -The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins real DACL lifecycle; and `runner.spec.ts` pins paired-SID validation, sibling temp denial under a shared workspace SID, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public denial, Everyone/hard-link partial boundaries, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. +The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins the real DACL and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, paired-SID validation, sibling temp denial under a shared workspace SID, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index d349b504ca..8b78fbe713 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该机制来自 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 仍在列表中,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 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 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。该机制来自 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) 交付。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher ## 后果 -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md)保留控制台继承);工作区常驻 ACE 改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标仍可写;NULL-DACL 目录在 grant/revoke 往返下不保持身份;`whoami` 与令牌检查 cmdlet 在受限令牌下失败;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 ## 测试 -产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL 生命周期;`runner.spec.ts` 钉住成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 拒绝、Everyone/硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 +产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL 与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 ## Related diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index ed39d664a1..e197dfeeef 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 30eaea27b9f8a0ebd1691348c9e6f87706891866 -README.zh.md: eeb65e5db1044eb75e0c13c5f3c76667432dc485 +README.md: 92e08b922717f1f548350c2aac26015ae23a250f +README.zh.md: 9aa2bcdf81f1c6df4a99b7590246b14d134c0d93 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 30eaea27b9..92e08b9227 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -On Windows, this package confines child-process writes to the workspace and a private temporary directory. `workspace-write` grants both locations, while `read-only` grants neither. Mounting `dsh-sandbox-local` selects this behavior automatically for confined bash and PowerShell commands, or callers can use the public `AclSandbox` API directly with captured standard streams. Any failed Win32 operation prevents the child from starting unrestricted. The guarantee is intentionally partial because process startup retains Everyone access and NTFS hard links can expose the same file through another path; callers can detect this limitation through the reported `partial` enforcement level. +On Windows, this package confines child-process writes and deletes to the workspace and a private temporary directory: `workspace-write` grants both, `read-only` grants neither. Mounting `dsh-sandbox-local` selects this for confined bash and PowerShell commands, or callers use the public `AclSandbox` API directly; any failed Win32 operation prevents an unrestricted spawn. Two layers enforce the boundary, the write-restricted token's capability-SID intersection and a Low integrity label on the granted directories, but the guarantee stays partial: process startup retains Everyone access, hard links alias file objects, and files ACL'd by another AppContainer tool stay unreadable. ## Table of Contents @@ -59,15 +59,15 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure +sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair rmSync(tempDir, { recursive: true, force: true }) ``` -The workspace ACEs are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SID. +The workspace security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying both the capability-SID allow ACE and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs. ### What confinement gives you -Under `workspace-write`, the child may write into the workspace and its private temp directory; other ACL-addressable writes are denied except the documented Everyone and hard-link boundaries. Under `read-only`, no explicit write grants exist, so writes are denied with the same documented ambient boundaries. +Under `workspace-write`, the child may write into the workspace and its private temp directory; other ACL-addressable writes and deletes are denied except the documented hard-link and AppContainer-ACL boundaries. Under `read-only`, no explicit write grants exist, so writes and deletes are denied with the same documented boundaries. Temp isolation is per live session/workspace pair: sessions sharing a workspace share its write authority but cannot write one another's temp directories. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. @@ -87,11 +87,11 @@ This section explains the restricted-token mechanism, the token lists, the runne ### Mechanism -The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. +The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control. The integrity label closes that route, because the mandatory check runs inside the access check whichever right supplied the authority, and every granted directory is labeled Low in the same call that adds its ACE. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root security descriptor edit materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. ### Modes and token lists -`workspace-write` (logon SID, Everyone, workspace SID, temp SID) grants the workspace and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing ACE keeps a re-upgrade free of re-propagation. +`workspace-write` (logon SID, Everyone, workspace SID, temp SID) grants the workspace and the session's private temp subdirectory separate Write grants; `read-only` (logon SID, Everyone — no write SID) grants none. Both modes spawn a Low-integrity token, and only labeled directories stay writable for it. The keep-alive group (logon SID + Everyone) is present in both modes: without it, early DLL initialization dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352`. The write SID stays out of the read-only list on purpose: the standing workspace grant from an earlier workspace-write period remains inert because the write-restricted pass-2 check grants only what the restricting list carries, while the standing security descriptor edit keeps a re-upgrade free of re-propagation. NUL writes are ambient, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in both modes. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, not the device DACL), while PowerShell's `> $null` redirection keeps working. @@ -111,15 +111,15 @@ When launched with the subprocess control marker, the runner forwards fd 7 throu ### Verified boundaries -- **Everyone grants remain ambient write authority** — Everyone must stay in both restricting lists (removing it breaks early DLL initialization and CNG); an external NTFS object whose DACL grants Everyone a requested write right clears both checks and stays writable under both modes. -- **Hard links are file-object aliases, not path aliases** — an inheritable workspace ACE propagated onto an existing hard link changes the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. -- **Writes are restricted; reads, network, and process visibility are not** — `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets; `read-only` therefore needs a read-side policy to be expressed. +- **Everyone stays in both restricting lists, but no longer confers write authority** — the keep-alive group is required for early DLL initialization and CNG; the Low label now denies an Everyone-granted write outside the labeled roots, so that former gap is closed. +- **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file and open sockets; `read-only` therefore needs a read-side policy to be expressed. +- **Hard links are file-object aliases, not path aliases** — an inheritable workspace grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. - **Console isolation is unavailable** — children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`); children share the host console, and pipe-based stdio redirection is unaffected. -- **ACL grants are standing directory mutations** — workspace ACEs stand by design (the reuse cache, never revoked); temp ACEs are revoked by `dispose()`; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. +- **Security descriptor edits are standing directory mutations** — workspace ACEs and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. - **Granted directories must be caller-owned** — the owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. - **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root. - **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories. -- **`whoami` and token-inspection cmdlets fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child, which is diagnostic noise rather than an operational failure. +- **`whoami` and token-inspection cmdlets run but report the reduced token** — `whoami /groups` succeeds and reports `S-1-16-4096`, which is the cheapest way to observe the active integrity level. ### Header verification and source map @@ -127,10 +127,10 @@ The sandbox-owned SID, ACL, token, file, and lock declarations are checked again | File | Role | |---|---| -| [`src/index.ts`](src/index.ts) | `AclSandbox`: restricted-token policy, DACL grants, fail-closed spawn and dispose | +| [`src/index.ts`](src/index.ts) | `AclSandbox`: restricted-token policy, DACL and label grants, fail-closed spawn and dispose | | [`src/runner.ts`](src/runner.ts) | The runner entry over shared Win32 process primitives | | [`src/grant.ts`](src/grant.ts) | `AclWriteGrant`: server-side grant materialization and revocation | -| [`src/token.ts`](src/token.ts) + [`src/acl.ts`](src/acl.ts) | Win32 token and DACL primitives behind the sandbox | +| [`src/token.ts`](src/token.ts) + [`src/acl.ts`](src/acl.ts) | Win32 token, DACL, and mandatory-label primitives behind the sandbox | @@ -173,7 +173,8 @@ These limits define when the backend is a poor fit or needs special operational - **Piped stdio capture is impossible for confined grandchildren** — libuv's pipe stdio uses named pipes, whose client-end open requests write access no restricting SID is granted (the Win32 layer's default SD template, not the token default DACL), so `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM; inherited and ignored stdio spawns work, and anonymous pipes (PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE. - **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-workspace identity pays it once per workspace per machine, and the exact-ACE skip makes every later provision cheap. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. -- **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable** — the UI-side warnings are not implemented, a FAT volume as a grant root fails loudly, and a FAT-class target outside the granted roots has no security descriptors so it stays writable under both confined modes; FAT is treated as legacy residue. +- **Reads stop at objects another AppContainer-based tool has ACL'd with a package SID** — Windows denies a Low-integrity token access to a file whose DACL carries an ACE for a package SID (`S-1-15-2-…`), even when that DACL also grants the user full control and Everyone read. Tools that sandbox themselves with AppContainers grant exactly such ACEs, so a project tree stamped by one of them becomes unreadable to this backend's child. Removing the foreign ACE (or re-installing the affected tree) restores access; the package cannot grant it back. +- **Wide-directory and FAT-volume warnings are deferred; the FAT-class residue is unverified** — the UI-side warnings are not implemented, a FAT volume as a grant root fails loudly, and a FAT-class target outside the granted roots stores no security descriptor; its effective integrity label is assigned by the system rather than recorded on the object, so the label layer's behavior there is untested. FAT stays legacy residue. - **PowerShell language mode differs by confined mode** — under `read-only`, PowerShell cannot create its AppLocker probe files in temp and conservatively starts in ConstrainedLanguage (`Add-Type`, non-core .NET static calls, COM, and reflection fail); the shipped `workspace-write` path lets the probe complete, so pwsh stays in FullLanguage unless host-wide WDAC/AppLocker policy says otherwise, while a direct `AclSandbox` with `tempDir: null` has no such guarantee. This split is PowerShell startup behavior, not part of the ACL write boundary. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index eeb65e5db1..9aa2bcdf81 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -在 Windows 上,本包将子进程的写入限制在工作区和私有临时目录内。`workspace-write` 授予对这两个位置的写入权限,`read-only` 则均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 和 PowerShell 命令会自动获得此行为;调用方也可以直接使用公开 `AclSandbox` API,并捕获标准流。任何 Win32 操作失败都会阻止子进程在不受限制的情况下启动。该保证特意标记为部分强制,因为进程启动会保留 Everyone 访问权限,NTFS 硬链接也可以通过其他路径暴露同一文件;调用方可通过报告的 `partial` 强制级别检测此限制。 +在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。边界由两层共同强制:受限令牌的能力 SID 交叉检查,以及授予目录上的 Low 完整性标签;但该保证仍为部分强制:进程启动保留 Everyone 访问,硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。 ## 目录 @@ -59,15 +59,15 @@ await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricte const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() -sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure +sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the standing workspace pair rmSync(tempDir, { recursive: true, force: true }) ``` -工作区 ACE 以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID。 +工作区的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE 与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。 ### 隔离给你带来什么 -在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。在 `read-only` 下不存在显式写入授权,因此写入会被拒绝,同样带有已记录的边界。 +在 `workspace-write` 下,子进程可以写入工作区及其私有临时目录;受 ACL 管辖的其他写入与删除都会被拒绝,已记录的硬链接与 AppContainer ACL 边界除外。在 `read-only` 下不存在显式写入授权,因此写入与删除都会被拒绝,同样带有已记录的边界。 临时隔离按每个活跃的会话/工作区对进行:共享工作区的会话共享其写权限,但无法写入彼此的临时目录。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。 @@ -87,11 +87,11 @@ rmSync(tempDir, { recursive: true, force: true }) ### 机制 -调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE 跳过。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 +调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌仍可删除其环境用户 SID 所控制的任何文件。完整性标签封堵了这条路径:无论权限来自哪一项,强制检查都在访问检查内部执行,而每个被授权目录都在添加 ACE 的同一次调用中被标记为 Low。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录的安全描述符改动每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE/精确标签跳过。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 ### 模式与令牌列表 -`workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID)为工作区与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻 ACE 让重新升级免于重新传播。 +`workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID)为工作区与会话的私有临时子目录分别授予 Write;`read-only`(登录 SID、Everyone——不含写入 SID)不授予任何内容。两种模式都 spawn 降级为 Low 完整性的令牌,对它而言只有被标记 Low 的目录保持可写。保活组(登录 SID + Everyone)在两种模式下都存在:没有它,早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃。写入 SID 有意留在 read-only 列表之外:先前 workspace-write 时期留下的常驻授权 ACE 仍然失效,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容,而常驻安全描述符改动让重新升级免于重新传播。 NUL 写入是环境性的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在两种模式下都能写。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,非设备 DACL 所致),而 PowerShell 的 `> $null` 重定向不受影响。 @@ -111,15 +111,15 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 ### 已验证边界 -- **Everyone 授权仍是环境中的写权限来源。** Everyone 必须保留在两种 restricting 列表中(移除它会破坏早期 DLL 初始化与 CNG);外部 NTFS 对象若其 DACL 向 Everyone 授予所请求的写权限,就会同时通过两次检查,并在两种模式下保持可写。 -- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区 ACE 会修改底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 -- **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字;`read-only` 因而需要读侧策略才能表达。 +- **Everyone 仍留在两种 restricting 列表中,但不再带来写权限。** 保活组是早期 DLL 初始化与 CNG 所必需的;如今 Low 标签会拒绝对被标记根目录之外、由 Everyone 授权的写入,因此这一旧缺口已关闭。 +- **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件并打开套接字;`read-only` 因而需要读侧策略才能表达。 +- **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 - **控制台隔离不可用。** 以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡;子进程共享宿主控制台,基于管道的 stdio 重定向不受影响。 -- **ACL 授权是对真实目录的驻留改动。** 工作区 ACE 按设计常驻(复用缓存,绝不撤销);临时 ACE 由 `dispose()` 撤销;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 +- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE 与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 - **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙箱无需提权即可编辑 DACL 的原因。 - **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。 - **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 -- **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,这是诊断噪音而非运行故障。 +- **`whoami` 与令牌检查 cmdlet 可以运行,但报告的是被削减的令牌。** `whoami /groups` 成功并报告 `S-1-16-4096`,这是观察当前完整性级别最简便的方式。 ### 头文件验证与源码索引 @@ -173,7 +173,8 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 - **受限孙进程的管道 stdio 捕获不可用。** libuv 的管道 stdio 用的是 NAMED pipe,其 client 端打开所请求的写访问没有任何 restricting SID 被授予(是 Win32 层的默认 SD 模板,而非令牌默认 DACL),因此受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败;继承与忽略 stdio 的 spawn 可用,匿名管道(PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE 而可用。 - **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(大型工作区树上以数十秒计);按工作区身份每台机器每个工作区只付一次,精确 ACE 跳过让后续每次供给都很便宜。 - **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 -- **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标没有安全描述符,因此在两种受限模式下都可写;FAT 被视为遗留残留。 +- **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,即使该 DACL 同时向用户授予完全控制、向 Everyone 授予读取,Windows 也会拒绝 Low 完整性的令牌访问它。以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的项目树对本后端的子进程将变得不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问;本包无法反向授予。 +- **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。 - **PowerShell 语言模式因受限模式而异。** 在 `read-only` 下,PowerShell 无法在临时目录中创建 AppLocker 探针文件,因此会保守地以 ConstrainedLanguage 启动(`Add-Type`、非核心 .NET 静态调用、COM 与反射失败);交付的 `workspace-write` 路径可让探针完成,因此除非主机范围的 WDAC/AppLocker 策略另有规定,否则 pwsh 保持 FullLanguage,而直接使用 `AclSandbox` 并配置 `tempDir: null` 时则没有这一保证。这一区别属于 PowerShell 启动行为,不是 ACL 写入边界的一部分。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index eded0f86a8..846670f489 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -5,6 +5,14 @@ * failure is reported with the API name, the exact Win32 code, the formatted * system text, and the affected path. * + * Each grant is a PAIR of edits applied in ONE SetNamedSecurityInfoW call: + * the capability-SID allow ACE in the DACL, and a Low mandatory label with the + * no-write-up policy in the label ACL ({@link buildLowLabelAcl}). The label + * denies write-class access to every object that is not labeled Low, and the + * kernel applies that policy inside the access check itself — including the + * parent directory's FILE_DELETE_CHILD authority, which the token's + * write-restricted intersection does not reach. + * * Concurrency: grants are read-merge-write against the directory's CURRENT * DACL, and the whole get-merge-set sequence runs under a per-path exclusive * LockFileEx lock (see {@link withPathLock}) so concurrent sandbox instances @@ -109,39 +117,108 @@ export function withPathLock(api: Win32Bindings, path: string, action: () => } /** - * Read the directory's current explicit DACL via GetNamedSecurityInfoW. + * Read the directory's current explicit DACL and mandatory label via + * GetNamedSecurityInfoW. * Allocation contract (the POC's RevokeAccess, minus its missing checks): the * returned ACL pointer sits INSIDE the security descriptor allocation — only * the descriptor may be LocalFree'd, and it must not be freed before * SetEntriesInAclW has consumed the ACL. Freeing the ACL pointer itself * corrupts the heap (verified the hard way). * @param api - the binding table. - * @param path - the directory whose DACL is read. - * @returns the current explicit DACL (null when the directory carries none) and its owning descriptor. + * @param path - the directory whose DACL and label are read. + * @returns the current explicit DACL and label ACL (null when the directory carries none) plus their owning descriptor. */ -function readCurrentDacl(api: Win32Bindings, path: string): { oldAcl: NativePtr | null; descriptor: NativePtr | null } { +function readCurrentSecurity( + api: Win32Bindings, + path: string, +): { oldAcl: NativePtr | null; labelAcl: NativePtr | null; descriptor: NativePtr | null } { const ownerSlot = allocPtrSlot() const groupSlot = allocPtrSlot() const daclSlot = allocPtrSlot() const saclSlot = allocPtrSlot() const descriptorSlot = allocPtrSlot() const readResult = api.getNamedSecurityInfoW( - path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION | abi.LABEL_SECURITY_INFORMATION, ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, ) if (readResult !== abi.ERROR_SUCCESS) throwWin32(api, 'GetNamedSecurityInfoW', readResult, path) - return { oldAcl: decodePtr(daclSlot), descriptor: decodePtr(descriptorSlot) } + return { oldAcl: decodePtr(daclSlot), labelAcl: decodePtr(saclSlot), descriptor: decodePtr(descriptorSlot) } +} + +/** + * Build the Low mandatory label applied with every write grant: one + * SYSTEM_MANDATORY_LABEL_ACE naming `lowLabelSidPtr` with the no-write-up + * policy, inheriting to subcontainers and objects so files created later + * inside the granted tree carry the same label automatically. + * + * The caller frees the returned ACL with LocalFree; SetNamedSecurityInfoW + * copies it. Fail-closed: every Win32 call is checked and the half-built ACL + * is released before the error is thrown. + * @param api - the binding table. + * @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096) the label names. + * @returns the ACL carrying the single inheritable label ACE. + */ +export function buildLowLabelAcl(api: Win32Bindings, lowLabelSidPtr: NativePtr): NativePtr { + const sidLength = api.getLengthSid(lowLabelSidPtr) + if (sidLength === 0) throwLastError(api, 'GetLengthSid', 'Low mandatory label SID') + const aclLength = abi.ACL_HEADER_SIZE + abi.MANDATORY_ACE_OVERHEAD + sidLength + const acl = api.localAlloc(abi.LPTR, aclLength) + if (isNullPtr(acl)) throwLastError(api, 'LocalAlloc', 'Low mandatory label ACL') + if (api.initializeAcl(acl, aclLength, abi.ACL_REVISION) === 0) { + const win32Code = api.getLastError() + api.localFree(acl) // best-effort on the error path + throwWin32(api, 'InitializeAcl', win32Code, 'Low mandatory label ACL') + } + if (api.addMandatoryAce( + acl, abi.ACL_REVISION, abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, lowLabelSidPtr, + ) === 0) { + const win32Code = api.getLastError() + api.localFree(acl) // best-effort on the error path + throwWin32(api, 'AddMandatoryAce', win32Code, 'Low mandatory label ACL') + } + return acl +} + +/** + * True when the label ACL already carries the EXACT label this module would + * add (mandatory-label ACE, OI|CI inheritance, no-write-up policy, the Low + * SID), so a re-grant can skip the eager full-tree propagation. The ACE + * layout matches {@link hasExactGrant} (header@0, Mask@4, inline SID@8), and a + * malformed header reads as "no exact label" so the caller falls back to the + * apply path, which owns the robust failure handling. + * @param labelAcl - the current label ACL pointer (from {@link readCurrentSecurity}). + * @param lowLabelSidPtr - the Low integrity SID to match. + * @returns whether the exact label ACE is already present. + */ +function hasExactLabel(labelAcl: NativePtr, lowLabelSidPtr: NativePtr): boolean { + const aclSize = decodeUint16At(labelAcl, 2) + const aceCount = decodeUint16At(labelAcl, 4) + if (aclSize < abi.ACL_HEADER_SIZE || aclSize > 1_048_576) return false // implausible: fall back to the apply path + let offset = abi.ACL_HEADER_SIZE // the first ACE follows the ACL header + for (let index = 0; index < aceCount; index++) { + const aceSize = decodeUint16At(labelAcl, offset + 2) + if (aceSize < abi.ACL_HEADER_SIZE || offset + aceSize > aclSize) return false // implausible: fall back to the apply path + const exact = decodeUint8At(labelAcl, offset) === abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE + && decodeUint8At(labelAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT + && decodeUint32At(labelAcl, offset + 4) === abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP + if (exact && sameSidAt(labelAcl, offset + 8, lowLabelSidPtr, 0)) return true + offset += aceSize + } + return false } /** * Shared tail of grantWrite and revokeWrite: merge `entry` into `oldAcl` * (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch), - * free the descriptor before applying the merged ACL, apply it, then free the - * merged ACL — checking every call and reporting with the caller's label. + * free the descriptor before applying the merged ACL, apply the merged DACL + * together with `labelAcl` in one SetNamedSecurityInfoW call (null clears the + * label), then free both ACLs — checking every call and reporting with the + * caller's label. * @param api - the binding table. - * @param path - the directory the DACL edit applies to. + * @param path - the directory the DACL and label edits apply to. * @param entry - the EXPLICIT_ACCESS_W to merge (grant or revoke). - * @param oldAcl - the current explicit DACL (from {@link readCurrentDacl}). + * @param oldAcl - the current explicit DACL (from {@link readCurrentSecurity}). + * @param labelAcl - the label ACL to apply, or null to clear the mandatory label. * @param descriptor - the descriptor allocation owning `oldAcl`. * @param label - the caller's name for error details. */ @@ -150,6 +227,7 @@ function mergeAndApply( path: string, entry: Buffer, oldAcl: NativePtr | null, + labelAcl: NativePtr | null, descriptor: NativePtr | null, label: string, ): void { @@ -169,13 +247,15 @@ function mergeAndApply( // before applying, exactly like the POC. const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null const applyResult = api.setNamedSecurityInfoW( - path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, - null, null, newAcl, null, + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION | abi.LABEL_SECURITY_INFORMATION, + null, null, newAcl, labelAcl, ) const freedNew = api.localFree(newAcl) + const freedLabel = labelAcl !== null ? api.localFree(labelAcl) : null if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`) if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `${label}(${path}) descriptor`) if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`) + if (freedLabel !== null && !isNullPtr(freedLabel)) throwLastError(api, 'LocalFree', `${label}(${path}) label ACL`) } /** @@ -214,50 +294,56 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { /** * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID - * on `path`, inheriting to subcontainers and objects. Idempotent: when the - * directory's current explicit DACL already carries the exact ACE (the - * per-session grant surviving from a previous server lifetime), the - * SetNamedSecurityInfoW apply is SKIPPED — it would otherwise re-propagate - * the identical ACE across the whole tree (eager inheritance; minutes on - * large workspaces). Otherwise read-merge-write: the new ACE merges into the - * directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so - * pre-existing explicit ACEs survive. Runs under the per-path lock. The - * directory must be owned by the caller (owner implicit WRITE_DAC) — same - * precondition as the POC. + * on `path`, inheriting to subcontainers and objects, and apply the Low + * mandatory label to the same directory in the same call. Idempotent: when the + * directory's current explicit DACL already carries the exact ACE AND its label + * ACL the exact label (the per-workspace grant surviving from a previous server + * lifetime), the SetNamedSecurityInfoW apply is SKIPPED — it would otherwise + * re-propagate the identical security descriptor across the whole tree (eager + * inheritance; minutes on large workspaces). Otherwise read-merge-write: the + * new ACE merges into the directory's CURRENT explicit DACL (same shape as + * {@link revokeWrite}), so pre-existing explicit ACEs survive. Runs under the + * per-path lock. The directory must be owned by the caller (owner implicit + * WRITE_DAC) — same precondition as the POC. * @param api - the binding table. - * @param path - the directory whose DACL gains the grant (the workspace or temp root). + * @param path - the directory whose DACL and label gain the grant (the workspace or temp root). * @param sidPtr - the capability SID the ACE names. + * @param lowLabelSidPtr - the Low integrity SID the mandatory label names. */ -export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { +export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr, lowLabelSidPtr: NativePtr): void { withPathLock(api, path, () => { - const { oldAcl, descriptor } = readCurrentDacl(api, path) - if (oldAcl !== null && hasExactGrant(oldAcl, sidPtr)) { - // The exact ACE stands: releasing the descriptor is the whole operation. + const { oldAcl, labelAcl, descriptor } = readCurrentSecurity(api, path) + if (oldAcl !== null && labelAcl !== null && hasExactGrant(oldAcl, sidPtr) && hasExactLabel(labelAcl, lowLabelSidPtr)) { + // The exact ACE and label stand: releasing the descriptor is the whole operation. if (descriptor !== null) { const freed = api.localFree(descriptor) if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path}) descriptor`) } return } - mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), oldAcl, descriptor, 'grantWrite') + mergeAndApply( + api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), + oldAcl, buildLowLabelAcl(api, lowLabelSidPtr), descriptor, 'grantWrite', + ) }) } /** * Remove every ACE for the capability SID from the directory DACL (REVOKE_ACCESS - * merge — other entries are preserved). Returns whether an ACE removal was - * attempted (false when the directory carries no DACL at all). + * merge — other entries are preserved) and clear the Low mandatory label in the + * same call. Returns whether an ACE removal was attempted (false when the + * directory carries no DACL at all). * * Runs under the per-path lock (the whole get-merge-set sequence); the - * descriptor/ACL allocation contract lives on {@link readCurrentDacl}. + * descriptor/ACL allocation contract lives on {@link readCurrentSecurity}. * @param api - the binding table. - * @param path - the directory whose DACL loses the capability-SID ACEs. + * @param path - the directory whose DACL loses the capability-SID ACEs and whose label is cleared. * @param sidPtr - the capability SID whose ACEs are removed. * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). */ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { return withPathLock(api, path, () => { - const { oldAcl, descriptor } = readCurrentDacl(api, path) + const { oldAcl, descriptor } = readCurrentSecurity(api, path) if (oldAcl === null) { if (descriptor !== null) { const freed = api.localFree(descriptor) @@ -265,7 +351,7 @@ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr) } return false } - mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, descriptor, 'revokeWrite') + mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, null, descriptor, 'revokeWrite') return true }) } diff --git a/packages/sandbox/sandbox-windows-acl/src/ffi.ts b/packages/sandbox/sandbox-windows-acl/src/ffi.ts index e65e1631d3..76308bb419 100644 --- a/packages/sandbox/sandbox-windows-acl/src/ffi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/ffi.ts @@ -60,6 +60,8 @@ export interface Win32Bindings extends Win32ProcessBindings { newToken: NativePtr, ): number setEntriesInAclW(count: number, entries: Buffer, oldAcl: NativePtr | null, newAcl: NativePtr): number + initializeAcl(acl: NativePtr, length: number, revision: number): number + addMandatoryAce(acl: NativePtr, revision: number, aceFlags: number, policy: number, sid: NativePtr): number setNamedSecurityInfoW( path: string, objectType: number, @@ -67,7 +69,7 @@ export interface Win32Bindings extends Win32ProcessBindings { owner: null, group: null, dacl: NativePtr | null, - sacl: null, + sacl: NativePtr | null, ): number getNamedSecurityInfoW( path: string, @@ -252,6 +254,8 @@ function bindings(): Win32Bindings { PVOID, 'uint32', 'uint32', PVOID, 'uint32', PVOID, 'uint32', PVOID, PPVOID, ]), setEntriesInAclW: bind(advapi32, 'SetEntriesInAclW', 'uint32', ['uint32', PVOID, PVOID, PPVOID]), + initializeAcl: bind(advapi32, 'InitializeAcl', 'int', [PVOID, 'uint32', 'uint32']), + addMandatoryAce: bind(advapi32, 'AddMandatoryAce', 'int', [PVOID, 'uint32', 'uint32', 'uint32', PVOID]), setNamedSecurityInfoW: bind(advapi32, 'SetNamedSecurityInfoW', 'uint32', [ 'str16', 'int', 'uint32', PVOID, PVOID, PVOID, PVOID, ]), diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index edb0345579..e31db0e05f 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -14,34 +14,40 @@ import { grantWrite, revokeWrite } from './acl.ts' import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' +import { makeWellKnownSid } from './token.ts' +import * as abi from './win32-abi.ts' /** * One write SID's provider-lifetime grant materialization: the parsed SID - * pointer plus every directory whose DACL currently carries its ACE. - * Workspace paths are added STANDING (their ACEs are the cross-session reuse - * cache and outlive the grant — dispose() skips revoking them, or the next + * pointer plus every directory whose DACL currently carries its ACE and whose + * label ACL carries the Low mandatory label. Workspace paths are added + * STANDING (their security descriptor edits are the cross-session reuse cache + * and outlive the grant — dispose() skips revoking them, or the next * provision would re-propagate the whole tree); temp paths are revocable * (dispose() revokes them — an inheritable ACE must not outlive its * session's temp directory). Create with {@link AclWriteGrant.create}; - * dispose revokes the revocable paths and frees the SID. + * dispose revokes the revocable paths and frees every SID. */ export class AclWriteGrant { /** The write SID in SDDL string form. */ readonly writeSid: string private readonly api: Win32Bindings private readonly sidPtr: NativePtr + private readonly lowLabelSidPtr: NativePtr private readonly revocablePaths: string[] = [] private readonly standingPaths: string[] = [] - private constructor(api: Win32Bindings, sidPtr: NativePtr, writeSid: string) { + private constructor(api: Win32Bindings, sidPtr: NativePtr, lowLabelSidPtr: NativePtr, writeSid: string) { this.api = api this.sidPtr = sidPtr + this.lowLabelSidPtr = lowLabelSidPtr this.writeSid = writeSid } /** - * Parse the SID string and open the binding table (lazily, once per - * server). Fail-closed: any failure throws — nothing is granted yet. + * Parse the SID string, create the Low integrity SID the grants label with, + * and open the binding table (lazily, once per server). Fail-closed: any + * failure throws — nothing is granted yet. * @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string. * @param api - optional already-resolved bindings (tests). * @returns the ready grant (no ACEs yet). @@ -54,26 +60,27 @@ export class AclWriteGrant { } const sidPtr = decodePtr(sidSlot) if (sidPtr === null) throwLastError(bindings, 'ConvertStringSidToSidW', `null SID for ${writeSid}`) - return new AclWriteGrant(bindings, sidPtr, writeSid) + return new AclWriteGrant(bindings, sidPtr, makeWellKnownSid(bindings, abi.WinLowLabelSid), writeSid) } /** - * Grant the write ACE on one directory (idempotent: an already-standing - * exact ACE skips the eager full-tree re-propagation — see - * {@link grantWrite}) and record the path for {@link dispose} unless it is - * standing. The path is recorded BEFORE the grant: a post-apply throw (a - * LocalFree failure after SetNamedSecurityInfoW succeeded) must still - * revoke it, and revoking an ungranted path is a no-op merge. Callers - * treat a throw as a failed materialization and dispose the instance to - * revoke the paths granted so far. - * @param path - the directory whose DACL gains the grant. - * @param standing - the ACE outlives this grant (the workspace reuse + * Grant the write ACE and the Low mandatory label on one directory + * (idempotent: an already-standing exact ACE plus exact label skips the + * eager full-tree re-propagation — see {@link grantWrite}) and record the + * path for {@link dispose} unless it is standing. The path is recorded + * BEFORE the grant: a post-apply throw (a LocalFree failure after + * SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an + * ungranted path is a no-op merge. Callers treat a throw as a failed + * materialization and dispose the instance to revoke the paths granted so + * far. + * @param path - the directory whose DACL and label gain the grant. + * @param standing - the edits outlive this grant (the workspace reuse * cache; dispose() skips revoking it). Default false (revoked on * dispose — the temp-directory lifecycle). */ add(path: string, standing = false): void { ;(standing ? this.standingPaths : this.revocablePaths).push(path) - grantWrite(this.api, path, this.sidPtr) + grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr) } /** Every directory currently carrying the grant, in grant order. */ @@ -81,7 +88,7 @@ export class AclWriteGrant { return [...this.standingPaths, ...this.revocablePaths] } - /** Revoke every revocable grant (standing ACEs stay) and free the SID; reports every cleanup failure. */ + /** Revoke every revocable grant (standing security descriptor edits stay) and free the SIDs; reports every cleanup failure. */ dispose(): void { const failures: unknown[] = [] for (const path of this.revocablePaths) { @@ -91,11 +98,13 @@ export class AclWriteGrant { failures.push(error) } } - try { - const freed = this.api.localFree(this.sidPtr) - if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', 'write SID') - } catch (error) { - failures.push(error) + for (const [label, sidPtr] of [['write SID', this.sidPtr], ['Low label SID', this.lowLabelSidPtr]] as const) { + try { + const freed = this.api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', label) + } catch (error) { + failures.push(error) + } } if (failures.length > 0) { throw new AggregateError(failures, `AclWriteGrant dispose completed with ${failures.length} cleanup failure(s)`) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 70e7661993..2760758ebb 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -10,7 +10,14 @@ * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, * and LOCAL are absent from both lists — see the seam's dual-list contract * in `packages/sandbox/sandbox-local` and the package README's Modes section - * for the complete boundary). The write SID is the per-WORKSPACE identity + * for the complete boundary). The intersection covers only the write bits of + * the object's OWN access check: Windows can also authorize a write or delete + * from the parent directory's FILE_DELETE_CHILD right, which no restricting + * SID has to co-sign. The token is therefore ALSO lowered to Low integrity + * and every granted directory carries a Low no-write-up label, so the + * kernel's mandatory-integrity check — evaluated inside the access check + * regardless of which right supplied the authority — confines the child to + * the labeled roots. The write SID is the per-WORKSPACE identity * ({@link workspaceWriteSid}): deterministic from the canonical workspace * path, so the workspace-root ACE materializes once per workspace per * machine and every later provision hits the exact-ACE skip — the @@ -49,7 +56,7 @@ import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi import type { NativePtr, Win32Bindings } from './ffi.ts' import { assertPrivateTempDisjoint } from './path-boundary.ts' import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' -import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts' +import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, restrictTokenIntegrity, setTokenDefaultDaclGrant } from './token.ts' import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' @@ -256,17 +263,22 @@ export class AclSandbox { // provision would re-propagate the whole tree) and the temp ACE is // REVOCABLE (dispose() removes it before the private directory is // deleted; the ambient temp root is never granted). + // The Low integrity SID every granted directory is labeled with and the + // confined token itself is lowered to. + const lowLabelSid = makeWellKnownSid(api, abi.WinLowLabelSid) + this.sidAllocations.push(lowLabelSid) + if (this.manageDacls) { if (this.writeSidPtr !== undefined) { for (const path of this.writableDirs) { - grantWrite(api, path, this.writeSidPtr) + grantWrite(api, path, this.writeSidPtr, lowLabelSid) } if (tempDir !== null && this.tempWriteSidPtr !== undefined) { // Record BEFORE granting: grantWrite can throw after a successful // apply (a LocalFree failure), and the fail-closed catch must still // revoke that path (revoking an ungranted path is a no-op merge). this.grantedPaths.push({ path: tempDir, sidPtr: this.tempWriteSidPtr }) - grantWrite(api, tempDir, this.tempWriteSidPtr) + grantWrite(api, tempDir, this.tempWriteSidPtr, lowLabelSid) } } } @@ -280,6 +292,13 @@ export class AclSandbox { { world: worldSid }, this.mode, ) + // Lower the confined token to Low integrity. The write-SID intersection + // does not reach the parent directory's FILE_DELETE_CHILD authority, so + // without the matching label a confined child could delete anything its + // ambient user SIDs may delete; the label plus this level close that + // route, and the granted directories stay writable because + // grantWrite labels them Low too. + restrictTokenIntegrity(api, restrictedToken, lowLabelSid) this.token = restrictedToken // The restricted token's default DACL still names only the user's // ambient SIDs — none of the restricting SIDs. Every NEW object the diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index f1f2befbdd..27e8fd848b 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -144,6 +144,35 @@ export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, s api.localFree(newDacl) } +/** + * Lower the restricted token's integrity level to Low (S-1-16-4096). + * + * `WRITE_RESTRICTED` intersects only the write bits of the access mask it + * evaluates against an object's DACL. Windows can also authorize a write or a + * delete from the PARENT directory's FILE_DELETE_CHILD right, and that + * authority is granted without the restricting-SID intersection — so a + * confined child could still delete files anywhere its ambient user SIDs hold + * Modify. The mandatory label closes that route: the kernel applies the + * integrity policy inside the access check, wherever the authority came from, + * so a Low child only writes where the object is labeled Low (see + * `grantWrite`). + * + * Requires TOKEN_ADJUST_DEFAULT on the token (the caller opens it with the + * right). Fails closed: a failure throws before any child is spawned. + * @param api - the binding table. + * @param token - the restricted token to lower. + * @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096). + */ +export function restrictTokenIntegrity(api: Win32Bindings, token: NativePtr, lowLabelSidPtr: NativePtr): void { + const sidLength = api.getLengthSid(lowLabelSidPtr) + const info = Buffer.alloc(abi.TOKEN_MANDATORY_LABEL_SIZE + sidLength) + info.writeBigUInt64LE(ptrAddress(lowLabelSidPtr), 0) // Label.Sid + info.writeUInt32LE(abi.SE_GROUP_INTEGRITY, 8) // Label.Attributes + if (api.setTokenInformation(token, abi.TokenIntegrityLevel, info, info.length) === 0) { + throwLastError(api, 'SetTokenInformation', 'TokenIntegrityLevel (Low)') + } +} + /** Pack `SID_AND_ATTRIBUTES[count]` (16-byte stride; Attributes stay 0). */ function buildRestrictingSids(sids: readonly NativePtr[]): Buffer { const buffer = Buffer.alloc(abi.SID_AND_ATTRIBUTES_SIZE * sids.length) diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 478fc71e1d..76a263e976 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -42,6 +42,33 @@ export const TokenGroups = 2 export const TokenDefaultDacl = 6 /** SECURITY_INFORMATION flag selecting the DACL. */ export const DACL_SECURITY_INFORMATION = 0x00000004 +/** SECURITY_INFORMATION flag selecting the mandatory integrity label. */ +export const LABEL_SECURITY_INFORMATION = 0x00000010 +/** ACE type carrying a mandatory integrity label. */ +export const SYSTEM_MANDATORY_LABEL_ACE_TYPE = 0x11 +/** + * Mandatory policy denying write-class access to higher-integrity objects. + * The kernel applies it inside the access check, so it also covers writes and + * deletes granted through a parent directory's FILE_DELETE_CHILD right — + * the path the write-restricted pass-2 intersection does not reach. + */ +export const SYSTEM_MANDATORY_LABEL_NO_WRITE_UP = 0x00000001 +/** TOKEN_INFORMATION_CLASS value for the token's integrity level. */ +export const TokenIntegrityLevel = 25 +/** Group attribute marking the integrity SID of a TOKEN_MANDATORY_LABEL. */ +export const SE_GROUP_INTEGRITY = 0x00000020 +/** WELL_KNOWN_SID_TYPE value for the Low mandatory level (S-1-16-4096). */ +export const WinLowLabelSid = 66 +/** x64 TOKEN_MANDATORY_LABEL byte size (the SID is referenced, not embedded). */ +export const TOKEN_MANDATORY_LABEL_SIZE = 16 +/** x64 ACL header byte size (AclRevision, Sbz1, AclSize, AceCount, Sbz2). */ +export const ACL_HEADER_SIZE = 8 +/** Bytes a SYSTEM_MANDATORY_LABEL_ACE occupies beyond the ACL header and its SID. */ +export const MANDATORY_ACE_OVERHEAD = 8 +/** ACL revision accepted by InitializeAcl and AddMandatoryAce. */ +export const ACL_REVISION = 2 +/** LocalAlloc flag selecting zero-initialized fixed memory (LMEM_FIXED | LMEM_ZEROINIT). */ +export const LPTR = 0x0040 /** SE_OBJECT_TYPE value for filesystem objects. */ export const SE_FILE_OBJECT = 1 /** TRUSTEE_TYPE value used when trustee classification is unknown. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index fce0c3562a..e93d6750cc 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -1,11 +1,11 @@ /** * ACL failure-path tests with minimal stub binding tables: every checked - * Win32 call in the lock, read-merge-write, and + * Win32 call in the lock, read-merge-write, mandatory-label, and * grant-skip sequence has a failing counterpart, and each failure closes the - * handles it created before throwing. The exact-ACE skip and the DACL-walk - * defenses are driven through crafted in-memory ACL/SID buffers. Pure - * stubs — no real Win32 calls, so these run on every platform; the - * real-FFI round-trip lives in acl.spec.ts (win32 only). + * handles it created before throwing. The exact-ACE/exact-label skip and the + * DACL/SACL-walk defenses are driven through crafted in-memory ACL/SID + * buffers. Pure stubs — no real Win32 calls, so these run on every platform; + * the real-FFI round-trip lives in acl.spec.ts (win32 only). */ import { tmpdir } from 'node:os' @@ -34,9 +34,10 @@ function aclApi(overrides: Partial = {}): Win32Bindings { closeHandle: vi.fn(() => 1), getNamedSecurityInfoW: vi.fn(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, ) => { koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(sacl, PVOID, 0n) // no mandatory label either koffi.encode(descriptor, PVOID, 0n) return 0 }), @@ -45,6 +46,10 @@ function aclApi(overrides: Partial = {}): Win32Bindings { return 0 }), setNamedSecurityInfoW: vi.fn(() => 0), + localAlloc: vi.fn(() => 11n), + getLengthSid: vi.fn(() => 12), + initializeAcl: vi.fn(() => 1), + addMandatoryAce: vi.fn(() => 1), localFree: vi.fn(() => 0n as NativePtr), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), @@ -63,22 +68,27 @@ function craftSid(revision: number, count: number, authority: number[] = [0, 0, return sid } +/** The Low integrity SID both the token and the directory labels name. */ +function craftLowLabelSid(): NativePtr { + return craftSid(1, 0, [0, 0, 0, 0, 0, 16]) +} + /** - * One in-memory ACL carrying the exact grant ACE the skip checks for: - * header (AclRevision@0, AclSize@2, AceCount@4) then one ACCESS_ALLOWED_ACE - * (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8). `match` selects - * whether the inline SID bytes equal `sid`. + * One in-memory ACL carrying a single inheritable ACE: header (AclRevision@0, + * AclSize@2, AceCount@4) then a 16-byte ACE (AceType@0, AceFlags@1, + * AceSize@2, Mask@4, inline SID@8). `match` selects whether the inline SID + * bytes equal `sid`. */ -function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { +function craftAcl(aceType: number, mask: number, sid: NativePtr, match: boolean, aceSize = 16, aclSize = 24, count = 1): NativePtr { const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) // AclRevision - koffi.encode(acl, 2, 'uint16', 24) // AclSize: 8-byte header + one 16-byte ACE - koffi.encode(acl, 4, 'uint16', 1) // AceCount + koffi.encode(acl, 2, 'uint16', aclSize) + koffi.encode(acl, 4, 'uint16', count) const ace = 8 - koffi.encode(acl, ace + 0, 'uint8', abi.ACCESS_ALLOWED_ACE_TYPE) + koffi.encode(acl, ace + 0, 'uint8', aceType) koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) - koffi.encode(acl, ace + 2, 'uint16', 16) // AceSize: header + mask + inline 8-byte SID - koffi.encode(acl, ace + 4, 'uint32', abi.GRANT_MASK) + koffi.encode(acl, ace + 2, 'uint16', aceSize) + koffi.encode(acl, ace + 4, 'uint32', mask) const inlineSid = ace + 8 for (let offset = 0; offset < 8; offset++) { koffi.encode(acl, inlineSid + offset, 'uint8', match @@ -88,6 +98,29 @@ function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { return acl } +/** The DACL the exact-grant skip looks for: the capability ACE with GRANT_MASK. */ +function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { + return craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, match) +} + +/** The label ACL the exact-label skip looks for: the Low no-write-up label ACE. */ +function craftAclWithLabel(sid: NativePtr, match: boolean): NativePtr { + return craftAcl(abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE, abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, sid, match) +} + +/** Encode a security-info read whose DACL and label ACL are the given pointers. */ +function readStub(dacl: NativePtr | null, label: NativePtr | null, descriptor: bigint | null) { + return vi.fn(( + _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, + daclSlot: NativePtr, saclSlot: NativePtr, descriptorSlot: NativePtr, + ) => { + koffi.encode(daclSlot, PVOID, dacl === null ? 0n : ptrAddress(dacl)) + koffi.encode(saclSlot, PVOID, label === null ? 0n : ptrAddress(label)) + koffi.encode(descriptorSlot, PVOID, descriptor ?? 0n) + return 0 + }) +} + describe('withPathLock failure paths', () => { it('fails closed when CreateFileW returns an invalid handle', () => { const api = aclApi({ createFileW: vi.fn(() => 0n as NativePtr) }) @@ -142,13 +175,67 @@ describe('withPathLock failure paths', () => { }) }) +describe('buildLowLabelAcl failure paths', () => { + it('reports a failed GetLengthSid for the Low label SID', () => { + const api = aclApi({ getLengthSid: vi.fn(() => 0) }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetLengthSid') + }) + + it('reports a failed LocalAlloc for the label ACL', () => { + const api = aclApi({ localAlloc: vi.fn(() => 0n as NativePtr) }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalAlloc') + }) + + it('frees the label ACL and reports when InitializeAcl fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ initializeAcl: vi.fn(() => 0), localFree }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('InitializeAcl') + expect(localFree).toHaveBeenCalledWith(11n) + }) + + it('frees the label ACL and reports when AddMandatoryAce fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ addMandatoryAce: vi.fn(() => 0), localFree }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('AddMandatoryAce') + expect(localFree).toHaveBeenCalledWith(11n) + }) +}) + describe('mergeAndApply failure paths', () => { it('reports a SetEntriesInAclW failure when the directory carries no descriptor to free', () => { const api = aclApi({ setEntriesInAclW: vi.fn(() => 5) }) // default descriptor: none const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } @@ -161,7 +248,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } @@ -172,21 +259,14 @@ describe('mergeAndApply failure paths', () => { it('frees the descriptor and reports when SetEntriesInAclW fails', () => { const localFree = vi.fn(() => 0n as NativePtr) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) // an existing explicit DACL - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), // an existing descriptor without a DACL setEntriesInAclW: vi.fn(() => 5), localFree, }) const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } @@ -198,21 +278,14 @@ describe('mergeAndApply failure paths', () => { it('frees the descriptor and reports a NULL merged ACL', () => { const localFree = vi.fn(() => 0n as NativePtr) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot localFree, }) const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } @@ -221,37 +294,31 @@ describe('mergeAndApply failure paths', () => { expect(localFree).toHaveBeenCalledWith(6n) }) - it('frees the merged ACL and reports when SetNamedSecurityInfoW fails', () => { + it('frees the merged ACL and the label ACL and reports when SetNamedSecurityInfoW fails', () => { const localFree = vi.fn(() => 0n as NativePtr) const api = aclApi({ setNamedSecurityInfoW: vi.fn(() => 5), localFree }) const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } expect(caught).toBeInstanceOf(Win32Error) expect((caught as Win32Error).api).toBe('SetNamedSecurityInfoW') - expect(localFree).toHaveBeenCalledWith(9n) + expect(localFree).toHaveBeenCalledWith(9n) // merged DACL + expect(localFree).toHaveBeenCalledWith(11n) // label ACL }) it('reports a failed descriptor LocalFree after a successful apply', () => { const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), localFree: vi.fn(() => 1n as NativePtr), // both frees "fail"; the first is checked }) const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } @@ -266,74 +333,80 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) } catch (error) { caught = error } expect(caught).toBeInstanceOf(Win32Error) expect((caught as Win32Error).api).toBe('LocalFree') }) + + it('reports a failed label-ACL LocalFree after a successful apply', () => { + // Every free succeeds until the label ACL's: descriptor and merged ACL + // return null, the label ACL returns a stale pointer. + const localFree = vi.fn() + .mockReturnValueOnce(0n) + .mockReturnValueOnce(0n) + .mockReturnValue(1n) + const api = aclApi({ + getNamedSecurityInfoW: readStub(null, null, 6n), + localFree, + }) + const sid = craftSid(1, 0) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('LocalFree') + expect(localFree).toHaveBeenLastCalledWith(11n) + }) }) -describe('the exact-ACE skip and DACL-walk defenses', () => { - it('grantWrite skips the apply when the standing exact ACE matches (descriptor freed, nothing merged)', () => { +describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { + it('grantWrite skips the apply when the standing exact ACE and the exact label match (descriptor freed, nothing merged)', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() const localFree = vi.fn(() => 0n as NativePtr) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, true), 6n), localFree, setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(localFree).toHaveBeenCalledWith(6n) }) - it('grantWrite skips the apply without freeing when the exact ACE stands but no descriptor owns it', () => { + it('grantWrite skips the apply without freeing when the exact ACE and label stand but no descriptor owns them', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() const localFree = vi.fn(() => 0n as NativePtr) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) - koffi.encode(descriptor, PVOID, 0n) // the read "returned" a bare ACL with no descriptor - return 0 - }), + // the read "returned" ACLs with no descriptor allocation of their own + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, true), null), localFree, setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(localFree).not.toHaveBeenCalled() }) - it('grantWrite reports a failed descriptor LocalFree on the exact-ACE skip path', () => { + it('grantWrite reports a failed descriptor LocalFree on the exact-ACE/exact-label skip path', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, true))) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, true), 6n), localFree: vi.fn(() => 1n as NativePtr), }) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid) } catch (error) { caught = error } @@ -341,48 +414,61 @@ describe('the exact-ACE skip and DACL-walk defenses', () => { expect((caught as Win32Error).api).toBe('LocalFree') }) - it('falls back to the merge path when the standing ACE names a different SID', () => { + it('does not skip when the standing ACE and label match but the label ACL is absent', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(craftAclWithGrant(sid, false))) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), null, 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not skip when the exact ACE stands but the label names another integrity level', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, false), 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('falls back to the merge path when the standing ACE names a different SID', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, false), craftAclWithLabel(lowSid, true), 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats an implausibly small ACL size as no exact grant', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) koffi.encode(acl, 2, 'uint16', 4) // smaller than the 8-byte ACL header koffi.encode(acl, 4, 'uint16', 1) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(acl)) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(acl, craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats an ACE that would overrun the ACL as no exact grant', () => { const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) koffi.encode(acl, 2, 'uint16', 8) // header only: no room for any ACE @@ -390,17 +476,76 @@ describe('the exact-ACE skip and DACL-walk defenses', () => { koffi.encode(acl, 10, 'uint16', 100) // the walk reads a lying ACE size const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, ptrAddress(acl)) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(acl, craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid) + grantWrite(api, 'C:\\granted', sid, lowSid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('treats an implausibly small label ACL size as no exact label', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const label = allocBytes(32) + koffi.encode(label, 'uint8', 2) + koffi.encode(label, 2, 'uint16', 4) // smaller than the 8-byte ACL header + koffi.encode(label, 4, 'uint16', 1) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), label, 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('treats a label ACE that would overrun its ACL as no exact label', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const label = allocBytes(32) + koffi.encode(label, 'uint8', 2) + koffi.encode(label, 2, 'uint16', 8) // header only: no room for any ACE + koffi.encode(label, 4, 'uint16', 1) + koffi.encode(label, 10, 'uint16', 100) // the walk reads a lying ACE size + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), label, 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not treat a no-write-up ACL Allow ACE as the mandatory label', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + // Same mask and SID, but an ACCESS_ALLOWED_ACE is not a mandatory label. + getNamedSecurityInfoW: readStub( + craftAclWithGrant(sid, true), + craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, lowSid, true), + 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not treat a label ACE granting write-up as the exact label', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub( + craftAclWithGrant(sid, true), + craftAcl(abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE, 0, lowSid, true), + 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) }) @@ -416,14 +561,7 @@ describe('revokeWrite no-DACL path', () => { it('frees a descriptor that carries no DACL and reports nothing to revoke', () => { const localFree = vi.fn(() => 0n as NativePtr) const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) // descriptor WITHOUT a DACL - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), // descriptor WITHOUT a DACL localFree, }) const sid = craftSid(1, 0) @@ -433,14 +571,7 @@ describe('revokeWrite no-DACL path', () => { it('reports a failed descriptor LocalFree on the no-DACL path', () => { const api = aclApi({ - getNamedSecurityInfoW: vi.fn(( - _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, - ) => { - koffi.encode(dacl, PVOID, 0n) - koffi.encode(descriptor, PVOID, 6n) - return 0 - }), + getNamedSecurityInfoW: readStub(null, null, 6n), localFree: vi.fn(() => 1n as NativePtr), }) const sid = craftSid(1, 0) diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 8d548a16df..3b2bb976e5 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -17,7 +17,7 @@ import koffi from 'koffi' import { buildExplicitAccess, grantWrite, lockFilePath, revokeWrite, withPathLock } from '../src/acl.ts' import { AclSandbox } from '../src/index.ts' -import { createRestrictedToken } from '../src/token.ts' +import { createRestrictedToken, makeWellKnownSid } from '../src/token.ts' import { allocOverlapped, allocPtrSlot, decodePtr, isInvalidHandle, isNullPtr, win32 } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import * as abi from '../src/win32-abi.ts' @@ -104,6 +104,54 @@ function readDirectAces(api: Win32Bindings, path: string): DirectAce[] { } } +/** One direct (explicit, non-inherited) label ACE of a directory's mandatory label. */ +interface DirectLabelAce { + sid: string + mask: number +} + +/** The Low integrity SID every grant labels with (S-1-16-4096). */ +function lowLabelSid(api: Win32Bindings): NativePtr { + return makeWellKnownSid(api, abi.WinLowLabelSid) +} + +/** + * Read the directory's mandatory label ACEs (inherited entries excluded) — + * same ACE layout as {@link readDirectAces}, read through the label security + * information class. A directory with no label returns an empty list. + */ +function readLabelAces(api: Win32Bindings, path: string): DirectLabelAce[] { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.LABEL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throw new Error(`GetNamedSecurityInfoW (label) failed (${readResult}) for ${path}`) + const acl = decodePtr(saclSlot) + const descriptor = decodePtr(descriptorSlot) + try { + if (acl === null) return [] + const aclSize = koffi.decode(acl, 2, 'uint16') as number + const aces: DirectLabelAce[] = [] + for (let offset = 8; offset + 8 <= aclSize;) { + const type = koffi.decode(acl, offset, 'uint8') as number + const flags = koffi.decode(acl, offset + 1, 'uint8') as number + const aceSize = koffi.decode(acl, offset + 2, 'uint16') as number + if (type === abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE && (flags & abi.INHERITED_ACE) === 0) { + aces.push({ sid: sidString(koffi.decode(acl, offset + 8, SID_STRUCT) as SidLayout), mask: koffi.decode(acl, offset + 4, 'uint32') as number }) + } + offset += aceSize + } + return aces + } finally { + if (descriptor !== null) api.localFree(descriptor) + } +} + describe.skipIf(!isWin32)('ACL editing', () => { const scratchDirs: string[] = [] afterEach(() => { @@ -121,6 +169,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { const dir = scratch() const usersSid = sidFromString(api, 'S-1-5-32-545') const capabilitySid = sidFromString(api, 'S-1-4-4242-1') + const lowSid = lowLabelSid(api) try { // Install one explicit ACE (Users + benign read mask) with the // package's own bindings, exactly like a pre-existing explicit DACL @@ -137,37 +186,61 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS) expect(isNullPtr(freed)).toBe(true) - grantWrite(api, dir, capabilitySid) + grantWrite(api, dir, capabilitySid, lowSid) revokeWrite(api, dir, capabilitySid) const aces = readDirectAces(api, dir) expect(aces.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved expect(aces.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(false) // orphan grant fully removed + expect(readLabelAces(api, dir)).toEqual([]) // the label went with it } finally { if (!isNullPtr(usersSid)) api.localFree(usersSid) if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) + if (!isNullPtr(lowSid)) api.localFree(lowSid) } }) - it('grantWrite is idempotent: a second grant over the standing exact ACE skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { + it('grantWrite applies the Low no-write-up label; revokeWrite clears it in the same edit', async () => { + const api = await win32() + const dir = scratch() + const capabilitySid = sidFromString(api, 'S-1-4-4242-9') + const lowSid = lowLabelSid(api) + try { + expect(readLabelAces(api, dir)).toEqual([]) + grantWrite(api, dir, capabilitySid, lowSid) + expect(readLabelAces(api, dir)).toEqual([ + { sid: 'S-1-16-4096', mask: abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP }, + ]) + revokeWrite(api, dir, capabilitySid) + expect(readLabelAces(api, dir)).toEqual([]) + } finally { + if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) + if (!isNullPtr(lowSid)) api.localFree(lowSid) + } + }) + + it('grantWrite is idempotent: a second grant over the standing exact ACE and label skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { const api = await win32() const dir = scratch() const capabilitySid = sidFromString(api, 'S-1-4-4242-2') + const lowSid = lowLabelSid(api) const apply = vi.spyOn(api, 'setNamedSecurityInfoW') try { - grantWrite(api, dir, capabilitySid) + grantWrite(api, dir, capabilitySid, lowSid) expect(apply).toHaveBeenCalledTimes(1) - // The exact ACE now stands (the per-session grant surviving from a - // previous server lifetime): the second grant is a DACL read only. - grantWrite(api, dir, capabilitySid) + // The exact ACE and label now stand (the per-session grant surviving + // from a previous server lifetime): the second grant is a read only. + grantWrite(api, dir, capabilitySid, lowSid) expect(apply).toHaveBeenCalledTimes(1) const aces = readDirectAces(api, dir) expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1) + expect(readLabelAces(api, dir)).toHaveLength(1) revokeWrite(api, dir, capabilitySid) expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false) } finally { apply.mockRestore() if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) + if (!isNullPtr(lowSid)) api.localFree(lowSid) } }) @@ -188,7 +261,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(aces.some(ace => ace.sid === 'S-1-4-9000-2')).toBe(true) }) - it('dispose revokes the revocable temp ACE and keeps the standing workspace ACE (self-managed flow)', async () => { + it('dispose revokes the revocable temp ACE and label and keeps the standing workspace pair (self-managed flow)', async () => { const api = await win32() const workspaceDir = scratch() const tempDir = scratch() @@ -203,8 +276,12 @@ describe.skipIf(!isWin32)('ACL editing', () => { sandbox.dispose() const workspaceAces = readDirectAces(api, workspaceDir) expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true) + expect(readLabelAces(api, workspaceDir)).toEqual([ + { sid: 'S-1-16-4096', mask: abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP }, + ]) const tempAces = readDirectAces(api, tempDir) expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3-1')).toBe(false) + expect(readLabelAces(api, tempDir)).toEqual([]) }) it('rejects an overlapping private temp directory before applying either capability', async () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts index e22a46060c..8be8b30a86 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -23,6 +23,12 @@ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { koffi.encode(slot, PVOID, 42n) return 1 }), + createWellKnownSid: vi.fn(() => 1), + isValidSid: vi.fn(() => 1), + getLengthSid: vi.fn(() => 12), + localAlloc: vi.fn(() => 11n), + initializeAcl: vi.fn(() => 1), + addMandatoryAce: vi.fn(() => 1), getTempPathW: vi.fn((_length: number, buffer: Buffer) => { const temp = tmpdir().endsWith('/') || tmpdir().endsWith('\\') ? tmpdir() : `${tmpdir()}/` buffer.write(temp, 'utf16le') @@ -34,10 +40,11 @@ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { closeHandle: vi.fn(() => 1), getNamedSecurityInfoW: vi.fn(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, ) => { if (state.failReads) return 2 // ERROR_FILE_NOT_FOUND — the revoke's read fails koffi.encode(dacl, PVOID, 0n) // no explicit DACL: the merge builds one + koffi.encode(sacl, PVOID, 0n) koffi.encode(descriptor, PVOID, 0n) return 0 }), @@ -90,6 +97,8 @@ describe('AclWriteGrant failure paths', () => { koffi.encode(slot, PVOID, 42n) return 1 }), + createWellKnownSid: vi.fn(() => 1), + isValidSid: vi.fn(() => 1), localFree: vi.fn(() => 1n), // non-NULL: LocalFree "failed" getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), @@ -97,4 +106,17 @@ describe('AclWriteGrant failure paths', () => { const grant = AclWriteGrant.create('S-1-4-42-42', api) expect(() =>{ grant.dispose() }).toThrow(AggregateError) }) + + it('create fails closed when the Low label SID cannot be created', () => { + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + createWellKnownSid: vi.fn(() => 0), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/CreateWellKnownSid/) + }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 50a4a9987e..95f639df88 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -35,6 +35,8 @@ interface HappyStubs { createRestrictedToken: MockFn createJobObjectW: MockFn getNamedSecurityInfoW: MockFn + setTokenInformation: MockFn + addMandatoryAce: MockFn } const state = vi.hoisted(() => ({ stubs: undefined as HappyStubs | undefined })) @@ -86,9 +88,10 @@ function happyStubs(): HappyStubs { const createFileW = vi.fn(() => fresh()) const getNamedSecurityInfoW = vi.fn(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, ) => { koffi.encode(dacl, PVOID, 0n) + koffi.encode(sacl, PVOID, 0n) koffi.encode(descriptor, PVOID, 0n) return 0 }) @@ -97,6 +100,9 @@ function happyStubs(): HappyStubs { return 0 }) const setNamedSecurityInfoW = vi.fn(() => 0) + const localAlloc = vi.fn(() => fresh()) + const initializeAcl = vi.fn(() => 1) + const addMandatoryAce = vi.fn(() => 1) const getTokenInformation = vi.fn((_token: unknown, cls: number, info: Buffer | null, _length: number, needed: NativePtr) => { if (info === null) { koffi.encode(needed, 'uint32', cls === abi.TokenGroups ? 24 : 8) @@ -158,6 +164,7 @@ function happyStubs(): HappyStubs { openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW, lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1), getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation, + localAlloc, initializeAcl, addMandatoryAce, getLengthSid, copySid, createWellKnownSid, isValidSid, createRestrictedToken, setTokenInformation, createPipe, setHandleInformation, createProcessAsUserW, peekNamedPipe, readFile, waitForSingleObject, getExitCodeProcess, createJobObjectW, @@ -167,7 +174,7 @@ function happyStubs(): HappyStubs { } as unknown as Win32Bindings return { api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree, - createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, + createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, setTokenInformation, addMandatoryAce, } } @@ -338,10 +345,11 @@ describe('AclSandbox init', () => { localFree.mockImplementation(() => (inCleanup ? 1n : 0n)) getNamedSecurityInfoW.mockImplementation(( _path: unknown, _type: unknown, _info: unknown, _owner: unknown, _group: unknown, - dacl: NativePtr, _sacl: unknown, descriptor: NativePtr, + dacl: NativePtr, sacl: NativePtr, descriptor: NativePtr, ) => { if (inCleanup) return 2 // the cleanup's revocation read fails too koffi.encode(dacl, PVOID, 0n) + koffi.encode(sacl, PVOID, 0n) koffi.encode(descriptor, PVOID, 0n) return 0 }) @@ -352,7 +360,8 @@ describe('AclSandbox init', () => { tempWriteSid: 'S-1-4-9000-10-1', mode: 'workspace-write', }) - await expect(sandbox.init()).rejects.toThrow(/5 cleanup operation\(s\) also failed/u) + // Five SID frees/mutations fail plus the Low label SID the init allocated. + await expect(sandbox.init()).rejects.toThrow(/6 cleanup operation\(s\) also failed/u) expect(sandbox.tempDir).toBeUndefined() }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index b0d364202b..a58ac01d82 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -78,10 +78,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", // The private-temp capability lets PowerShell complete its startup - // AppLocker probe, so without a host policy workspace-write stays in - // FullLanguage. Read-only cannot create those scratch files and fails - // that probe closed to ConstrainedLanguage (pinned below). + // AppLocker probe, so workspace-write stays in FullLanguage. Read-only + // cannot create those scratch files and fails that probe closed to + // ConstrainedLanguage (pinned below). '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', + // The integrity level the token was lowered to: the mandatory label on + // the granted directories is what makes them writable at this level. + "(whoami /groups | Select-String 'Mandatory Label') -replace '\\s+', ' ';", `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, "try{Set-Content -Path (Join-Path $env:TEMP 'child-wrote.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};", `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, @@ -98,6 +101,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) expect(result.stdout).toContain('LANGMODE: FullLanguage') + expect(result.stdout).toContain('S-1-16-4096') expect(result.stdout).toContain('TARGET-WRITE: OK') expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') @@ -395,19 +399,72 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) - it('partial boundary: an external Everyone-Modify directory stays writable under BOTH modes', () => { + it('delete-escape regression: deletes outside the granted roots are denied in BOTH modes, through every delete authority', () => { + // Windows authorizes a delete from EITHER the object's own DELETE right OR + // the parent directory's FILE_DELETE_CHILD right, and the + // WRITE_RESTRICTED pass-2 intersection only covers the first. Every + // ordinary directory the ambient user SIDs control (a project checkout, + // %TEMP%, the profile) grants FILE_DELETE_CHILD, so `cmd /c del` deleted + // files outside the workspace even though a plain write from the same + // child was denied. The Low mandatory label closes both routes because the + // kernel applies the integrity policy inside the access check, whichever + // right supplied the authority; the remaining delete paths below are the + // ones a confined child reaches for. + for (const mode of ['read-only', 'workspace-write'] as const) { + const victims = { + cmd: join(scratchRoot, `delete-${mode}-cmd.txt`), + dotnet: join(scratchRoot, `delete-${mode}-dotnet.txt`), + remove: join(scratchRoot, `delete-${mode}-remove.txt`), + unlink: join(scratchRoot, `delete-${mode}-unlink.txt`), + inside: join(writableDir, `delete-${mode}-inside.txt`), + } + for (const path of Object.values(victims)) writeFileSync(path, 'delete me') + const quoted = (path: string) => `'${path}'` + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `& '${process.env.SystemRoot ?? 'C:\\Windows'}\\System32\\cmd.exe' /c del /f /q ${quoted(victims.cmd)} 2>&1 | Out-Null;` + + `'CMD-DEL: ' + $(if (Test-Path -LiteralPath ${quoted(victims.cmd)}) {'DENIED'} else {'DELETED'});`, + `try{[System.IO.File]::Delete(${quoted(victims.dotnet)});'DOTNET-DELETE: DELETED'}catch{'DOTNET-DELETE: DENIED'};`, + `try{Remove-Item -LiteralPath ${quoted(victims.remove)} -ErrorAction Stop;'REMOVE-ITEM: DELETED'}catch{'REMOVE-ITEM: DENIED'};`, + `node -e "const fs=require('node:fs');try{fs.unlinkSync(process.argv[1]);console.log('NODE-UNLINK: DELETED')}catch(e){console.log('NODE-UNLINK: DENIED')}" ${quoted(victims.unlink)};`, + `try{[System.IO.File]::Delete(${quoted(victims.inside)});'INSIDE-DELETE: DELETED'}catch{'INSIDE-DELETE: DENIED'}`, + ].join('') + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('CMD-DEL: DENIED') + expect(result.stdout, `mode: ${mode}`).toContain('DOTNET-DELETE: DENIED') + expect(result.stdout, `mode: ${mode}`).toContain('REMOVE-ITEM: DENIED') + expect(result.stdout, `mode: ${mode}`).toContain('NODE-UNLINK: DENIED') + for (const path of [victims.cmd, victims.dotnet, victims.remove, victims.unlink]) { + expect(existsSync(path), `mode: ${mode}, ${path}`).toBe(true) + } + // The granted root keeps its own delete authority: read-only carries no + // write SID, so only workspace-write may delete there. + expect(result.stdout, `mode: ${mode}`).toContain( + mode === 'workspace-write' ? 'INSIDE-DELETE: DELETED' : 'INSIDE-DELETE: DENIED', + ) + expect(existsSync(victims.inside), `mode: ${mode}`).toBe(mode === 'read-only') + } + }, 60_000) + + it('the Low mandatory label closes the Everyone-Modify ambient boundary under BOTH modes', () => { // Everyone is a required keep-alive restricting SID: without it early DLL // initialization and CNG fail. A normal DACL that grants Everyone Modify - // therefore also clears the WRITE_RESTRICTED pass-2 check. Pin this - // unavoidable gap beside the provider's `partial` enforcement report. + // therefore also clears the WRITE_RESTRICTED pass-2 check. The Low label + // still denies the write, because the kernel evaluates the integrity + // policy inside the access check regardless of which right supplied the + // authority. for (const mode of ['read-only', 'workspace-write'] as const) { const target = join(worldWritableDir, `${mode}.txt`) const result = runRunner([ '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'written')", target, ]) - expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) - expect(existsSync(target), `mode: ${mode}`).toBe(true) + expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).not.toBe(0) + expect(existsSync(target), `mode: ${mode}`).toBe(false) } }, 30_000) diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts index 6149d75277..36f18ff03d 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -15,7 +15,7 @@ import koffi from 'koffi' import { allocBytes, isNullPtr } from '../src/ffi.ts' import type { NativePtr, Win32Bindings } from '../src/ffi.ts' import { - createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant, + createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, restrictTokenIntegrity, setTokenDefaultDaclGrant, } from '../src/token.ts' import * as abi from '../src/win32-abi.ts' @@ -371,6 +371,41 @@ describe('setTokenDefaultDaclGrant failure paths', () => { }) }) +describe('restrictTokenIntegrity', () => { + it('sets TokenIntegrityLevel to the Low label SID with SE_GROUP_INTEGRITY', () => { + const lowSid = allocBytes(12) + const setTokenInformation = vi.fn(( + _token: unknown, cls: number, info: Buffer, length: number, + ) => { + expect(cls).toBe(abi.TokenIntegrityLevel) + expect(length).toBe(abi.TOKEN_MANDATORY_LABEL_SIZE + 12) + expect(info.readBigUInt64LE(0)).toBe(koffi.address(lowSid)) + expect(info.readUInt32LE(8)).toBe(abi.SE_GROUP_INTEGRITY) + return 1 + }) + const api = { getLengthSid: vi.fn(() => 12), setTokenInformation } as unknown as Win32Bindings + restrictTokenIntegrity(api, 5n as NativePtr, lowSid) + expect(setTokenInformation).toHaveBeenCalledTimes(1) + }) + + it('fails closed when SetTokenInformation rejects the integrity level', () => { + const api = { + getLengthSid: vi.fn(() => 12), + setTokenInformation: vi.fn(() => 0), + getLastError: vi.fn(() => 5), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + restrictTokenIntegrity(api, 5n as NativePtr, allocBytes(12)) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetTokenInformation') + }) +}) + describe('createRestrictedToken failure paths', () => { it('builds the read-only restricting list without a write SID', () => { const create = vi.fn(( From 36e632751e9684b3aad7f174488953ae5a41db85 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 02:06:30 +0800 Subject: [PATCH 02/20] fix(sandbox): deny the ambient parent-delete right inside granted roots The Low integrity label confines writes and deletes to the labeled roots, but every granted root is labeled Low, so the integrity check alone still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory): Windows authorizes a delete from the parent directory's FILE_DELETE_CHILD right, which the write-restricted intersection never reaches. Each grant now also denies FILE_DELETE_CHILD to the world SID in the same SetNamedSecurityInfoW call, so the capability ACE's DELETE bit is the only delete authority inside a granted root. The idempotent skip additionally requires the exact deny, so a root granted by an earlier build receives it on its next provision. Delete paths inside the roots keep working; a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, for the confined child and for the user's own processes alike. --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 6 +- ...windows-acl-restricted-token-sandbox.zh.md | 6 +- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 11 +- .../sandbox/sandbox-windows-acl/README.zh.md | 11 +- .../sandbox/sandbox-windows-acl/src/acl.ts | 156 +++++++++---- .../sandbox/sandbox-windows-acl/src/grant.ts | 43 +++- .../sandbox/sandbox-windows-acl/src/index.ts | 14 +- .../sandbox-windows-acl/src/win32-abi.ts | 4 + .../tests/acl-failure-paths.spec.ts | 220 +++++++++++++----- .../sandbox-windows-acl/tests/acl.spec.ts | 38 ++- .../sandbox-windows-acl/tests/runner.spec.ts | 65 ++++++ 13 files changed, 432 insertions(+), 150 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index aa10470b1a..43bc59bf13 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 1218d39271908cd09440646608088deb3ca64ed4 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 8b78fbe7132064f81a1f12f2c3a4e177b13337a1 +2026-08-08-windows-acl-restricted-token-sandbox.md: 2ab157c697d8917f2e3c6315e5ef161ce6eb5229 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10cac08c3161c6c723414c4274333af4eebc36f3 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 1218d39271..2ab157c697 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -10,7 +10,7 @@ The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. That intersection covers only the object's own access check, and Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right — which no restricting SID has to co-sign, so `cmd /c del` deleted files outside the workspace wherever the caller's ambient SIDs hold Modify. The token is therefore also lowered to Low integrity and every granted directory receives a Low no-write-up mandatory label in the same call that adds its ACE; the kernel applies that policy inside the access check, whichever right supplied the authority, so the granted roots stay writable and everything else becomes delete-proof as well as write-proof. Because every granted root is labeled Low, the integrity check alone would still let one confined child delete inside ANOTHER granted root (another workspace, a live sibling temp directory), so each grant also DENIES `FILE_DELETE_CHILD` to the world SID: the capability ACE's DELETE bit becomes the only delete authority inside a granted root, for the confined child and for the user's own processes, whose ordinary deletes keep working because the same DACL grants them DELETE directly. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Everyone remains in both lists but no longer confers write authority, because the label layer denies it; because NTFS ACLs belong to file objects, a granted workspace hard link still grants an external alias; and because a Low-integrity token cannot open an object whose DACL names another sandbox's AppContainer package SID, a tree stamped by such a tool becomes unreadable to the confined child. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.md) as the confining executor. ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ The landstrip evaluation was rejected before implementation (not battle-tested; ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../bug-fix/2026-09-16-windows-subprocess-console-visibility.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades; mandatory integrity control predates it as well), reads/network/process visibility untouched exactly as the mode vocabulary requires, deletes confined through every authority Windows accepts and confined to the granted root that owns them, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because NTFS hard-link aliases cannot be path-confined by this token shape and because a Low-integrity token cannot open a file whose DACL carries another sandbox's AppContainer package SID, which makes a tree stamped by such a tool unreadable to the confined child; no read-side or network isolation; console isolation unavailable (children created with `CREATE_NO_WINDOW` or `CREATE_NEW_CONSOLE` die with `STATUS_DLL_INIT_FAILED`; [startup visibility](../bug-fix/2026-09-16-windows-subprocess-console-visibility.md) preserves console inheritance); standing workspace security-descriptor mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets unverified rather than writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. ## Testing -The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins the real DACL and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, paired-SID validation, sibling temp denial under a shared workspace SID, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. +The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins the real DACL, ambient-delete deny, and mandatory-label lifecycle; and `runner.spec.ts` pins the Low integrity level, paired-SID validation, sibling temp denial under a shared workspace SID, the outside-delete regression through `cmd`, .NET, `Remove-Item`, and libuv, the cross-root delete regression against another workspace and another live temp tree, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public and Everyone denial, the hard-link partial boundary, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 8b78fbe713..10cac08c31 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -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 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。该机制来自 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) 交付。 ## How the restriction works (why no new identity) @@ -32,11 +32,11 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher ## 后果 -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年;强制完整性控制出现得更早)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、删除在 Windows 接受的每条授权路径上都被约束且被约束在各自的授权根目录内,且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 NTFS 硬链接别名限制在路径边界内,而 Low 完整性的令牌也无法打开 DACL 中携带其他沙箱 AppContainer 包 SID 的文件——被这类工具标记过的目录树对受限子进程将不可读;无读侧或网络隔离;控制台隔离不可用(通过 `CREATE_NO_WINDOW` 或 `CREATE_NEW_CONSOLE` 创建的子进程以 `STATUS_DLL_INIT_FAILED` 死亡;[启动可见性](../bug-fix/2026-09-16-windows-subprocess-console-visibility.zh.md)保留控制台继承);工作区常驻安全描述符改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标的行为未经验证而非「仍可写」;NULL-DACL 目录在 grant/revoke 往返下不保持身份;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 ## 测试 -产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL 与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 +产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL、环境性删除拒绝与强制标签生命周期;`runner.spec.ts` 钉住 Low 完整性级别、成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、经 `cmd`、.NET、`Remove-Item` 与 libuv 触发的越界删除回归、针对另一个工作区与另一棵活跃临时目录树的跨根删除回归、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 与 Everyone 拒绝、硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 ## Related diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index e197dfeeef..813c667920 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 92e08b922717f1f548350c2aac26015ae23a250f -README.zh.md: 9aa2bcdf81f1c6df4a99b7590246b14d134c0d93 +README.md: 94d49d76437799b19cb2468e96a020589058d624 +README.zh.md: 7709ddcf4f6d609fbce953946a532952b90ea620 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 92e08b9227..94d49d7643 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -9,7 +9,7 @@ English | [中文](README.zh.md) ## Summary -On Windows, this package confines child-process writes and deletes to the workspace and a private temporary directory: `workspace-write` grants both, `read-only` grants neither. Mounting `dsh-sandbox-local` selects this for confined bash and PowerShell commands, or callers use the public `AclSandbox` API directly; any failed Win32 operation prevents an unrestricted spawn. Two layers enforce the boundary, the write-restricted token's capability-SID intersection and a Low integrity label on the granted directories, but the guarantee stays partial: process startup retains Everyone access, hard links alias file objects, and files ACL'd by another AppContainer tool stay unreadable. +On Windows, this package confines child-process writes and deletes to the workspace and a private temporary directory: `workspace-write` grants both, `read-only` grants neither. Mounting `dsh-sandbox-local` selects this for confined bash and PowerShell commands, or callers use the public `AclSandbox` API directly; any failed Win32 operation prevents an unrestricted spawn. Each grant combines a capability-SID allow ACE, a deny of the ambient parent-directory delete right, and a Low integrity label the lowered token must match, so one granted root cannot reach another. The guarantee stays partial: hard links alias file objects and files ACL'd by another AppContainer tool stay unreadable. ## Table of Contents @@ -63,7 +63,7 @@ sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the sta rmSync(tempDir, { recursive: true, force: true }) ``` -The workspace security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying both the capability-SID allow ACE and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs. +The workspace security descriptor edits are granted standing — `dispose()` leaves them, because they are the cross-instance reuse cache — while the distinct temp SID is granted revocably. Each grant is one call carrying the capability-SID allow ACE, the ambient-delete deny, and the Low no-write-up label. The server-side counterpart is the `AclWriteGrant` class: `add(path, standing)` per directory, and `dispose()` revokes the revocable paths and frees the SIDs. ### What confinement gives you @@ -87,7 +87,7 @@ This section explains the restricted-token mechanism, the token lists, the runne ### Mechanism -The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control. The integrity label closes that route, because the mandatory check runs inside the access check whichever right supplied the authority, and every granted directory is labeled Low in the same call that adds its ACE. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root security descriptor edit materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. +The caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities, and that token is lowered to Low integrity. Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass; separately, the kernel's mandatory-integrity check denies write-class access to every object that is not labeled Low. The write-SID intersection covers only the object's OWN access check: Windows can also authorize a write or a delete from the parent directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign, so a token holding only the intersection could still delete anything its ambient user SIDs control — including files inside ANOTHER granted root, whose Low label clears the integrity check. Each grant therefore also denies `FILE_DELETE_CHILD` to the world SID, leaving the capability ACE's DELETE bit as the only delete authority inside the granted roots, and labels the same directory Low in the one `SetNamedSecurityInfoW` call that applies all three edits. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root security descriptor edit materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE/exact-deny/exact-label skip. Each live session/workspace pair instead receives a random private temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Every policy-specific Win32 call and every process primitive from [`dsh-win32-process`](../../subprocess/win32-process/README.md) is checked; failures throw `Win32Error` carrying the API name, exact code, system text, and failing context — fail-closed by construction. ### Modes and token lists @@ -112,10 +112,11 @@ When launched with the subprocess control marker, the runner forwards fd 7 throu ### Verified boundaries - **Everyone stays in both restricting lists, but no longer confers write authority** — the keep-alive group is required for early DLL initialization and CNG; the Low label now denies an Everyone-granted write outside the labeled roots, so that former gap is closed. -- **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file and open sockets; `read-only` therefore needs a read-side policy to be expressed. +- **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working because the workspace DACL grants them DELETE directly. +- **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file (including files in another workspace) and open sockets; `read-only` therefore needs a read-side policy to be expressed. - **Hard links are file-object aliases, not path aliases** — an inheritable workspace grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. - **Console isolation is unavailable** — children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`); children share the host console, and pipe-based stdio redirection is unaffected. -- **Security descriptor edits are standing directory mutations** — workspace ACEs and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. +- **Security descriptor edits are standing directory mutations** — workspace ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, and the deny a revoke leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. - **Granted directories must be caller-owned** — the owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. - **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root. - **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 9aa2bcdf81..7709ddcf4f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -9,7 +9,7 @@ kind: "package-library" ## 概述 -在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。边界由两层共同强制:受限令牌的能力 SID 交叉检查,以及授予目录上的 Low 完整性标签;但该保证仍为部分强制:进程启动保留 Everyone 访问,硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。 +在 Windows 上,本包将子进程的写入与删除限制在工作区和私有临时目录内:`workspace-write` 授予这两处,`read-only` 均不授予。挂载 `dsh-sandbox-local` 后,受限的 bash 与 PowerShell 命令即获得此行为;调用方也可以直接使用公开 `AclSandbox` API;任何 Win32 操作失败都会阻止不受限制的 spawn。每次授权同时写入能力 SID 允许 ACE、对父目录删除权限的环境性拒绝,以及被降级令牌必须匹配的 Low 完整性标签,因此一个授权根目录无法触及另一个。该保证仍为部分强制:硬链接是文件对象别名,而被其他 AppContainer 工具 ACL 过的文件不可读。 ## 目录 @@ -63,7 +63,7 @@ sandbox.dispose() // revokes the revocable (temp) grant and label, keeps the sta rmSync(tempDir, { recursive: true, force: true }) ``` -工作区的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE 与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。 +工作区的安全描述符改动以常驻方式授予——`dispose()` 保留它们,因为它们是跨实例的复用缓存——而不同的临时 SID 以可回收方式授予。每次授权就是一次调用,同时携带能力 SID 允许 ACE、环境性删除拒绝与 Low 禁止上调标签。服务端对应实现是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放各 SID。 ### 隔离给你带来什么 @@ -87,7 +87,7 @@ rmSync(tempDir, { recursive: true, force: true }) ### 机制 -调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌仍可删除其环境用户 SID 所控制的任何文件。完整性标签封堵了这条路径:无论权限来自哪一项,强制检查都在访问检查内部执行,而每个被授权目录都在添加 ACE 的同一次调用中被标记为 Low。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录的安全描述符改动每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE/精确标签跳过。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 +调用者令牌被复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区与私有临时目录能力,该令牌还会被降级为 Low 完整性。Windows 执行两次访问检查——先对正常 SID,再对 restricting SID——并且只在两次检查都通过时才授予写类访问;与此同时,内核的强制完整性检查会拒绝对任何未标记为 Low 的对象进行写类访问。写 SID 交叉检查只覆盖对象自身的那次访问检查:Windows 也可以依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署,因此只带交叉检查的令牌不仅能删除其环境用户 SID 所控制的任何文件,还能删除**另一个授权根目录**内的文件——那里的 Low 标签恰好能通过完整性检查。所以每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`,使能力 ACE 的 DELETE 位成为授权根目录内唯一的删除授权来源,并在同一次 `SetNamedSecurityInfoW` 调用中把该目录标记为 Low。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录的安全描述符改动每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE/精确拒绝/精确标签跳过。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。每个策略专用 Win32 调用和 [`dsh-win32-process`](../../subprocess/win32-process/README.zh.md) 提供的进程原语都有检查;失败抛出携带 API 名、精确错误码、系统文本与失败上下文的 `Win32Error`——从构造上 fail-closed。 ### 模式与令牌列表 @@ -112,10 +112,11 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 ### 已验证边界 - **Everyone 仍留在两种 restricting 列表中,但不再带来写权限。** 保活组是早期 DLL 初始化与 CNG 所必需的;如今 Low 标签会拒绝对被标记根目录之外、由 Everyone 授权的写入,因此这一旧缺口已关闭。 -- **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件并打开套接字;`read-only` 因而需要读侧策略才能表达。 +- **在授权根目录内,能力 ACE 的 DELETE 位是唯一的删除授权来源。** 授权会向 world SID 拒绝 `FILE_DELETE_CHILD`,这同时移除了环境性默认行为:自身 DACL 未授予 DELETE 的文件不再能凭父目录权限删除——受限子进程与用户自身进程皆然。用户日常删除仍然可用,因为工作区 DACL 直接向其授予 DELETE。 +- **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件(包括其他工作区中的文件)并打开套接字;`read-only` 因而需要读侧策略才能表达。 - **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 - **控制台隔离不可用。** 以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡;子进程共享宿主控制台,基于管道的 stdio 重定向不受影响。 -- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE 与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 +- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销,撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 - **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙箱无需提权即可编辑 DACL 的原因。 - **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。 - **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index 846670f489..f9ce88d700 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -6,12 +6,16 @@ * system text, and the affected path. * * Each grant is a PAIR of edits applied in ONE SetNamedSecurityInfoW call: - * the capability-SID allow ACE in the DACL, and a Low mandatory label with the - * no-write-up policy in the label ACL ({@link buildLowLabelAcl}). The label - * denies write-class access to every object that is not labeled Low, and the - * kernel applies that policy inside the access check itself — including the - * parent directory's FILE_DELETE_CHILD authority, which the token's - * write-restricted intersection does not reach. + * a Deny ACE removing the ambient parent-directory delete right + * (`Everyone` / `FILE_DELETE_CHILD`) plus the capability-SID allow ACE in the + * DACL, and a Low mandatory label with the no-write-up policy in the label + * ACL ({@link buildLowLabelAcl}). The deny makes the capability ACE's DELETE + * bit the only delete authority inside the granted roots — Windows otherwise + * authorizes a delete from the parent directory's `FILE_DELETE_CHILD`, which + * the token's write-restricted intersection does not reach, so one confined + * child could delete files in another granted root. The label denies + * write-class access to every object that is not labeled Low, and the kernel + * applies that policy inside the access check itself. * * Concurrency: grants are read-merge-write against the directory's CURRENT * DACL, and the whole get-merge-set sequence runs under a per-path exclusive @@ -208,15 +212,16 @@ function hasExactLabel(labelAcl: NativePtr, lowLabelSidPtr: NativePtr): boolean } /** - * Shared tail of grantWrite and revokeWrite: merge `entry` into `oldAcl` + * Shared tail of grantWrite and revokeWrite: merge `entries` into `oldAcl` * (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch), * free the descriptor before applying the merged ACL, apply the merged DACL * together with `labelAcl` in one SetNamedSecurityInfoW call (null clears the * label), then free both ACLs — checking every call and reporting with the - * caller's label. + * caller's label. The entry count derives from the buffer, so a grant can + * carry its capability ACE and its ambient-delete deny in one merge. * @param api - the binding table. * @param path - the directory the DACL and label edits apply to. - * @param entry - the EXPLICIT_ACCESS_W to merge (grant or revoke). + * @param entries - packed EXPLICIT_ACCESS_W records to merge (grant, deny, or revoke). * @param oldAcl - the current explicit DACL (from {@link readCurrentSecurity}). * @param labelAcl - the label ACL to apply, or null to clear the mandatory label. * @param descriptor - the descriptor allocation owning `oldAcl`. @@ -225,14 +230,14 @@ function hasExactLabel(labelAcl: NativePtr, lowLabelSidPtr: NativePtr): boolean function mergeAndApply( api: Win32Bindings, path: string, - entry: Buffer, + entries: Buffer, oldAcl: NativePtr | null, labelAcl: NativePtr | null, descriptor: NativePtr | null, label: string, ): void { const newAclSlot = allocPtrSlot() - const mergeResult = api.setEntriesInAclW(1, entry, oldAcl, newAclSlot) + const mergeResult = api.setEntriesInAclW(entries.length / abi.EXPLICIT_ACCESS_W_SIZE, entries, oldAcl, newAclSlot) if (mergeResult !== abi.ERROR_SUCCESS) { if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too throwWin32(api, 'SetEntriesInAclW', mergeResult, `${label}(${path})`) @@ -259,62 +264,111 @@ function mergeAndApply( } /** - * True when the explicit DACL already carries the EXACT write grant this - * module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the - * capability SID). Every field is read through koffi.decode at pointer offsets — - * no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the - * ACE after the 4-byte mask — there is no pointer to read; reading one - * yields garbage addresses and crashed EqualSid, verified by gdb), so it is - * compared field-by-field against the capability SID through bounded offset - * reads ({@link sameSidAt}). A malformed header reads as "no exact grant" - * so the caller falls back to the merge-apply path, which owns the robust - * failure handling. - * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}). - * @param sidPtr - the capability SID to match. - * @returns whether the exact grant ACE is already present. + * True when the explicit DACL already carries the EXACT entry + * `(aceType, OI|CI inheritance, mask, trustee SID)`. Every field is read + * through koffi.decode at pointer offsets — no memcpy, no pointer arithmetic. + * The ACE's SID is INLINE (embedded in the ACE after the 4-byte mask — there + * is no pointer to read; reading one yields garbage addresses and crashed + * EqualSid, verified by gdb), so it is compared field-by-field against the + * trustee SID through bounded offset reads ({@link sameSidAt}). Allowed and + * denied ACEs share the Mask@4/SID@8 layout. A malformed header reads as "no + * exact entry" so the caller falls back to the merge-apply path, which owns + * the robust failure handling. + * @param acl - the current explicit DACL pointer (from {@link readCurrentSecurity}). + * @param aceType - the ACE type to match. + * @param mask - the access mask to match. + * @param sidPtr - the trustee SID to match. + * @returns whether the exact entry is already present. */ -function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { - const aclSize = decodeUint16At(oldAcl, 2) - const aceCount = decodeUint16At(oldAcl, 4) +function hasExactEntry(acl: NativePtr, aceType: number, mask: number, sidPtr: NativePtr): boolean { + const aclSize = decodeUint16At(acl, 2) + const aceCount = decodeUint16At(acl, 4) if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: fall back to the merge path let offset = 8 // the first ACE follows the 8-byte ACL header for (let index = 0; index < aceCount; index++) { - // ACE_HEADER: AceType@0, AceFlags@1, AceSize@2 (WORD); - // ACCESS_ALLOWED_ACE: Mask@4, inline SID@8. - const aceSize = decodeUint16At(oldAcl, offset + 2) + // ACE_HEADER: AceType@0, AceFlags@1, AceSize@2 (WORD); Mask@4, inline SID@8. + const aceSize = decodeUint16At(acl, offset + 2) if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: fall back to the merge path - const exact = decodeUint8At(oldAcl, offset) === abi.ACCESS_ALLOWED_ACE_TYPE - && decodeUint8At(oldAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT - && decodeUint32At(oldAcl, offset + 4) === abi.GRANT_MASK - if (exact && sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true + const exact = decodeUint8At(acl, offset) === aceType + && decodeUint8At(acl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT + && decodeUint32At(acl, offset + 4) === mask + if (exact && sameSidAt(acl, offset + 8, sidPtr, 0)) return true offset += aceSize } return false } +/** + * True when the explicit DACL already carries the EXACT write grant this + * module would add: the Allow ACE for {@link abi.GRANT_MASK} naming the + * capability SID. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). + * @param sidPtr - the capability SID to match. + * @returns whether the exact grant ACE is already present. + */ +function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { + return hasExactEntry(oldAcl, abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sidPtr) +} + +/** + * True when the merge's ambient-delete deny is already present: the exact + * Deny ACE for {@link abi.FILE_DELETE_CHILD} naming the world SID. Together + * with {@link hasExactGrant} and the label check this gates the idempotent + * skip, so a root granted by an earlier build receives the deny on its next + * provision instead of keeping the parent-`FILE_DELETE_CHILD` route open. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). + * @param worldSidPtr - the Everyone SID the deny names. + * @returns whether the exact deny ACE is already present. + */ +function hasExactDeny(oldAcl: NativePtr, worldSidPtr: NativePtr): boolean { + return hasExactEntry(oldAcl, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, worldSidPtr) +} + /** * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID - * on `path`, inheriting to subcontainers and objects, and apply the Low - * mandatory label to the same directory in the same call. Idempotent: when the - * directory's current explicit DACL already carries the exact ACE AND its label - * ACL the exact label (the per-workspace grant surviving from a previous server - * lifetime), the SetNamedSecurityInfoW apply is SKIPPED — it would otherwise - * re-propagate the identical security descriptor across the whole tree (eager - * inheritance; minutes on large workspaces). Otherwise read-merge-write: the - * new ACE merges into the directory's CURRENT explicit DACL (same shape as - * {@link revokeWrite}), so pre-existing explicit ACEs survive. Runs under the - * per-path lock. The directory must be owned by the caller (owner implicit - * WRITE_DAC) — same precondition as the POC. + * on `path`, deny the ambient `FILE_DELETE_CHILD` right to the world SID, and + * apply the Low mandatory label — all in one SetNamedSecurityInfoW call. + * + * The deny closes the parent-directory delete route inside the granted roots: + * Windows authorizes a delete from the object's own DELETE right OR from the + * parent's `FILE_DELETE_CHILD`, and the token's write-restricted intersection + * reaches only the first, so without the deny one confined child could delete + * files in ANOTHER granted root (another workspace, a live sibling temp + * directory) whose Low label clears the integrity check. With the deny in + * place the capability ACE's DELETE bit is the only delete authority inside + * the granted roots, for the confined child and the ambient user alike; a file + * whose own DACL grants no DELETE is therefore no longer deletable by its + * parent's default rights. + * + * Idempotent: when the directory's current explicit DACL already carries the + * exact ACE and deny AND its label ACL the exact label (the per-workspace + * grant surviving from a previous server lifetime), the SetNamedSecurityInfoW + * apply is SKIPPED — it would otherwise re-propagate the identical security + * descriptor across the whole tree (eager inheritance; minutes on large + * workspaces). Otherwise read-merge-write: the entries merge into the + * directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so + * pre-existing explicit ACEs survive. Runs under the per-path lock. The + * directory must be owned by the caller (owner implicit WRITE_DAC) — same + * precondition as the POC. * @param api - the binding table. * @param path - the directory whose DACL and label gain the grant (the workspace or temp root). * @param sidPtr - the capability SID the ACE names. * @param lowLabelSidPtr - the Low integrity SID the mandatory label names. + * @param worldSidPtr - the Everyone SID the ambient-delete deny names. */ -export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr, lowLabelSidPtr: NativePtr): void { +export function grantWrite( + api: Win32Bindings, + path: string, + sidPtr: NativePtr, + lowLabelSidPtr: NativePtr, + worldSidPtr: NativePtr, +): void { withPathLock(api, path, () => { const { oldAcl, labelAcl, descriptor } = readCurrentSecurity(api, path) - if (oldAcl !== null && labelAcl !== null && hasExactGrant(oldAcl, sidPtr) && hasExactLabel(labelAcl, lowLabelSidPtr)) { - // The exact ACE and label stand: releasing the descriptor is the whole operation. + if (oldAcl !== null && labelAcl !== null + && hasExactGrant(oldAcl, sidPtr) && hasExactDeny(oldAcl, worldSidPtr) + && hasExactLabel(labelAcl, lowLabelSidPtr)) { + // The exact ACE, deny, and label stand: releasing the descriptor is the whole operation. if (descriptor !== null) { const freed = api.localFree(descriptor) if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', `grantWrite(${path}) descriptor`) @@ -322,7 +376,11 @@ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr, return } mergeAndApply( - api, path, buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), + api, path, + Buffer.concat([ + buildExplicitAccess(worldSidPtr, abi.DENY_ACCESS, abi.FILE_DELETE_CHILD), + buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), + ]), oldAcl, buildLowLabelAcl(api, lowLabelSidPtr), descriptor, 'grantWrite', ) }) diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index e31db0e05f..812fec2a43 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -34,20 +34,29 @@ export class AclWriteGrant { private readonly api: Win32Bindings private readonly sidPtr: NativePtr private readonly lowLabelSidPtr: NativePtr + private readonly worldSidPtr: NativePtr private readonly revocablePaths: string[] = [] private readonly standingPaths: string[] = [] - private constructor(api: Win32Bindings, sidPtr: NativePtr, lowLabelSidPtr: NativePtr, writeSid: string) { + private constructor( + api: Win32Bindings, + sidPtr: NativePtr, + lowLabelSidPtr: NativePtr, + worldSidPtr: NativePtr, + writeSid: string, + ) { this.api = api this.sidPtr = sidPtr this.lowLabelSidPtr = lowLabelSidPtr + this.worldSidPtr = worldSidPtr this.writeSid = writeSid } /** - * Parse the SID string, create the Low integrity SID the grants label with, - * and open the binding table (lazily, once per server). Fail-closed: any - * failure throws — nothing is granted yet. + * Parse the SID string, create the Low integrity SID the grants label with + * and the world SID their ambient-delete deny names, and open the binding + * table (lazily, once per server). Fail-closed: any failure throws — nothing + * is granted yet. * @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string. * @param api - optional already-resolved bindings (tests). * @returns the ready grant (no ACEs yet). @@ -60,15 +69,21 @@ export class AclWriteGrant { } const sidPtr = decodePtr(sidSlot) if (sidPtr === null) throwLastError(bindings, 'ConvertStringSidToSidW', `null SID for ${writeSid}`) - return new AclWriteGrant(bindings, sidPtr, makeWellKnownSid(bindings, abi.WinLowLabelSid), writeSid) + return new AclWriteGrant( + bindings, + sidPtr, + makeWellKnownSid(bindings, abi.WinLowLabelSid), + makeWellKnownSid(bindings, abi.WinWorldSid), + writeSid, + ) } /** - * Grant the write ACE and the Low mandatory label on one directory - * (idempotent: an already-standing exact ACE plus exact label skips the - * eager full-tree re-propagation — see {@link grantWrite}) and record the - * path for {@link dispose} unless it is standing. The path is recorded - * BEFORE the grant: a post-apply throw (a LocalFree failure after + * Grant the write ACE, the ambient-delete deny, and the Low mandatory label + * on one directory (idempotent: an already-standing exact ACE, deny, and + * label skip the eager full-tree re-propagation — see {@link grantWrite}) + * and record the path for {@link dispose} unless it is standing. The path is + * recorded BEFORE the grant: a post-apply throw (a LocalFree failure after * SetNamedSecurityInfoW succeeded) must still revoke it, and revoking an * ungranted path is a no-op merge. Callers treat a throw as a failed * materialization and dispose the instance to revoke the paths granted so @@ -80,7 +95,7 @@ export class AclWriteGrant { */ add(path: string, standing = false): void { ;(standing ? this.standingPaths : this.revocablePaths).push(path) - grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr) + grantWrite(this.api, path, this.sidPtr, this.lowLabelSidPtr, this.worldSidPtr) } /** Every directory currently carrying the grant, in grant order. */ @@ -98,7 +113,11 @@ export class AclWriteGrant { failures.push(error) } } - for (const [label, sidPtr] of [['write SID', this.sidPtr], ['Low label SID', this.lowLabelSidPtr]] as const) { + for (const [label, sidPtr] of [ + ['write SID', this.sidPtr], + ['Low label SID', this.lowLabelSidPtr], + ['world SID', this.worldSidPtr], + ] as const) { try { const freed = this.api.localFree(sidPtr) if (!isNullPtr(freed)) throwLastError(this.api, 'LocalFree', label) diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 2760758ebb..3ecaee8458 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -263,29 +263,29 @@ export class AclSandbox { // provision would re-propagate the whole tree) and the temp ACE is // REVOCABLE (dispose() removes it before the private directory is // deleted; the ambient temp root is never granted). - // The Low integrity SID every granted directory is labeled with and the - // confined token itself is lowered to. + // The Low integrity SID every granted directory is labeled with (and the + // confined token is lowered to) plus the world SID the grants deny the + // ambient parent-directory delete right to. const lowLabelSid = makeWellKnownSid(api, abi.WinLowLabelSid) - this.sidAllocations.push(lowLabelSid) + const worldSid = makeWellKnownSid(api, abi.WinWorldSid) + this.sidAllocations.push(lowLabelSid, worldSid) if (this.manageDacls) { if (this.writeSidPtr !== undefined) { for (const path of this.writableDirs) { - grantWrite(api, path, this.writeSidPtr, lowLabelSid) + grantWrite(api, path, this.writeSidPtr, lowLabelSid, worldSid) } if (tempDir !== null && this.tempWriteSidPtr !== undefined) { // Record BEFORE granting: grantWrite can throw after a successful // apply (a LocalFree failure), and the fail-closed catch must still // revoke that path (revoking an ungranted path is a no-op merge). this.grantedPaths.push({ path: tempDir, sidPtr: this.tempWriteSidPtr }) - grantWrite(api, tempDir, this.tempWriteSidPtr, lowLabelSid) + grantWrite(api, tempDir, this.tempWriteSidPtr, lowLabelSid, worldSid) } } } const logonSid = findLogonSid(api, currentToken) this.sidAllocations.push(logonSid) - const worldSid = makeWellKnownSid(api, abi.WinWorldSid) - this.sidAllocations.push(worldSid) const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined) restrictedToken = createRestrictedToken( api, currentToken, logonSid, writeSids, diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 76a263e976..bf8ad49851 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -79,6 +79,8 @@ export const TRUSTEE_IS_SID = 0 export const NO_MULTIPLE_TRUSTEE = 0 /** EXPLICIT_ACCESS mode that grants access. */ export const GRANT_ACCESS = 1 +/** EXPLICIT_ACCESS mode that denies access. */ +export const DENY_ACCESS = 3 /** EXPLICIT_ACCESS mode that revokes access. */ export const REVOKE_ACCESS = 4 /** ACE inheritance flags for child containers and objects. */ @@ -107,6 +109,8 @@ export const LOCKFILE_EXCLUSIVE_LOCK = 0x2 export const LOCKFILE_FAIL_IMMEDIATELY = 0x1 /** ACE type for an allowed-access entry. */ export const ACCESS_ALLOWED_ACE_TYPE = 0 +/** ACE type for a denied-access entry (shares the allowed ACE's Mask/SID layout). */ +export const ACCESS_DENIED_ACE_TYPE = 1 /** Maximum SID sub-authority count. */ export const SID_MAX_SUB_AUTHORITIES = 15 /** ACE flag marking inherited entries. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index e93d6750cc..88306142c0 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -73,34 +73,76 @@ function craftLowLabelSid(): NativePtr { return craftSid(1, 0, [0, 0, 0, 0, 0, 16]) } +/** The world SID the grant's ambient-delete deny names (crafted like the other stubs: 8 header bytes, no sub-authorities). */ +function craftWorldSid(): NativePtr { + return craftSid(1, 0, [0, 0, 0, 0, 0, 1]) +} + +/** Write one 16-byte ACE (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8) at `offset`. */ +function writeAce(acl: NativePtr, offset: number, aceType: number, mask: number, sid: NativePtr, match: boolean): void { + koffi.encode(acl, offset + 0, 'uint8', aceType) + koffi.encode(acl, offset + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) + koffi.encode(acl, offset + 2, 'uint16', 16) + koffi.encode(acl, offset + 4, 'uint32', mask) + for (let byte = 0; byte < 8; byte++) { + koffi.encode(acl, offset + 8 + byte, 'uint8', match + ? koffi.decode(sid, byte, 'uint8') as number + : byte === 0 ? 9 : 0) + } +} + /** * One in-memory ACL carrying a single inheritable ACE: header (AclRevision@0, - * AclSize@2, AceCount@4) then a 16-byte ACE (AceType@0, AceFlags@1, - * AceSize@2, Mask@4, inline SID@8). `match` selects whether the inline SID - * bytes equal `sid`. + * AclSize@2, AceCount@4) then a 16-byte ACE. `match` selects whether the + * inline SID bytes equal `sid`. */ -function craftAcl(aceType: number, mask: number, sid: NativePtr, match: boolean, aceSize = 16, aclSize = 24, count = 1): NativePtr { +function craftAcl(aceType: number, mask: number, sid: NativePtr, match: boolean): NativePtr { const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) // AclRevision - koffi.encode(acl, 2, 'uint16', aclSize) - koffi.encode(acl, 4, 'uint16', count) - const ace = 8 - koffi.encode(acl, ace + 0, 'uint8', aceType) - koffi.encode(acl, ace + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) - koffi.encode(acl, ace + 2, 'uint16', aceSize) - koffi.encode(acl, ace + 4, 'uint32', mask) - const inlineSid = ace + 8 - for (let offset = 0; offset < 8; offset++) { - koffi.encode(acl, inlineSid + offset, 'uint8', match - ? koffi.decode(sid, offset, 'uint8') as number - : offset === 0 ? 9 : 0) - } + koffi.encode(acl, 2, 'uint16', 24) + koffi.encode(acl, 4, 'uint16', 1) + writeAce(acl, 8, aceType, mask, sid, match) return acl } -/** The DACL the exact-grant skip looks for: the capability ACE with GRANT_MASK. */ -function craftAclWithGrant(sid: NativePtr, match: boolean): NativePtr { - return craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, match) +/** + * A two-ACE DACL: the ambient-delete deny plus the capability grant, each with + * caller-chosen type, mask, and trustee match so the skip's field checks can be + * driven one at a time. + */ +function craftPair( + grantSid: NativePtr, + world: NativePtr, + denyType: number, + denyMask: number, + denyMatches: boolean, + grantMatches: boolean, +): NativePtr { + const acl = allocBytes(64) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 40) + koffi.encode(acl, 4, 'uint16', 2) + writeAce(acl, 8, denyType, denyMask, world, denyMatches) + writeAce(acl, 24, abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, grantSid, grantMatches) + return acl +} + +/** + * The DACL a fully granted directory carries: the ambient-delete deny for the + * world SID plus the capability grant. `grantMatches` selects whether the + * grant's inline SID equals `sid` (the deny always matches), and + * `includeDeny` drops the deny to model a root granted by an earlier build. + */ +function craftGrantedAcl(sid: NativePtr, world: NativePtr, grantMatches: boolean, includeDeny = true): NativePtr { + if (!includeDeny) { + const acl = allocBytes(64) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 24) + koffi.encode(acl, 4, 'uint16', 1) + writeAce(acl, 8, abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, grantMatches) + return acl + } + return craftPair(sid, world, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, true, grantMatches) } /** The label ACL the exact-label skip looks for: the Low no-write-up label ACE. */ @@ -180,7 +222,7 @@ describe('buildLowLabelAcl failure paths', () => { const api = aclApi({ getLengthSid: vi.fn(() => 0) }) let caught: unknown try { - grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -192,7 +234,7 @@ describe('buildLowLabelAcl failure paths', () => { const api = aclApi({ localAlloc: vi.fn(() => 0n as NativePtr) }) let caught: unknown try { - grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -205,7 +247,7 @@ describe('buildLowLabelAcl failure paths', () => { const api = aclApi({ initializeAcl: vi.fn(() => 0), localFree }) let caught: unknown try { - grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -219,7 +261,7 @@ describe('buildLowLabelAcl failure paths', () => { const api = aclApi({ addMandatoryAce: vi.fn(() => 0), localFree }) let caught: unknown try { - grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid()) + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -235,7 +277,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -248,7 +290,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -266,7 +308,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -285,7 +327,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -300,7 +342,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -318,7 +360,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -333,7 +375,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -355,7 +397,7 @@ describe('mergeAndApply failure paths', () => { const sid = craftSid(1, 0) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, craftLowLabelSid()) + grantWrite(api, 'C:\\granted', sid, craftLowLabelSid(), craftWorldSid()) } catch (error) { caught = error } @@ -369,14 +411,15 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { it('grantWrite skips the apply when the standing exact ACE and the exact label match (descriptor freed, nothing merged)', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const localFree = vi.fn(() => 0n as NativePtr) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, true), 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), craftAclWithLabel(lowSid, true), 6n), localFree, setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(localFree).toHaveBeenCalledWith(6n) }) @@ -384,15 +427,16 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { it('grantWrite skips the apply without freeing when the exact ACE and label stand but no descriptor owns them', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const localFree = vi.fn(() => 0n as NativePtr) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ // the read "returned" ACLs with no descriptor allocation of their own - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, true), null), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), craftAclWithLabel(lowSid, true), null), localFree, setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).not.toHaveBeenCalled() expect(localFree).not.toHaveBeenCalled() }) @@ -400,13 +444,14 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { it('grantWrite reports a failed descriptor LocalFree on the exact-ACE/exact-label skip path', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, true), 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), craftAclWithLabel(lowSid, true), 6n), localFree: vi.fn(() => 1n as NativePtr), }) let caught: unknown try { - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) } catch (error) { caught = error } @@ -414,45 +459,107 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { expect((caught as Win32Error).api).toBe('LocalFree') }) + it('does not skip when a root granted by an earlier build carries the ACE and label but no ambient-delete deny', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true, false), craftAclWithLabel(lowSid, true), 6n), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not skip when the deny names another trustee', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub( + craftPair(sid, world, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, false, true), craftAclWithLabel(lowSid, true), 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not treat a deny of another right as the ambient-delete deny', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub( + craftPair(sid, world, abi.ACCESS_DENIED_ACE_TYPE, abi.GRANT_MASK, true, true), craftAclWithLabel(lowSid, true), 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + + it('does not treat an Allow ACE as the ambient-delete deny', () => { + const sid = craftSid(1, 0) + const lowSid = craftLowLabelSid() + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub( + craftPair(sid, world, abi.ACCESS_ALLOWED_ACE_TYPE, abi.FILE_DELETE_CHILD, true, true), craftAclWithLabel(lowSid, true), 6n, + ), + setNamedSecurityInfoW, + }) + grantWrite(api, 'C:\\granted', sid, lowSid, world) + expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) + }) + it('does not skip when the standing ACE and label match but the label ACL is absent', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), null, 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), null, 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('does not skip when the exact ACE stands but the label names another integrity level', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), craftAclWithLabel(lowSid, false), 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), craftAclWithLabel(lowSid, false), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('falls back to the merge path when the standing ACE names a different SID', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, false), craftAclWithLabel(lowSid, true), 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, false), craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats an implausibly small ACL size as no exact grant', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) koffi.encode(acl, 2, 'uint16', 4) // smaller than the 8-byte ACL header @@ -462,13 +569,14 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { getNamedSecurityInfoW: readStub(acl, craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats an ACE that would overrun the ACL as no exact grant', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const acl = allocBytes(32) koffi.encode(acl, 'uint8', 2) koffi.encode(acl, 2, 'uint16', 8) // header only: no room for any ACE @@ -479,29 +587,31 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { getNamedSecurityInfoW: readStub(acl, craftAclWithLabel(lowSid, true), 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats an implausibly small label ACL size as no exact label', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const label = allocBytes(32) koffi.encode(label, 'uint8', 2) koffi.encode(label, 2, 'uint16', 4) // smaller than the 8-byte ACL header koffi.encode(label, 4, 'uint16', 1) const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), label, 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), label, 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('treats a label ACE that would overrun its ACL as no exact label', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const label = allocBytes(32) koffi.encode(label, 'uint8', 2) koffi.encode(label, 2, 'uint16', 8) // header only: no room for any ACE @@ -509,43 +619,45 @@ describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { koffi.encode(label, 10, 'uint16', 100) // the walk reads a lying ACE size const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ - getNamedSecurityInfoW: readStub(craftAclWithGrant(sid, true), label, 6n), + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), label, 6n), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('does not treat a no-write-up ACL Allow ACE as the mandatory label', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ // Same mask and SID, but an ACCESS_ALLOWED_ACE is not a mandatory label. getNamedSecurityInfoW: readStub( - craftAclWithGrant(sid, true), + craftGrantedAcl(sid, world, true), craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, lowSid, true), 6n, ), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) it('does not treat a label ACE granting write-up as the exact label', () => { const sid = craftSid(1, 0) const lowSid = craftLowLabelSid() + const world = craftWorldSid() const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ getNamedSecurityInfoW: readStub( - craftAclWithGrant(sid, true), + craftGrantedAcl(sid, world, true), craftAcl(abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE, 0, lowSid, true), 6n, ), setNamedSecurityInfoW, }) - grantWrite(api, 'C:\\granted', sid, lowSid) + grantWrite(api, 'C:\\granted', sid, lowSid, world) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(1) }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 3b2bb976e5..9d5e6e697a 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -115,6 +115,11 @@ function lowLabelSid(api: Win32Bindings): NativePtr { return makeWellKnownSid(api, abi.WinLowLabelSid) } +/** The world SID (S-1-1-0) every grant denies the ambient delete right to. */ +function worldSid(api: Win32Bindings): NativePtr { + return makeWellKnownSid(api, abi.WinWorldSid) +} + /** * Read the directory's mandatory label ACEs (inherited entries excluded) — * same ACE layout as {@link readDirectAces}, read through the label security @@ -170,6 +175,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { const usersSid = sidFromString(api, 'S-1-5-32-545') const capabilitySid = sidFromString(api, 'S-1-4-4242-1') const lowSid = lowLabelSid(api) + const world = worldSid(api) try { // Install one explicit ACE (Users + benign read mask) with the // package's own bindings, exactly like a pre-existing explicit DACL @@ -186,7 +192,12 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS) expect(isNullPtr(freed)).toBe(true) - grantWrite(api, dir, capabilitySid, lowSid) + grantWrite(api, dir, capabilitySid, lowSid, world) + + const granted = readDirectAces(api, dir) + expect(granted.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved + expect(granted.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(true) + revokeWrite(api, dir, capabilitySid) const aces = readDirectAces(api, dir) @@ -197,43 +208,53 @@ describe.skipIf(!isWin32)('ACL editing', () => { if (!isNullPtr(usersSid)) api.localFree(usersSid) if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) } }) - it('grantWrite applies the Low no-write-up label; revokeWrite clears it in the same edit', async () => { + it('grantWrite applies the Low no-write-up label and denies the ambient parent-delete right; revokeWrite clears both capability and label', async () => { const api = await win32() const dir = scratch() const capabilitySid = sidFromString(api, 'S-1-4-4242-9') const lowSid = lowLabelSid(api) + const world = worldSid(api) try { expect(readLabelAces(api, dir)).toEqual([]) - grantWrite(api, dir, capabilitySid, lowSid) + grantWrite(api, dir, capabilitySid, lowSid, world) expect(readLabelAces(api, dir)).toEqual([ { sid: 'S-1-16-4096', mask: abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP }, ]) + // The ambient delete route inside another granted root is what the deny + // removes; the capability ACE stays the only delete authority there. + const denied = readDirectAces(api, dir).filter(ace => ace.sid === 'S-1-1-0') + expect(denied).toEqual([{ sid: 'S-1-1-0', mask: abi.FILE_DELETE_CHILD }]) revokeWrite(api, dir, capabilitySid) expect(readLabelAces(api, dir)).toEqual([]) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-9')).toBe(false) } finally { if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) } }) - it('grantWrite is idempotent: a second grant over the standing exact ACE and label skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { + it('grantWrite is idempotent: a second grant over the standing exact ACE, deny, and label skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { const api = await win32() const dir = scratch() const capabilitySid = sidFromString(api, 'S-1-4-4242-2') const lowSid = lowLabelSid(api) + const world = worldSid(api) const apply = vi.spyOn(api, 'setNamedSecurityInfoW') try { - grantWrite(api, dir, capabilitySid, lowSid) + grantWrite(api, dir, capabilitySid, lowSid, world) expect(apply).toHaveBeenCalledTimes(1) - // The exact ACE and label now stand (the per-session grant surviving - // from a previous server lifetime): the second grant is a read only. - grantWrite(api, dir, capabilitySid, lowSid) + // The exact ACE, deny, and label now stand (the per-session grant + // surviving from a previous server lifetime): the second grant is a read only. + grantWrite(api, dir, capabilitySid, lowSid, world) expect(apply).toHaveBeenCalledTimes(1) const aces = readDirectAces(api, dir) expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1) + expect(aces.filter(ace => ace.sid === 'S-1-1-0')).toHaveLength(1) expect(readLabelAces(api, dir)).toHaveLength(1) revokeWrite(api, dir, capabilitySid) expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false) @@ -241,6 +262,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { apply.mockRestore() if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) } }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index a58ac01d82..fb734ba0ec 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -450,6 +450,71 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 60_000) + it('cross-root delete regression: one session cannot delete inside ANOTHER granted root', () => { + // Both roots carry the Low label, so the integrity check alone would let a + // confined child delete in the other one: Windows authorizes a delete from + // the parent directory's FILE_DELETE_CHILD, which the write-restricted + // intersection does not reach. The grant therefore denies that right to the + // world SID, leaving the capability ACE's DELETE bit as the only delete + // authority inside a granted root. + const otherWorkspace = join(scratchRoot, 'other-workspace') + const otherTemp = join(scratchRoot, 'other-temp') + mkdirSync(otherWorkspace) + mkdirSync(otherTemp) + const otherWorkspaceSid = workspaceWriteSid(otherWorkspace) + const otherTempSid = tempWriteSid(otherTemp) + const otherWorkspaceGrant = AclWriteGrant.create(otherWorkspaceSid) + const otherTempGrant = AclWriteGrant.create(otherTempSid) + otherWorkspaceGrant.add(otherWorkspace, true) + otherTempGrant.add(otherTemp) + // Session A's own roots, granted the same way, run through the runner. + const ownWorkspace = join(scratchRoot, 'own-workspace') + const ownTemp = join(scratchRoot, 'own-temp') + mkdirSync(ownWorkspace) + mkdirSync(ownTemp) + const ownWorkspaceSid = workspaceWriteSid(ownWorkspace) + const ownTempSid = tempWriteSid(ownTemp) + const ownWorkspaceGrant = AclWriteGrant.create(ownWorkspaceSid) + const ownTempGrant = AclWriteGrant.create(ownTempSid) + ownWorkspaceGrant.add(ownWorkspace, true) + ownTempGrant.add(ownTemp) + try { + const victims = { + ownWork: join(ownWorkspace, 'victim.txt'), + otherWork: join(otherWorkspace, 'victim.txt'), + ownTemp: join(ownTemp, 'victim.txt'), + otherTemp: join(otherTemp, 'victim.txt'), + } + for (const path of Object.values(victims)) writeFileSync(path, 'delete me') + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + ...Object.entries(victims).map(([name, path]) => + `try{[System.IO.File]::Delete('${path}');'${name}: DELETED'}catch{'${name}: DENIED'};`), + ].join('') + const result = runRunner([ + '--workspace', ownWorkspace, '--temp', ownTemp, '--mode', 'workspace-write', + '--write-sid', ownWorkspaceSid, '--temp-write-sid', ownTempSid, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('ownWork: DELETED') + expect(result.stdout).toContain('ownTemp: DELETED') + expect(result.stdout).toContain('otherWork: DENIED') + expect(result.stdout).toContain('otherTemp: DENIED') + expect(existsSync(victims.otherWork)).toBe(true) + expect(existsSync(victims.otherTemp)).toBe(true) + expect(existsSync(victims.ownWork)).toBe(false) + expect(existsSync(victims.ownTemp)).toBe(false) + } finally { + ownWorkspaceGrant.dispose() + ownTempGrant.dispose() + otherWorkspaceGrant.dispose() + otherTempGrant.dispose() + rmSync(ownTemp, { recursive: true, force: true }) + rmSync(otherTemp, { recursive: true, force: true }) + } + }, 60_000) + it('the Low mandatory label closes the Everyone-Modify ambient boundary under BOTH modes', () => { // Everyone is a required keep-alive restricting SID: without it early DLL // initialization and CNG fail. A normal DACL that grants Everyone Modify From 7450e1eb24c8269d1336db95ceea1044dc2beef8 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 02:24:48 +0800 Subject: [PATCH 03/20] docs(sandbox): tighten the comments added by the delete-confinement fix One home per fact: the parent-FILE_DELETE_CHILD rationale now lives in the acl module doc and the package README instead of being retold in restrictTokenIntegrity, init(), and grantWrite. Drops the duplicated inline narration; behavior unchanged. --- .../sandbox/sandbox-windows-acl/src/acl.ts | 71 +++++++------------ .../sandbox/sandbox-windows-acl/src/index.ts | 22 ++---- .../sandbox/sandbox-windows-acl/src/token.ts | 18 ++--- .../sandbox-windows-acl/tests/runner.spec.ts | 26 +++---- 4 files changed, 46 insertions(+), 91 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index f9ce88d700..199e9e394b 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -5,17 +5,14 @@ * failure is reported with the API name, the exact Win32 code, the formatted * system text, and the affected path. * - * Each grant is a PAIR of edits applied in ONE SetNamedSecurityInfoW call: - * a Deny ACE removing the ambient parent-directory delete right - * (`Everyone` / `FILE_DELETE_CHILD`) plus the capability-SID allow ACE in the - * DACL, and a Low mandatory label with the no-write-up policy in the label - * ACL ({@link buildLowLabelAcl}). The deny makes the capability ACE's DELETE - * bit the only delete authority inside the granted roots — Windows otherwise - * authorizes a delete from the parent directory's `FILE_DELETE_CHILD`, which - * the token's write-restricted intersection does not reach, so one confined - * child could delete files in another granted root. The label denies - * write-class access to every object that is not labeled Low, and the kernel - * applies that policy inside the access check itself. + * Each grant applies three edits in ONE SetNamedSecurityInfoW call: the + * capability-SID allow ACE, a Deny ACE that removes the ambient + * `FILE_DELETE_CHILD` right from the world SID, and a Low no-write-up + * mandatory label ({@link buildLowLabelAcl}). The deny is what keeps one + * granted root out of another's reach: Windows also authorizes a delete from + * the parent directory's `FILE_DELETE_CHILD` right, which the token's + * write-restricted intersection does not reach, and every granted root carries + * the Low label that clears the integrity check. * * Concurrency: grants are read-merge-write against the directory's CURRENT * DACL, and the whole get-merge-set sequence runs under a per-path exclusive @@ -152,12 +149,10 @@ function readCurrentSecurity( /** * Build the Low mandatory label applied with every write grant: one * SYSTEM_MANDATORY_LABEL_ACE naming `lowLabelSidPtr` with the no-write-up - * policy, inheriting to subcontainers and objects so files created later - * inside the granted tree carry the same label automatically. - * - * The caller frees the returned ACL with LocalFree; SetNamedSecurityInfoW - * copies it. Fail-closed: every Win32 call is checked and the half-built ACL - * is released before the error is thrown. + * policy, inheriting to subcontainers and objects so later children carry the + * same label. The caller frees the returned ACL with LocalFree + * (SetNamedSecurityInfoW copies it); every Win32 call is checked and a + * half-built ACL is released before the error is thrown. * @param api - the binding table. * @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096) the label names. * @returns the ACL carrying the single inheritable label ACE. @@ -311,11 +306,10 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { } /** - * True when the merge's ambient-delete deny is already present: the exact - * Deny ACE for {@link abi.FILE_DELETE_CHILD} naming the world SID. Together - * with {@link hasExactGrant} and the label check this gates the idempotent - * skip, so a root granted by an earlier build receives the deny on its next - * provision instead of keeping the parent-`FILE_DELETE_CHILD` route open. + * True when the explicit DACL already carries the EXACT ambient-delete deny: + * the Deny ACE for {@link abi.FILE_DELETE_CHILD} naming the world SID. It is + * part of the idempotent skip, so a root granted by an earlier build receives + * the deny on its next provision. * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). * @param worldSidPtr - the Everyone SID the deny names. * @returns whether the exact deny ACE is already present. @@ -326,29 +320,18 @@ function hasExactDeny(oldAcl: NativePtr, worldSidPtr: NativePtr): boolean { /** * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID - * on `path`, deny the ambient `FILE_DELETE_CHILD` right to the world SID, and - * apply the Low mandatory label — all in one SetNamedSecurityInfoW call. + * on `path`, deny the world SID the ambient `FILE_DELETE_CHILD` right, and + * apply the Low mandatory label — one merge, inheriting to subcontainers and + * objects. The capability ACE's DELETE bit is then the only delete authority + * inside the root, so a file whose own DACL grants no DELETE is no longer + * deletable through its parent's rights. * - * The deny closes the parent-directory delete route inside the granted roots: - * Windows authorizes a delete from the object's own DELETE right OR from the - * parent's `FILE_DELETE_CHILD`, and the token's write-restricted intersection - * reaches only the first, so without the deny one confined child could delete - * files in ANOTHER granted root (another workspace, a live sibling temp - * directory) whose Low label clears the integrity check. With the deny in - * place the capability ACE's DELETE bit is the only delete authority inside - * the granted roots, for the confined child and the ambient user alike; a file - * whose own DACL grants no DELETE is therefore no longer deletable by its - * parent's default rights. - * - * Idempotent: when the directory's current explicit DACL already carries the - * exact ACE and deny AND its label ACL the exact label (the per-workspace - * grant surviving from a previous server lifetime), the SetNamedSecurityInfoW - * apply is SKIPPED — it would otherwise re-propagate the identical security - * descriptor across the whole tree (eager inheritance; minutes on large - * workspaces). Otherwise read-merge-write: the entries merge into the - * directory's CURRENT explicit DACL (same shape as {@link revokeWrite}), so - * pre-existing explicit ACEs survive. Runs under the per-path lock. The - * directory must be owned by the caller (owner implicit WRITE_DAC) — same + * Idempotent: the exact ACE, deny, and label together SKIP the + * SetNamedSecurityInfoW apply, which would otherwise re-propagate the + * identical descriptor across the whole tree (eager inheritance; minutes on + * large workspaces). Otherwise read-merge-write, so pre-existing explicit ACEs + * survive (same shape as {@link revokeWrite}). Runs under the per-path lock. + * The directory must be owned by the caller (owner implicit WRITE_DAC) — same * precondition as the POC. * @param api - the binding table. * @param path - the directory whose DACL and label gain the grant (the workspace or temp root). diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 3ecaee8458..75dd23e7a5 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -10,14 +10,10 @@ * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, * and LOCAL are absent from both lists — see the seam's dual-list contract * in `packages/sandbox/sandbox-local` and the package README's Modes section - * for the complete boundary). The intersection covers only the write bits of - * the object's OWN access check: Windows can also authorize a write or delete - * from the parent directory's FILE_DELETE_CHILD right, which no restricting - * SID has to co-sign. The token is therefore ALSO lowered to Low integrity - * and every granted directory carries a Low no-write-up label, so the - * kernel's mandatory-integrity check — evaluated inside the access check - * regardless of which right supplied the authority — confines the child to - * the labeled roots. The write SID is the per-WORKSPACE identity + * for the complete boundary). The intersection covers only the object's own + * access check, so the token is also lowered to Low integrity and every + * granted directory carries a Low no-write-up label and the ambient-delete + * deny the `acl` module documents. The write SID is the per-WORKSPACE identity * ({@link workspaceWriteSid}): deterministic from the canonical workspace * path, so the workspace-root ACE materializes once per workspace per * machine and every later provision hits the exact-ACE skip — the @@ -263,9 +259,7 @@ export class AclSandbox { // provision would re-propagate the whole tree) and the temp ACE is // REVOCABLE (dispose() removes it before the private directory is // deleted; the ambient temp root is never granted). - // The Low integrity SID every granted directory is labeled with (and the - // confined token is lowered to) plus the world SID the grants deny the - // ambient parent-directory delete right to. + // The Low label SID and the world SID the grants deny and label with. const lowLabelSid = makeWellKnownSid(api, abi.WinLowLabelSid) const worldSid = makeWellKnownSid(api, abi.WinWorldSid) this.sidAllocations.push(lowLabelSid, worldSid) @@ -292,12 +286,6 @@ export class AclSandbox { { world: worldSid }, this.mode, ) - // Lower the confined token to Low integrity. The write-SID intersection - // does not reach the parent directory's FILE_DELETE_CHILD authority, so - // without the matching label a confined child could delete anything its - // ambient user SIDs may delete; the label plus this level close that - // route, and the granted directories stay writable because - // grantWrite labels them Low too. restrictTokenIntegrity(api, restrictedToken, lowLabelSid) this.token = restrictedToken // The restricted token's default DACL still names only the user's diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 27e8fd848b..10674afb26 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -145,20 +145,10 @@ export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, s } /** - * Lower the restricted token's integrity level to Low (S-1-16-4096). - * - * `WRITE_RESTRICTED` intersects only the write bits of the access mask it - * evaluates against an object's DACL. Windows can also authorize a write or a - * delete from the PARENT directory's FILE_DELETE_CHILD right, and that - * authority is granted without the restricting-SID intersection — so a - * confined child could still delete files anywhere its ambient user SIDs hold - * Modify. The mandatory label closes that route: the kernel applies the - * integrity policy inside the access check, wherever the authority came from, - * so a Low child only writes where the object is labeled Low (see - * `grantWrite`). - * - * Requires TOKEN_ADJUST_DEFAULT on the token (the caller opens it with the - * right). Fails closed: a failure throws before any child is spawned. + * Lower the restricted token's integrity level to Low (S-1-16-4096), the level + * the mandatory labels `grantWrite` applies are matched against; a token left + * at Medium would ignore them. Requires TOKEN_ADJUST_DEFAULT on the token; + * fails closed before any child is spawned. * @param api - the binding table. * @param token - the restricted token to lower. * @param lowLabelSidPtr - the Low integrity SID (S-1-16-4096). diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index fb734ba0ec..f539b99f44 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -400,16 +400,11 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { }, 30_000) it('delete-escape regression: deletes outside the granted roots are denied in BOTH modes, through every delete authority', () => { - // Windows authorizes a delete from EITHER the object's own DELETE right OR - // the parent directory's FILE_DELETE_CHILD right, and the - // WRITE_RESTRICTED pass-2 intersection only covers the first. Every - // ordinary directory the ambient user SIDs control (a project checkout, - // %TEMP%, the profile) grants FILE_DELETE_CHILD, so `cmd /c del` deleted - // files outside the workspace even though a plain write from the same - // child was denied. The Low mandatory label closes both routes because the - // kernel applies the integrity policy inside the access check, whichever - // right supplied the authority; the remaining delete paths below are the - // ones a confined child reaches for. + // Windows authorizes a delete from the object's own DELETE right OR the + // parent directory's FILE_DELETE_CHILD, and the write-restricted + // intersection only covers the first — so `cmd /c del` used to delete + // outside the workspace. The Low label plus the grant's deny close both + // routes; these are the deleters a confined child reaches for. for (const mode of ['read-only', 'workspace-write'] as const) { const victims = { cmd: join(scratchRoot, `delete-${mode}-cmd.txt`), @@ -451,12 +446,11 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { }, 60_000) it('cross-root delete regression: one session cannot delete inside ANOTHER granted root', () => { - // Both roots carry the Low label, so the integrity check alone would let a - // confined child delete in the other one: Windows authorizes a delete from - // the parent directory's FILE_DELETE_CHILD, which the write-restricted - // intersection does not reach. The grant therefore denies that right to the - // world SID, leaving the capability ACE's DELETE bit as the only delete - // authority inside a granted root. + // Both roots carry the Low label, so the integrity check alone would not + // stop this: Windows also authorizes a delete from the parent directory's + // FILE_DELETE_CHILD right, which the capability intersection never reaches. + // The grant denies that right, leaving the capability DELETE bit as the + // only delete authority inside a granted root. const otherWorkspace = join(scratchRoot, 'other-workspace') const otherTemp = join(scratchRoot, 'other-temp') mkdirSync(otherWorkspace) From 688d7b694b15fb0df451f52bad073b0e8d7e9e10 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 13:05:01 +0800 Subject: [PATCH 04/20] test(sandbox): drop the host-dependent whoami integrity assertion whoami /groups reports the mandatory label on this workstation but produces no matching line on the CI Windows runner, so the assertion pinned the host rather than the product. The lowered integrity level stays covered by the unit pin on restrictTokenIntegrity and by the behavior that only holds with it: the Everyone-Modify denial, the outside-delete denial, and the cross-root delete denial. README records the cmdlet as host-dependent diagnostic noise again. --- packages/sandbox/sandbox-windows-acl/README.i18n.yaml | 4 ++-- packages/sandbox/sandbox-windows-acl/README.md | 2 +- packages/sandbox/sandbox-windows-acl/README.zh.md | 2 +- packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts | 4 ---- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 813c667920..42c27aa70c 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 94d49d76437799b19cb2468e96a020589058d624 -README.zh.md: 7709ddcf4f6d609fbce953946a532952b90ea620 +README.md: 41415249c2fcf05c17634f6930692b1a2a5960c8 +README.zh.md: 7c62b263fa553748c186f4c40d2fc0aa1259804f diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 94d49d7643..41415249c2 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -120,7 +120,7 @@ When launched with the subprocess control marker, the runner forwards fd 7 throu - **Granted directories must be caller-owned** — the owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. - **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root. - **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories. -- **`whoami` and token-inspection cmdlets run but report the reduced token** — `whoami /groups` succeeds and reports `S-1-16-4096`, which is the cheapest way to observe the active integrity level. +- **`whoami` and token-inspection cmdlets may fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child, so whether a reporting cmdlet works is host-dependent; it is diagnostic noise rather than an operational failure. ### Header verification and source map diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 7709ddcf4f..7c62b263fa 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -120,7 +120,7 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 - **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙箱无需提权即可编辑 DACL 的原因。 - **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。 - **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 -- **`whoami` 与令牌检查 cmdlet 可以运行,但报告的是被削减的令牌。** `whoami /groups` 成功并报告 `S-1-16-4096`,这是观察当前完整性级别最简便的方式。 +- **受限令牌下 `whoami` 与令牌检查 cmdlet 可能失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此这类报告型 cmdlet 是否可用取决于宿主环境;这是诊断噪音而非运行故障。 ### 头文件验证与源码索引 diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index f539b99f44..a654b107ab 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -82,9 +82,6 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // cannot create those scratch files and fails that probe closed to // ConstrainedLanguage (pinned below). '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', - // The integrity level the token was lowered to: the mandatory label on - // the granted directories is what makes them writable at this level. - "(whoami /groups | Select-String 'Mandatory Label') -replace '\\s+', ' ';", `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, "try{Set-Content -Path (Join-Path $env:TEMP 'child-wrote.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};", `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, @@ -101,7 +98,6 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) expect(result.stdout).toContain('LANGMODE: FullLanguage') - expect(result.stdout).toContain('S-1-16-4096') expect(result.stdout).toContain('TARGET-WRITE: OK') expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') From 3d5ba3b83f5419a47731516cde6d6ebb9f588bc9 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 13:42:47 +0800 Subject: [PATCH 05/20] fix(sandbox): scope the delete deny to containers, clearing the review round The inheritable `Deny Everyone FILE_DELETE_CHILD` also reached FILES, and 0x40 is a member of FILE_ALL_ACCESS: measured against a real runner, a Medium caller's CreateFileW(GENERIC_ALL) on every file, directory, and temp root inside a granted root returned ACCESS_DENIED. The deny now carries CONTAINER_INHERIT_ACE, so files keep working; a FullControl open of a DIRECTORY inside a granted root stays denied and is pinned as the residual cost of denying a right the full-access mask contains. Also from the review: - Leaks on fail-closed paths: the label ACL on both early SetEntriesInAclW exits, the read descriptor when the label build fails, and the parsed capability SID when either well-known-SID creation fails. - `revokeWrite` keeps the shared Low label while another capability grant remains on the directory, so two grants on one path stay independently revocable; `hasExactLabel` delegates to hasExactEntry. - The delete probe uses process.execPath, restrictTokenIntegrity guards a zero-length SID, and verify/abi-probe.cpp covers the new constants (compiled; static_asserts pass). - Consumers updated in the same change: sandbox-local source comments and README pair, pwsh-sandbox README pair, docs/subsystems/sandbox pair, and the 2026-07-06 sandbox note pair no longer cite the Everyone-write boundary as a current partial reason. - New Agent Note records the mandatory-integrity decision, the deny scoping, the standing label with its externality (other Low-integrity processes running as the user, surviving the session), and the rejected alternatives; the 2026-08-08 note cross-links it. - New runner pins: NUL stays writable under the Low token, the FullControl file/directory split, and the shared-label revoke. Per-file coverage back to 100%. --- .../feature/2026-07-06-sandbox.i18n.yaml | 4 +- .../implemented/feature/2026-07-06-sandbox.md | 2 +- .../feature/2026-07-06-sandbox.zh.md | 2 +- ...ows-acl-restricted-token-sandbox.i18n.yaml | 4 +- ...08-windows-acl-restricted-token-sandbox.md | 2 + ...windows-acl-restricted-token-sandbox.zh.md | 2 + ...-mandatory-integrity-confinement.i18n.yaml | 6 + ...ows-acl-mandatory-integrity-confinement.md | 56 ++++++ ...-acl-mandatory-integrity-confinement.zh.md | 56 ++++++ docs/subsystems/sandbox.i18n.yaml | 6 +- docs/subsystems/sandbox.md | 2 +- docs/subsystems/sandbox.zh.md | 2 +- .../sandbox/sandbox-local/README.i18n.yaml | 6 +- packages/sandbox/sandbox-local/README.md | 6 +- packages/sandbox/sandbox-local/README.zh.md | 6 +- packages/sandbox/sandbox-local/src/index.ts | 19 +- .../sandbox-windows-acl/README.i18n.yaml | 6 +- .../sandbox/sandbox-windows-acl/README.md | 8 +- .../sandbox/sandbox-windows-acl/README.zh.md | 8 +- .../sandbox/sandbox-windows-acl/src/acl.ts | 156 ++++++++++------ .../sandbox/sandbox-windows-acl/src/grant.ts | 25 ++- .../sandbox/sandbox-windows-acl/src/token.ts | 1 + .../sandbox-windows-acl/src/win32-abi.ts | 2 + .../tests/acl-failure-paths.spec.ts | 169 +++++++++++++++++- .../sandbox-windows-acl/tests/acl.spec.ts | 100 ++++++++++- .../tests/grant-failure-paths.spec.ts | 22 +++ .../sandbox-windows-acl/tests/runner.spec.ts | 91 +++++++++- .../tests/token-failure-paths.spec.ts | 16 ++ .../sandbox-windows-acl/verify/abi-probe.cpp | 20 +++ packages/shell/pwsh-sandbox/README.i18n.yaml | 6 +- packages/shell/pwsh-sandbox/README.md | 4 +- packages/shell/pwsh-sandbox/README.zh.md | 4 +- 32 files changed, 709 insertions(+), 110 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md create mode 100644 .agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 0b2036ca86..0c0d28aa76 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 45a1c9c028f127c67e56f4436088852f59e10140 -2026-07-06-sandbox.zh.md: 6ad51a039583949849c853fab2f7c2a72d6d9db7 +2026-07-06-sandbox.md: f2c703ba67bc16529605250d35ed181412f05494 +2026-07-06-sandbox.zh.md: ff4d4ed592dc4235903555528a029b4b295d972e diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 45a1c9c028..f2c703ba67 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -176,7 +176,7 @@ Costs and accepted limits: - **The one-wrapper illusion is given up knowingly.** A `tools/pre-execute` wrapper plus prompt conventions does not solve sandbox approval — the correct design costs structured denials, native runner probes, per-call policy carriage, and consistent cross-family enforcement, and this design pays it. - **`read-only` became a cross-family boundary through a follow-up.** This RFC shipped bash-only enforcement; the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md) extends the same mode vocabulary to the filesystem tools through a sandboxed `ctx.fs` provider and relocates the mode/root config and the `sandbox/mode` override to `ctx.sandboxPolicy` (§ In-process tools). -- **Windows is a partial backend.** This RFC originally reserved an empty, fail-closed win32 chain; the later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled it with the restricted-token runner. Its Everyone and hard-link gaps are reported as `enforcement: 'partial'`, never promoted to the full promise. +- **Windows is a partial backend.** This RFC originally reserved an empty, fail-closed win32 chain; the later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled it with the restricted-token runner. Its hard-link, unconfined-read, and AppContainer-ACL gaps are reported as `enforcement: 'partial'`, never promoted to the full promise. - **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal under a usable workdir surfaces as a runner-attributable spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index 6ad51a0395..ff4d4ed592 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -176,7 +176,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **单一包装层的幻觉被有意放弃。**`tools/pre-execute` 包装层加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 - **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 RFC](2026-07-14-cross-family-fs-sandbox.zh.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 -- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)以受限令牌 runner 填充了它。其 Everyone 与硬链接缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 +- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)以受限令牌 runner 填充了它。其硬链接、读取不受限与 AppContainer ACL 缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index 43bc59bf13..65bc426f49 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 2ab157c697d8917f2e3c6315e5ef161ce6eb5229 -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: 10cac08c3161c6c723414c4274333af4eebc36f3 +2026-08-08-windows-acl-restricted-token-sandbox.md: 65780b9bba31605db02dc9be4c1b2454c74eb0f4 +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: fe9aadb97b172d77d65ca6b0a843f05751075502 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 2ab157c697..65780b9bba 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -41,3 +41,5 @@ The product-visible Windows roster flip is win32-only, so keyless snapshots that ## Related The [pwsh executor decision](../../archived/feature/2026-08-01-pwsh-tool-and-executor.md) owns the pwsh-sandbox/tool-pwsh dialect split this rung consumes. + +The [delete-confinement decision](2026-09-19-windows-acl-mandatory-integrity-confinement.md) extends this rung with the Low integrity layer and the ambient-delete deny; it supersedes only the delete route documented here. diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index 10cac08c31..fe9aadb97b 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -41,3 +41,5 @@ landstrip 评估在实现前已被否决(未经实战检验;自建 launcher ## Related [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 完整性层与环境性删除拒绝扩展本档;它只取代本文档所述的删除路径。 diff --git a/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml new file mode 100644 index 0000000000..4ab68b411e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md +2026-09-19-windows-acl-mandatory-integrity-confinement.md: f53765f34a7d6d9bcae8b06839c30731a054165d +2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md: a963fd7f549ca2e995f74893c95f1620034664eb diff --git a/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md new file mode 100644 index 0000000000..f53765f34a --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.md @@ -0,0 +1,56 @@ +# Agent Note: Windows sandbox delete confinement: mandatory integrity plus an ambient-delete deny + +Status: implemented + +English | [中文](2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md) + +## Problem + +The [restricted-token rung](2026-08-08-windows-acl-restricted-token-sandbox.md) confines writes by intersecting the requested access mask with restricting SIDs. That intersection covers only the access check against the object's OWN security descriptor: Windows also authorizes a write or a delete from the PARENT directory's `FILE_DELETE_CHILD` right, which no restricting SID has to co-sign. `cmd /c del` and `[System.IO.File]::Delete` — anything reaching `DeleteFileW` — therefore deleted files outside the workspace in BOTH confined modes (issue #4581), while plain writes from the same child were denied. + +## Decision + +Every grant now applies three edits in one `SetNamedSecurityInfoW` call, and the restricted token is lowered to match: + +1. the capability-SID allow ACE (`GRANT_MASK`, `OI|CI`) — the write authority, unchanged; +2. a DENY ACE for the world SID on `FILE_DELETE_CHILD` with `CONTAINER_INHERIT_ACE` — the capability ACE's DELETE bit becomes the only delete authority inside a granted root; +3. a Low integrity mandatory label (`S-1-16-4096`, `SYSTEM_MANDATORY_LABEL_NO_WRITE_UP`, `OI|CI`) — and `SetTokenInformation(TokenIntegrityLevel)` lowers the confined token to the same level. + +Each piece is load-bearing. The label is what reaches the parent-`FILE_DELETE_CHILD` route at all: the kernel evaluates the mandatory policy inside the access check whichever right supplied the authority, so a Medium object outside the granted roots is delete-proof as well as write-proof. The deny is what keeps one granted root out of ANOTHER's reach: every granted root carries the Low label, so the integrity check alone would still pass between them. The container-only inheritance on the deny is what keeps the ambient ACL usable: `0x40` is a member of `FILE_ALL_ACCESS`, so a file-level copy would refuse every `GENERIC_ALL`/`FullControl` open inside the root — measured against a real runner before the flag was narrowed. + +The label is STANDING on workspace roots, like the capability ACE and for the same reason: revoking it per session would re-propagate the whole tree on every provision and would race with concurrent sessions. `revokeWrite` clears it only when no other capability grant remains on the directory, so two grants on one directory stay independently revocable. The idempotent skip requires the exact ACE, the exact deny, AND the exact label, so a root granted by an earlier build picks the deny up on its next provision. + +## Alternatives considered + +### Why not make the label revocable per session? + +It removes the externality below, but pays the eager full-tree propagation on every provision (tens of seconds on large workspaces) that the deterministic per-workspace SID exists to avoid, and a concurrent session or a crashed one leaves the label oscillating. + +### Why not Untrusted integrity for read-only mode? + +Measured on this host: an Untrusted (`S-1-16-0`) token cannot start `pwsh` at all — DLL initialization fails (`0x8007045A`, `BCrypt.dll`) — so the read-only mode would have no usable shell. + +### Why not deny `FILE_DELETE_CHILD` with `OI|CI`? + +It also lands the deny on every FILE inside the granted root (observed through `icacls`: `Everyone:(I)(DENY)(DC)`), and `0x40` is part of `FILE_ALL_ACCESS`, so `CreateFileW(GENERIC_ALL)` on those files returns `ERROR_ACCESS_DENIED` for the user, Administrators, SYSTEM, and the DSH host alike. Narrowing the flag removes that class; a FullControl open of a DIRECTORY inside a granted root stays denied, which is the irreducible cost of denying a right that is a member of the full-access mask. + +### Why not drop the confinement layer and document the escape? + +The escape is the report: `cmd /c del` deleting outside the workspace is exactly what the sandbox promises not to allow, and no read-side or ACL-only change closes it — the restricting-SID intersection cannot reach the parent route by construction. + +### Why not give each workspace its own integrity level? + +Mandatory integrity control has five fixed levels (Untrusted, Low, Medium, High, System); they cannot be derived per workspace the way `S-1-4-x-y` capability SIDs are. + +## Consequences + +Bought: deletes confined through every authority Windows accepts AND confined to the granted root that owns them; the previously documented Everyone-write boundary closed; writes, reads, and process visibility otherwise unchanged; fail-closed errors on every Win32 call. Cost, all recorded in the package README: the standing Low label widens the workspace tree for ANY other process running at Low integrity as the same user and outlives DSH (the price of having a write boundary at all at this integrity level); a granted directory must now also grant `WRITE_OWNER` for its label (a Full-control workspace has it, a Modify-only one fails loudly); a FullControl open of a directory inside a granted root is denied; a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low child; FAT-class targets stay unverified. The [restricted-token rung note](2026-08-08-windows-acl-restricted-token-sandbox.md) remains the owner of the rung's token lists, runner contract, and grant lifecycle; this note owns only the delete route and the integrity layer that closes it. + +## Testing + +`runner.spec.ts` pins the behavior against a real runner and a real restricted token: the delete escape is denied in both modes through `cmd`, .NET, `Remove-Item`, and libuv while the file survives on disk; one session cannot delete inside another granted root; NUL stays writable in both modes; a `GENERIC_ALL` open of a file inside a granted root succeeds while the same open of a directory is denied; revoking one of two grants on a directory leaves the surviving grant usable. `acl.spec.ts` pins the real DACL/label lifecycle, that the deny inherits to containers only, and the shared-label revocation rule; `token-failure-paths.spec.ts` pins the exact `TokenIntegrityLevel` payload; the failure-path suites cover every new allocation and early exit, including the label ACL and descriptor releases. + +## Related + +- [Windows sandbox rung: raw ACL restricted tokens](2026-08-08-windows-acl-restricted-token-sandbox.md) — the rung this change extends (kept active; this note supersedes only its delete-route boundary). +- [Sandbox decision](2026-07-06-sandbox.md) — the platform chains and the `partial` enforcement vocabulary. diff --git a/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md new file mode 100644 index 0000000000..a963fd7f54 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-09-19-windows-acl-mandatory-integrity-confinement.zh.md @@ -0,0 +1,56 @@ +# Agent Note: Windows 沙箱删除约束:强制完整性 + 环境性删除拒绝 + +Status: implemented + +[English](2026-09-19-windows-acl-mandatory-integrity-confinement.md) | 中文 + +## 问题 + +[受限令牌档](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)通过把请求的访问掩码与 restricting SIDs 求交集来约束写入。该交集只覆盖针对对象**自身**安全描述符的那次访问检查:Windows 也会依据**父目录**的 `FILE_DELETE_CHILD` 权限批准写入或删除,而这项权限不需要任何 restricting SID 副署。因此 `cmd /c del` 与 `[System.IO.File]::Delete`——凡走 `DeleteFileW` 的路径——在两种受限模式下都能删除工作区之外的文件(issue #4581),而同一子进程的普通写入却被拒绝。 + +## 决策 + +每次授权现在都在一次 `SetNamedSecurityInfoW` 调用中施加三项编辑,并把受限令牌降到同一级别: + +1. 能力 SID 的允许 ACE(`GRANT_MASK`,`OI|CI`)——写权限本身,未变; +2. 对 world SID 的 `FILE_DELETE_CHILD` 拒绝 ACE,带 `CONTAINER_INHERIT_ACE`——能力 ACE 的 DELETE 位成为授权根内唯一的删除授权来源; +3. Low 完整性强制标签(`S-1-16-4096`、`SYSTEM_MANDATORY_LABEL_NO_WRITE_UP`、`OI|CI`)——并用 `SetTokenInformation(TokenIntegrityLevel)` 把受限令牌降到同一级别。 + +三者都不可省。标签是唯一能触及父目录 `FILE_DELETE_CHILD` 路径的手段:无论权限来自哪一项,内核都在访问检查内部执行强制策略,因此授权根之外的 Medium 对象不仅防写、也防删。拒绝项则负责隔开**各个**授权根:所有授权根都带 Low 标签,仅靠完整性检查它们之间仍会放行。而拒绝项只做容器继承,是为了不破坏环境 ACL 的可用性:`0x40` 是 `FILE_ALL_ACCESS` 的成员,若落到文件上会让授权根内每次 `GENERIC_ALL`/`FullControl` 打开都被拒绝——收窄标志之前已在真实 runner 上实测确认。 + +标签在工作区根上**常驻**,与能力 ACE 同理:按会话回收会导致每次供给都重新传播整棵树,并与并发会话竞争。`revokeWrite` 只在该目录上不再留有其他能力授权时才清除标签,因此同一目录上的两份授权仍可独立撤销。幂等跳过要求精确 ACE、精确拒绝与精确标签三者齐备,因此早前版本已授权的工作区会在下次供给时补上拒绝项。 + +## 考虑过的替代方案 + +### 为什么不让标签按会话回收? + +那样可以消除下面的外部性,但每次供给都要付出确定性工作区 SID 本要避免的急切全树传播(大型工作区上以数十秒计),而且并发会话或崩溃会话会让标签反复消失与重建。 + +### 为什么 read-only 模式不用 Untrusted 完整性? + +本机实测:Untrusted(`S-1-16-0`)令牌根本无法启动 `pwsh`——DLL 初始化失败(`0x8007045A`,`BCrypt.dll`)——该模式将没有可用的 shell。 + +### 为什么不用 `OI|CI` 施加 `FILE_DELETE_CHILD` 拒绝? + +那样拒绝项也会落到授权根内的每个**文件**上(用 `icacls` 观察到的正是 `Everyone:(I)(DENY)(DC)`),而 `0x40` 属于 `FILE_ALL_ACCESS`,于是这些文件上的 `CreateFileW(GENERIC_ALL)` 会对用户、Administrators、SYSTEM 与 DSH host 一并返回 `ERROR_ACCESS_DENIED`。收窄标志消除了这一类影响;授权根内**目录**的 FullControl 打开仍被拒绝,这是拒绝一项属于完全访问掩码的权限所无法避免的代价。 + +### 为什么不干脆去掉这层约束、只把逃逸写进文档? + +逃逸本身就是报告内容:`cmd /c del` 删除工作区之外的文件,恰恰是沙箱承诺不允许的事;而仅靠读侧或纯 ACL 的改动关不掉它——restricting SID 交集在构造上就触及不到父目录路径。 + +### 为什么不让每个工作区拥有自己的完整性级别? + +强制完整性控制只有五个固定级别(Untrusted、Low、Medium、High、System),无法像 `S-1-4-x-y` 能力 SID 那样按工作区派生。 + +## 后果 + +所得:删除在 Windows 接受的每条授权路径上都被约束,且被约束在各自的授权根内;此前记录的 Everyone 写边界被关闭;写入、读取与进程可见性其余部分不变;每次 Win32 调用失败都 fail-closed。所失(全部记录在包 README 中):常驻 Low 标签会向**任何**以同一用户身份运行在 Low 完整性的进程放宽该工作区树,并比 DSH 生命期更长(这是在该完整性级别上拥有写边界本身的代价);被授权目录现在还必须授予 `WRITE_OWNER` 才能写入标签(完全控制的工作区具备,仅授予 Modify 的会大声失败);授权根内目录的 FullControl 打开被拒绝;被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 子进程不可读;FAT 类目标仍未验证。[受限令牌档笔记](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)仍是该档令牌列表、runner 约定与授权生命周期的归属者;本笔记只负责删除路径与封闭它的完整性层。 + +## 测试 + +`runner.spec.ts` 以真实 runner 与真实受限令牌钉住行为:两种模式下经 `cmd`、.NET、`Remove-Item` 与 libuv 的越界删除全部被拒且**宿主侧文件仍存在**;一个会话无法删除另一个授权根内的文件;NUL 在两种模式下仍可写;授权根内**文件**的 `GENERIC_ALL` 打开成功,而**目录**的同一打开被拒绝;同一目录上两份授权之一被撤销后,存留的授权仍可用。`acl.spec.ts` 钉住真实 DACL/标签生命周期、拒绝项只继承到容器、以及共享标签的撤销规则;`token-failure-paths.spec.ts` 钉住 `TokenIntegrityLevel` 的精确载荷;失败路径套件覆盖每处新增分配与提前退出,包括标签 ACL 与描述符的释放。 + +## 相关 + +- [Windows sandbox 档:原始 ACL 受限令牌](2026-08-08-windows-acl-restricted-token-sandbox.zh.md)——本次改动所扩展的档(保持 active;本笔记只取代其删除路径边界)。 +- [沙箱决策](2026-07-06-sandbox.zh.md)——平台链与 `partial` 强制执行词汇。 diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 7bb8ec7c19..c8eeb0c0bb 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write docs/subsystems/sandbox.md -sandbox.md: c29450d83c54d48a69f74f0b2fa38fe7c4f35ef2 -sandbox.zh.md: 71b55fcb8d46144e45daef717999d8905a78eec4 +# pnpm run verify-translation-pairing --write docs//subsystems//sandbox.md +sandbox.md: 05be79018f60e1e6af964449756322d283048c0d +sandbox.zh.md: 843be48f65ebe47b6c3307f5914ea011191e0bcb diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index c29450d83c..05be79018f 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -27,7 +27,7 @@ Only the first two modes can be sent to a provider. A `danger-full-access` consu type ConfinedSandboxMode = Exclude ``` -Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. Older Landlock ABIs and the Windows ACL runner's Everyone/hard-link boundaries are current partial cases. +Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. Older Landlock ABIs and the Windows ACL runner's hard-link, unconfined-read, and AppContainer-ACL boundaries are current partial cases. ```ts type-equiv /** diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 71b55fcb8d..843be48f65 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -27,7 +27,7 @@ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' type ConfinedSandboxMode = Exclude ``` -强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。当前的部分强制执行情形包括较旧的 Landlock ABI,以及 Windows ACL runner 的 Everyone 与硬链接边界。 +强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。当前的部分强制执行情形包括较旧的 Landlock ABI,以及 Windows ACL runner 的硬链接、读取不受限与 AppContainer ACL 边界。 ```ts type-equiv /** diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 30cfbd2154..13441f23d7 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-local/README.md -README.md: 15da8baaa3ab7ce476f5c45c6207657915fd7815 -README.zh.md: 0e55553e1ff18ae6e91563c34eb30f4dab61f45c +# pnpm run verify-translation-pairing --write packages//sandbox//sandbox-local//README.md +README.md: 278f2e5810e0dee12b061b33d08716cdcf5b7ae2 +README.zh.md: a7854c30d03d6be84bc0dc5b2a7a4bd1e07f97e4 diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 15da8baaa3..278f2e5810 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -50,7 +50,7 @@ The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-a ### Confined execution and enforcement -With the provider mounted, a command runs under the mode you resolve per call. Enforcement is a reported fact, not a promise: `full` means the backend governs every promised file effect, while `partial` means it governs only a subset — the Windows ACL rung (Everyone and hard-link boundaries) and older Landlock ABIs are the current partial cases, so a consumer that requires an absolute boundary can reject or surface them. Denied file effects surface through the backend's denial dialect, and a runner that fails before executing the command reports a structured runner-failure signature. +With the provider mounted, a command runs under the mode you resolve per call. Enforcement is a reported fact, not a promise: `full` means the backend governs every promised file effect, while `partial` means it governs only a subset — the Windows ACL rung (hard-link, unconfined-read, and AppContainer-ACL boundaries) and older Landlock ABIs are the current partial cases, so a consumer that requires an absolute boundary can reject or surface them. Denied file effects surface through the backend's denial dialect, and a runner that fails before executing the command reports a structured runner-failure signature. ### Failures and recovery @@ -78,7 +78,7 @@ The `@deepseek-ai/node-addon-system/landlock-run` API supplies the platform laun The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists derived from the shared `writableRoots` helper, so exactly the mode's promised file effects are governed; every root is canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). -The Windows rung keeps one deterministic write SID and standing ACE per workspace, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because the restricted token must retain Everyone and NTFS hard links alias one file object across paths. +The Windows rung keeps one deterministic write SID and standing ACE per workspace, while every live session/workspace pair gets a random private temp directory with a distinct SID and revocable ACE — sessions sharing a workspace share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session. The rung reports `partial` enforcement because NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. When the built ACL runner is absent, source launch pins the `tsx/esm/api` loader and TypeScript path mapping to this installation. The command's working directory and ambient `TSX_TSCONFIG_PATH` cannot select the runner's source dependencies. @@ -127,7 +127,7 @@ No direct invalidation; the named consumers own any request-prefix changes. These limits define when the provider is a poor fit or needs special operational care. They are current package constraints, not a general platform comparison or a task backlog. -- **Windows ACL enforcement is partial** — the restricted token must retain Everyone for process initialization, so external objects granting Everyone write access remain writable; NTFS hard links also alias one file object across workspace and external paths. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full. +- **Windows ACL enforcement is partial** — NTFS hard links alias one file object across workspace and external paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full. - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. - **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. - **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 0e55553e1f..a7854c30d0 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -50,7 +50,7 @@ kind: "package-reference" ### 受限执行与强制执行 -挂载提供方后,命令在你逐调用解析的模式下运行。强制执行是报告的事实,而非承诺:`full` 表示后端管辖模式承诺的每个文件操作,`partial` 表示它只管辖子集——Windows ACL 档(Everyone 与硬链接边界)与较旧的 Landlock ABI 是当前的部分强制执行情形,因此需要绝对边界的消费方可以拒绝或向上暴露它们。被拒绝的文件操作通过后端的拒绝方言呈现,执行命令前失败的 runner 会报告结构化的 runner 失败签名。 +挂载提供方后,命令在你逐调用解析的模式下运行。强制执行是报告的事实,而非承诺:`full` 表示后端管辖模式承诺的每个文件操作,`partial` 表示它只管辖子集——Windows ACL 档(硬链接、读取不受限与 AppContainer ACL 边界)与较旧的 Landlock ABI 是当前的部分强制执行情形,因此需要绝对边界的消费方可以拒绝或向上暴露它们。被拒绝的文件操作通过后端的拒绝方言呈现,执行命令前失败的 runner 会报告结构化的 runner 失败签名。 ### 失败与恢复 @@ -78,7 +78,7 @@ bwrap profile 组合只读宿主根目录、全新 `/dev` 与私有 PID 命名 Seatbelt profile 默认允许,带 `(deny file-write*)` 与来自共享 `writableRoots` 辅助函数的写入 allow-list,因此恰好管辖模式承诺的文件操作;每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。 -Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为受限令牌必须保留 Everyone,且 NTFS 硬链接会把同一文件对象别名为多个路径。 +Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。 构建后的 ACL runner 缺失时,源码启动将 `tsx/esm/api` 加载器和 TypeScript 路径映射固定到本安装目录。命令的工作目录和环境中的 `TSX_TSCONFIG_PATH` 无法选择 runner 的源码依赖。 @@ -127,7 +127,7 @@ Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同 这些限制说明提供方何时不合适,或何时需要特别运维。它们是当前包约束,不是通用平台对比或任务积压。 -- **Windows ACL 只能实现部分强制执行**——受限令牌必须保留 Everyone 以完成进程初始化,因此授予 Everyone 写访问的外部对象仍可写;NTFS 硬链接也会使工作区路径与外部路径指向同一个文件对象。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。 +- **Windows ACL 只能实现部分强制执行**——NTFS 硬链接会使工作区路径与外部路径指向同一个文件对象,读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。 - **Landlock 可能只实现部分强制执行**——较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**——macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 - **runner 选择在提供方生命周期内缓存**——安装、移除或修复 runner 后,必须重载插件才能改变选择。 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 037dc90079..5f180668cf 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -15,8 +15,9 @@ * session); the private-temp ACEs are revoked on dispose. The runner * receives both SIDs (their presence marks the seam-managed contract) and * stops managing DACLs itself. The rung reports partial enforcement because - * WRITE_RESTRICTED must retain Everyone in its - * restricting list and NTFS hard links alias one file object across paths. + * NTFS hard links alias one file object across paths, reads stay unconfined, + * and a tree another AppContainer tool has ACL'd with a package SID is not + * readable by the Low-integrity child. * @module @deepseek-ai/dsh-sandbox-local */ @@ -178,11 +179,12 @@ const STATIC_ENFORCEMENT: Record = bwrap: 'full', landlock: 'full', seatbelt: 'full', - // WRITE_RESTRICTED needs Everyone in both restricting lists for process - // initialization. An external object that grants Everyone write access - // therefore remains writable, and NTFS hard links can alias a granted - // workspace file to a path outside it. The backend enforces the remaining - // ACL-addressable surface but must not advertise the absolute promise. + // Everyone stays in both restricting lists for process initialization, but + // the Low label denies the write authority it used to confer. NTFS hard + // links still alias a granted workspace file to a path outside it, reads + // stay unconfined, and an AppContainer-ACL'd tree is unreadable to the + // child: the backend enforces the remaining ACL-addressable surface but + // must not advertise the absolute promise. 'windows-acl': 'partial', } @@ -518,7 +520,8 @@ export class LocalSandboxProvider extends SandboxProvider { // every promised file effect by construction, so their passing probes // are always full enforcement; the Landlock launcher's probe report // distinguishes full from per-ABI-partial, while windows-acl is always - // partial for its documented Everyone and hard-link boundaries. + // partial for its documented hard-link, unconfined-read, and + // AppContainer-ACL boundaries. switch (runner) { case 'bwrap': { const probe = this.internals.probeBwrap ?? (() => defaultProbeBwrap(this.probeTimeoutMs)) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 42c27aa70c..0e8cc8e2b6 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/sandbox/sandbox-windows-acl/README.md -README.md: 41415249c2fcf05c17634f6930692b1a2a5960c8 -README.zh.md: 7c62b263fa553748c186f4c40d2fc0aa1259804f +# pnpm run verify-translation-pairing --write packages//sandbox//sandbox-windows-acl//README.md +README.md: 42b2290028c9138e38594ae23ce94fb3f0ebaa85 +README.zh.md: 6a99eec14adf65027f199079f7ae08d94b0636ad diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 41415249c2..42b2290028 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -113,11 +113,13 @@ When launched with the subprocess control marker, the runner forwards fd 7 throu - **Everyone stays in both restricting lists, but no longer confers write authority** — the keep-alive group is required for early DLL initialization and CNG; the Low label now denies an Everyone-granted write outside the labeled roots, so that former gap is closed. - **Inside a granted root, the capability ACE's DELETE bit is the only delete authority** — the grant denies `FILE_DELETE_CHILD` to the world SID, which also removes the ambient default: a file whose own DACL grants no DELETE is no longer deletable through its parent's rights, by the confined child or by the user's own processes. The user's ordinary deletes keep working because the workspace DACL grants them DELETE directly. +- **The deny inherits to subdirectories only, and a FullControl open of one is denied** — `FILE_DELETE_CHILD` is evaluated on directories, so the deny carries `CONTAINER_INHERIT_ACE` and never reaches files (its bit, `0x40`, is part of `FILE_ALL_ACCESS`, so a file-level copy would refuse every `GENERIC_ALL`/`FullControl` open by the user, Administrators, SYSTEM, or the DSH host). Directories inside a granted root keep the deny and therefore refuse those opens; `DELETE`-based deletes, `MAXIMUM_ALLOWED`, and ordinary read/write opens are unaffected, and both outcomes are pinned by the runner suite. - **Writes and deletes are restricted; reads, network, and process visibility are not** — neither layer intersects reads, so a confined child can read any caller-readable file (including files in another workspace) and open sockets; `read-only` therefore needs a read-side policy to be expressed. - **Hard links are file-object aliases, not path aliases** — an inheritable workspace grant propagated onto an existing hard link labels and grants the one underlying file security descriptor, so the same object is writable through an external alias; rejecting multiply-linked files is not viable for ordinary pnpm installations. - **Console isolation is unavailable** — children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`); children share the host console, and pipe-based stdio redirection is unaffected. -- **Security descriptor edits are standing directory mutations** — workspace ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, and the deny a revoke leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. -- **Granted directories must be caller-owned** — the owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. +- **Security descriptor edits are standing directory mutations** — workspace ACEs, denies, and labels stand by design (the reuse cache, never revoked); temp edits are revoked by `dispose()`, except that a revoke keeps the shared label while another capability grant remains on the directory, and the deny it leaves behind disappears with the temp directory itself; manual `icacls` cleanup cannot revoke them on this platform (`ERROR_NONE_MAPPED`, 1332), so revoke through this module. +- **A standing Low label outlives DSH and widens the tree for other Low-integrity processes** — the workspace's inheritable label survives the session (and same-volume moves), so any other process running at Low integrity as the same user — another product's Low-IL sandbox, a Protected Mode reader — can write and delete inside the workspace, where a Medium label would have denied it. The label is the price of the write boundary: without it the confined child cannot write at all, and revoking it per session would re-propagate the whole tree on every provision. +- **Granted directories must be caller-owned and grant `WRITE_OWNER`** — the owner's implicit rights cover only `READ_CONTROL` and `WRITE_DAC`; the label lives in the SACL, so the combined apply additionally needs `WRITE_OWNER` (a Full-control directory, the normal workspace case, has it). A directory whose DACL grants only Modify now fails the grant loudly instead of silently skipping the confinement. - **The ambient temp root is never granted implicitly** — direct callers must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or disable temp writes with `tempDir: null`; the actual temp directory must be disjoint from every writable root. - **The confined child's temp capability is private per live session/workspace pair** — the runner rewrites TMP/TEMP to that private directory before the spawn; two tokens sharing the same workspace SID cannot write one another's temp directories. - **`whoami` and token-inspection cmdlets may fail under the restricted token** — `GetTokenInformation` on the duplicate is partially unavailable to the child, so whether a reporting cmdlet works is host-dependent; it is diagnostic noise rather than an operational failure. @@ -174,7 +176,7 @@ These limits define when the backend is a poor fit or needs special operational - **Piped stdio capture is impossible for confined grandchildren** — libuv's pipe stdio uses named pipes, whose client-end open requests write access no restricting SID is granted (the Win32 layer's default SD template, not the token default DACL), so `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM; inherited and ignored stdio spawns work, and anonymous pipes (PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE. - **Grant materialization is an eager full-tree propagation** — `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (tens of seconds on large workspace trees); the per-workspace identity pays it once per workspace per machine, and the exact-ACE skip makes every later provision cheap. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. -- **Reads stop at objects another AppContainer-based tool has ACL'd with a package SID** — Windows denies a Low-integrity token access to a file whose DACL carries an ACE for a package SID (`S-1-15-2-…`), even when that DACL also grants the user full control and Everyone read. Tools that sandbox themselves with AppContainers grant exactly such ACEs, so a project tree stamped by one of them becomes unreadable to this backend's child. Removing the foreign ACE (or re-installing the affected tree) restores access; the package cannot grant it back. +- **Reads stop at objects another AppContainer-based tool has ACL'd with a package SID** — a file whose DACL carries an ACE for a package SID (`S-1-15-2-…`) is inaccessible to a Low-integrity token on this host, even when the same DACL grants the user full control and Everyone read (observed: adding that single ACE to a fresh file reproduces the denial, granting Everyone read does not lift it, and the same bytes copied elsewhere stay readable). The kernel rule behind it is unconfirmed and not this package's to change; tools that sandbox themselves with AppContainers stamp such ACEs, so a tree they touched becomes unreadable to this backend's child. Removing the foreign ACE (or re-installing the affected tree) restores access. - **Wide-directory and FAT-volume warnings are deferred; the FAT-class residue is unverified** — the UI-side warnings are not implemented, a FAT volume as a grant root fails loudly, and a FAT-class target outside the granted roots stores no security descriptor; its effective integrity label is assigned by the system rather than recorded on the object, so the label layer's behavior there is untested. FAT stays legacy residue. - **PowerShell language mode differs by confined mode** — under `read-only`, PowerShell cannot create its AppLocker probe files in temp and conservatively starts in ConstrainedLanguage (`Add-Type`, non-core .NET static calls, COM, and reflection fail); the shipped `workspace-write` path lets the probe complete, so pwsh stays in FullLanguage unless host-wide WDAC/AppLocker policy says otherwise, while a direct `AclSandbox` with `tempDir: null` has no such guarantee. This split is PowerShell startup behavior, not part of the ACL write boundary. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 7c62b263fa..6a99eec14a 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -113,11 +113,13 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 - **Everyone 仍留在两种 restricting 列表中,但不再带来写权限。** 保活组是早期 DLL 初始化与 CNG 所必需的;如今 Low 标签会拒绝对被标记根目录之外、由 Everyone 授权的写入,因此这一旧缺口已关闭。 - **在授权根目录内,能力 ACE 的 DELETE 位是唯一的删除授权来源。** 授权会向 world SID 拒绝 `FILE_DELETE_CHILD`,这同时移除了环境性默认行为:自身 DACL 未授予 DELETE 的文件不再能凭父目录权限删除——受限子进程与用户自身进程皆然。用户日常删除仍然可用,因为工作区 DACL 直接向其授予 DELETE。 +- **拒绝项只继承到子目录,且子目录的 FullControl 打开会被拒。** `FILE_DELETE_CHILD` 只在目录上被评估,因此该 ACE 带 `CONTAINER_INHERIT_ACE`、绝不落到文件上(它的位 `0x40` 属于 `FILE_ALL_ACCESS`,若落到文件上会让用户、Administrators、SYSTEM 或 DSH host 的每次 `GENERIC_ALL`/`FullControl` 打开都被拒绝)。授权根内的目录保留该拒绝项,因而会拒绝这类打开;基于 `DELETE` 的删除、`MAXIMUM_ALLOWED` 与常规读写打开不受影响——两种结果都已被 runner 套件钉住。 - **写入与删除受限;读取、网络与进程可见性不受限。** 两层都不交叉检查读取,因此受限子进程可以读取调用者可读的任何文件(包括其他工作区中的文件)并打开套接字;`read-only` 因而需要读侧策略才能表达。 - **硬链接是文件对象别名,而非路径别名。** 传播到已有硬链接上的可继承工作区授权会标记并授权底层同一文件的安全描述符,因此同一对象也可通过外部别名写入;拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接。 - **控制台隔离不可用。** 以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡;子进程共享宿主控制台,基于管道的 stdio 重定向不受影响。 -- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销,撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 -- **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙箱无需提权即可编辑 DACL 的原因。 +- **安全描述符改动是对真实目录的驻留改动。** 工作区 ACE、拒绝项与标签按设计常驻(复用缓存,绝不撤销);临时改动由 `dispose()` 撤销——但若该目录上仍留有其他能力授权,撤销会保留共享的 Low 标签——撤销后残留的那条拒绝项会随临时目录本身一并消失;手工 `icacls` 清理无法在本平台回收它们(`ERROR_NONE_MAPPED` 1332),请通过本模块回收。 +- **常驻 Low 标签的生命期长于 DSH,并会向其他 Low 完整性的进程放宽该目录树。** 工作区的可继承标签在会话结束后(以及同卷移动后)依然存在,因此任何以同一用户身份运行在 Low 完整性的其他进程——别家产品的 Low-IL 沙箱、受保护模式阅读器——都能写入与删除工作区内的内容,而在 Medium 标签下这会被拒绝。这个标签是写边界的代价:没有它受限子进程根本无法写入,而按会话回收会导致每次供给都要重新传播整棵树。 +- **被授权目录必须由调用者拥有并授予 `WRITE_OWNER`。** 所有者隐式获得的只有 `READ_CONTROL` 与 `WRITE_DAC`;标签位于 SACL,因此合并应用还需要 `WRITE_OWNER`(完全控制目录——即正常工作区情形——本就具备)。DACL 只授予 Modify 的目录现在会大声失败,而不是静默跳过隔离。 - **环境临时根目录绝不会被隐式授权。** 直接调用方必须提供已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或用 `tempDir: null` 禁用临时写入;实际临时目录不得与任何可写根目录重叠。 - **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前把 TMP/TEMP 改写为该私有目录;共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 - **受限令牌下 `whoami` 与令牌检查 cmdlet 可能失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此这类报告型 cmdlet 是否可用取决于宿主环境;这是诊断噪音而非运行故障。 @@ -174,7 +176,7 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 - **受限孙进程的管道 stdio 捕获不可用。** libuv 的管道 stdio 用的是 NAMED pipe,其 client 端打开所请求的写访问没有任何 restricting SID 被授予(是 Win32 层的默认 SD 模板,而非令牌默认 DACL),因此受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败;继承与忽略 stdio 的 spawn 可用,匿名管道(PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE 而可用。 - **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(大型工作区树上以数十秒计);按工作区身份每台机器每个工作区只付一次,精确 ACE 跳过让后续每次供给都很便宜。 - **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 -- **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,即使该 DACL 同时向用户授予完全控制、向 Everyone 授予读取,Windows 也会拒绝 Low 完整性的令牌访问它。以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的项目树对本后端的子进程将变得不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问;本包无法反向授予。 +- **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 在本机上,当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,Low 完整性的令牌无法访问它——即使同一份 DACL 同时向用户授予完全控制、向 Everyone 授予读取(已观测:只给新文件加这一条 ACE 即可复现拒绝,补授 Everyone 读取无法解除,而同样内容复制到别处仍可读)。其背后的内核规则尚未确证,也不由本包掌控;以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的目录树对本后端的子进程将不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问。 - **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。 - **PowerShell 语言模式因受限模式而异。** 在 `read-only` 下,PowerShell 无法在临时目录中创建 AppLocker 探针文件,因此会保守地以 ConstrainedLanguage 启动(`Add-Type`、非核心 .NET 静态调用、COM 与反射失败);交付的 `workspace-write` 路径可让探针完成,因此除非主机范围的 WDAC/AppLocker 策略另有规定,否则 pwsh 保持 FullLanguage,而直接使用 `AclSandbox` 并配置 `tempDir: null` 时则没有这一保证。这一区别属于 PowerShell 启动行为,不是 ACL 写入边界的一部分。 diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index 199e9e394b..82f7030b67 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -34,17 +34,26 @@ import * as abi from './win32-abi.ts' * perms@0, mode@4, inheritance@8, Trustee@16 { pMultipleTrustee@16, * MultipleTrusteeOperation@24, TrusteeForm@28, TrusteeType@32, ptstrName@40 }. * `permissions` is the access mask; the POC passes 0 for REVOKE_ACCESS, which - * removes every ACE for the trustee. + * removes every ACE for the trustee. `inheritance` defaults to children of + * both kinds; the ambient-delete deny narrows it to containers because + * FILE_DELETE_CHILD is meaningless on a file and its bit would otherwise + * spread through the file's inherited mask. * @param sidPtr - the trustee SID the entry names. - * @param mode - the access mode (GRANT_ACCESS or REVOKE_ACCESS). - * @param permissions - the access mask to grant (0 for REVOKE_ACCESS). + * @param mode - the access mode (GRANT_ACCESS, DENY_ACCESS, or REVOKE_ACCESS). + * @param permissions - the access mask to grant or deny (0 for REVOKE_ACCESS). + * @param inheritance - the ACE inheritance flags. * @returns the packed entry buffer. */ -export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions: number): Buffer { +export function buildExplicitAccess( + sidPtr: NativePtr, + mode: number, + permissions: number, + inheritance: number = abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, +): Buffer { const entry = Buffer.alloc(abi.EXPLICIT_ACCESS_W_SIZE) entry.writeUInt32LE(permissions, 0) // grfAccessPermissions entry.writeUInt32LE(mode, 4) // grfAccessMode - entry.writeUInt32LE(abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, 8) // grfInheritance: OI|CI + entry.writeUInt32LE(inheritance, 8) // grfInheritance entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType @@ -181,44 +190,39 @@ export function buildLowLabelAcl(api: Win32Bindings, lowLabelSidPtr: NativePtr): /** * True when the label ACL already carries the EXACT label this module would * add (mandatory-label ACE, OI|CI inheritance, no-write-up policy, the Low - * SID), so a re-grant can skip the eager full-tree propagation. The ACE - * layout matches {@link hasExactGrant} (header@0, Mask@4, inline SID@8), and a - * malformed header reads as "no exact label" so the caller falls back to the - * apply path, which owns the robust failure handling. + * SID), so a re-grant can skip the eager full-tree propagation. * @param labelAcl - the current label ACL pointer (from {@link readCurrentSecurity}). * @param lowLabelSidPtr - the Low integrity SID to match. * @returns whether the exact label ACE is already present. */ function hasExactLabel(labelAcl: NativePtr, lowLabelSidPtr: NativePtr): boolean { - const aclSize = decodeUint16At(labelAcl, 2) - const aceCount = decodeUint16At(labelAcl, 4) - if (aclSize < abi.ACL_HEADER_SIZE || aclSize > 1_048_576) return false // implausible: fall back to the apply path - let offset = abi.ACL_HEADER_SIZE // the first ACE follows the ACL header - for (let index = 0; index < aceCount; index++) { - const aceSize = decodeUint16At(labelAcl, offset + 2) - if (aceSize < abi.ACL_HEADER_SIZE || offset + aceSize > aclSize) return false // implausible: fall back to the apply path - const exact = decodeUint8At(labelAcl, offset) === abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE - && decodeUint8At(labelAcl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT - && decodeUint32At(labelAcl, offset + 4) === abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP - if (exact && sameSidAt(labelAcl, offset + 8, lowLabelSidPtr, 0)) return true - offset += aceSize - } - return false + return hasExactEntry( + labelAcl, abi.SYSTEM_MANDATORY_LABEL_ACE_TYPE, abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, + abi.SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, lowLabelSidPtr, + ) } +/** + * The mandatory-label half of an apply: set the given label ACL, clear the + * label, or leave it untouched. `clear` is what a revoke does when the + * directory carries no other capability grant; `keep` is what it does while + * another grant still relies on the shared Low level. + */ +type LabelEdit = { kind: 'apply'; acl: NativePtr } | { kind: 'clear' } | { kind: 'keep' } + /** * Shared tail of grantWrite and revokeWrite: merge `entries` into `oldAcl` * (null = no explicit DACL yet; SetEntriesInAclW builds one from scratch), * free the descriptor before applying the merged ACL, apply the merged DACL - * together with `labelAcl` in one SetNamedSecurityInfoW call (null clears the - * label), then free both ACLs — checking every call and reporting with the + * together with the label edit in one SetNamedSecurityInfoW call, then free + * every ACL this call owns — checking each call and reporting with the * caller's label. The entry count derives from the buffer, so a grant can * carry its capability ACE and its ambient-delete deny in one merge. * @param api - the binding table. * @param path - the directory the DACL and label edits apply to. * @param entries - packed EXPLICIT_ACCESS_W records to merge (grant, deny, or revoke). * @param oldAcl - the current explicit DACL (from {@link readCurrentSecurity}). - * @param labelAcl - the label ACL to apply, or null to clear the mandatory label. + * @param labelEdit - the label change to apply alongside the DACL. * @param descriptor - the descriptor allocation owning `oldAcl`. * @param label - the caller's name for error details. */ @@ -227,7 +231,7 @@ function mergeAndApply( path: string, entries: Buffer, oldAcl: NativePtr | null, - labelAcl: NativePtr | null, + labelEdit: LabelEdit, descriptor: NativePtr | null, label: string, ): void { @@ -235,11 +239,13 @@ function mergeAndApply( const mergeResult = api.setEntriesInAclW(entries.length / abi.EXPLICIT_ACCESS_W_SIZE, entries, oldAcl, newAclSlot) if (mergeResult !== abi.ERROR_SUCCESS) { if (descriptor !== null) api.localFree(descriptor) // frees the ACL block too + if (labelEdit.kind === 'apply') api.localFree(labelEdit.acl) throwWin32(api, 'SetEntriesInAclW', mergeResult, `${label}(${path})`) } const newAcl = decodePtr(newAclSlot) if (newAcl === null) { if (descriptor !== null) api.localFree(descriptor) + if (labelEdit.kind === 'apply') api.localFree(labelEdit.acl) throwWin32(api, 'SetEntriesInAclW', api.getLastError(), `${label}(${path}): null new ACL`) } @@ -247,11 +253,12 @@ function mergeAndApply( // before applying, exactly like the POC. const freedDescriptor = descriptor !== null ? api.localFree(descriptor) : null const applyResult = api.setNamedSecurityInfoW( - path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION | abi.LABEL_SECURITY_INFORMATION, - null, null, newAcl, labelAcl, + path, abi.SE_FILE_OBJECT, + labelEdit.kind === 'keep' ? abi.DACL_SECURITY_INFORMATION : abi.DACL_SECURITY_INFORMATION | abi.LABEL_SECURITY_INFORMATION, + null, null, newAcl, labelEdit.kind === 'apply' ? labelEdit.acl : null, ) const freedNew = api.localFree(newAcl) - const freedLabel = labelAcl !== null ? api.localFree(labelAcl) : null + const freedLabel = labelEdit.kind === 'apply' ? api.localFree(labelEdit.acl) : null if (applyResult !== abi.ERROR_SUCCESS) throwWin32(api, 'SetNamedSecurityInfoW', applyResult, `${label}(${path})`) if (freedDescriptor !== null && !isNullPtr(freedDescriptor)) throwLastError(api, 'LocalFree', `${label}(${path}) descriptor`) if (!isNullPtr(freedNew)) throwLastError(api, 'LocalFree', `${label}(${path}) new ACL`) @@ -260,10 +267,10 @@ function mergeAndApply( /** * True when the explicit DACL already carries the EXACT entry - * `(aceType, OI|CI inheritance, mask, trustee SID)`. Every field is read - * through koffi.decode at pointer offsets — no memcpy, no pointer arithmetic. - * The ACE's SID is INLINE (embedded in the ACE after the 4-byte mask — there - * is no pointer to read; reading one yields garbage addresses and crashed + * `(aceType, inheritance, mask, trustee SID)`. Every field is read through + * koffi.decode at pointer offsets — no memcpy, no pointer arithmetic. The + * ACE's SID is INLINE (embedded in the ACE after the 4-byte mask — there is + * no pointer to read; reading one yields garbage addresses and crashed * EqualSid, verified by gdb), so it is compared field-by-field against the * trustee SID through bounded offset reads ({@link sameSidAt}). Allowed and * denied ACEs share the Mask@4/SID@8 layout. A malformed header reads as "no @@ -271,11 +278,12 @@ function mergeAndApply( * the robust failure handling. * @param acl - the current explicit DACL pointer (from {@link readCurrentSecurity}). * @param aceType - the ACE type to match. + * @param inheritance - the ACE inheritance flags to match. * @param mask - the access mask to match. * @param sidPtr - the trustee SID to match. * @returns whether the exact entry is already present. */ -function hasExactEntry(acl: NativePtr, aceType: number, mask: number, sidPtr: NativePtr): boolean { +function hasExactEntry(acl: NativePtr, aceType: number, inheritance: number, mask: number, sidPtr: NativePtr): boolean { const aclSize = decodeUint16At(acl, 2) const aceCount = decodeUint16At(acl, 4) if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: fall back to the merge path @@ -285,7 +293,7 @@ function hasExactEntry(acl: NativePtr, aceType: number, mask: number, sidPtr: Na const aceSize = decodeUint16At(acl, offset + 2) if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: fall back to the merge path const exact = decodeUint8At(acl, offset) === aceType - && decodeUint8At(acl, offset + 1) === abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT + && decodeUint8At(acl, offset + 1) === inheritance && decodeUint32At(acl, offset + 4) === mask if (exact && sameSidAt(acl, offset + 8, sidPtr, 0)) return true offset += aceSize @@ -302,37 +310,64 @@ function hasExactEntry(acl: NativePtr, aceType: number, mask: number, sidPtr: Na * @returns whether the exact grant ACE is already present. */ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { - return hasExactEntry(oldAcl, abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sidPtr) + return hasExactEntry(oldAcl, abi.ACCESS_ALLOWED_ACE_TYPE, abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, abi.GRANT_MASK, sidPtr) } /** * True when the explicit DACL already carries the EXACT ambient-delete deny: - * the Deny ACE for {@link abi.FILE_DELETE_CHILD} naming the world SID. It is - * part of the idempotent skip, so a root granted by an earlier build receives - * the deny on its next provision. + * the container-inherited Deny ACE for {@link abi.FILE_DELETE_CHILD} naming + * the world SID. It is part of the idempotent skip, so a root granted by an + * earlier build receives the deny on its next provision. * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). * @param worldSidPtr - the Everyone SID the deny names. * @returns whether the exact deny ACE is already present. */ function hasExactDeny(oldAcl: NativePtr, worldSidPtr: NativePtr): boolean { - return hasExactEntry(oldAcl, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, worldSidPtr) + return hasExactEntry(oldAcl, abi.ACCESS_DENIED_ACE_TYPE, abi.CONTAINER_INHERIT_ACE, abi.FILE_DELETE_CHILD, worldSidPtr) +} + +/** + * True when a capability grant for a SID OTHER than `sidPtr` stands on this + * DACL — the condition under which a revoke must leave the shared Low label in + * place, or the remaining grant's child would lose its write authority. + * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentSecurity}). + * @param sidPtr - the capability SID being revoked. + * @returns whether another capability grant remains. + */ +function hasForeignGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { + const aclSize = decodeUint16At(oldAcl, 2) + const aceCount = decodeUint16At(oldAcl, 4) + if (aclSize < 8 || aclSize > 1_048_576) return false // implausible: leave the label alone + let offset = 8 + for (let index = 0; index < aceCount; index++) { + const aceSize = decodeUint16At(oldAcl, offset + 2) + if (aceSize < 8 || offset + aceSize > aclSize) return false // implausible: leave the label alone + const isGrant = decodeUint8At(oldAcl, offset) === abi.ACCESS_ALLOWED_ACE_TYPE + && decodeUint32At(oldAcl, offset + 4) === abi.GRANT_MASK + if (isGrant && !sameSidAt(oldAcl, offset + 8, sidPtr, 0)) return true + offset += aceSize + } + return false } /** * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID * on `path`, deny the world SID the ambient `FILE_DELETE_CHILD` right, and - * apply the Low mandatory label — one merge, inheriting to subcontainers and - * objects. The capability ACE's DELETE bit is then the only delete authority - * inside the root, so a file whose own DACL grants no DELETE is no longer - * deletable through its parent's rights. + * apply the Low mandatory label — one merge. The deny inherits to containers + * only: the right is evaluated on directories, and inheriting its bit onto + * files would deny every `FILE_ALL_ACCESS`/`GENERIC_ALL` open inside the root + * (0x40 is a member of that mask). The capability ACE's DELETE bit is then the + * only delete authority inside the root, so a file whose own DACL grants no + * DELETE is no longer deletable through its parent's rights. * * Idempotent: the exact ACE, deny, and label together SKIP the * SetNamedSecurityInfoW apply, which would otherwise re-propagate the * identical descriptor across the whole tree (eager inheritance; minutes on * large workspaces). Otherwise read-merge-write, so pre-existing explicit ACEs * survive (same shape as {@link revokeWrite}). Runs under the per-path lock. - * The directory must be owned by the caller (owner implicit WRITE_DAC) — same - * precondition as the POC. + * The directory must be owned by the caller AND grant WRITE_OWNER (the label + * lives in the SACL; owner-implicit rights cover only READ_CONTROL and + * WRITE_DAC) — a Full-control workspace satisfies both. * @param api - the binding table. * @param path - the directory whose DACL and label gain the grant (the workspace or temp root). * @param sidPtr - the capability SID the ACE names. @@ -358,27 +393,38 @@ export function grantWrite( } return } + let label: NativePtr + try { + label = buildLowLabelAcl(api, lowLabelSidPtr) + } catch (error) { + // The read already owns a descriptor allocation; release it before the + // label failure propagates. + if (descriptor !== null) api.localFree(descriptor) + throw error + } mergeAndApply( api, path, Buffer.concat([ - buildExplicitAccess(worldSidPtr, abi.DENY_ACCESS, abi.FILE_DELETE_CHILD), + buildExplicitAccess(worldSidPtr, abi.DENY_ACCESS, abi.FILE_DELETE_CHILD, abi.CONTAINER_INHERIT_ACE), buildExplicitAccess(sidPtr, abi.GRANT_ACCESS, abi.GRANT_MASK), ]), - oldAcl, buildLowLabelAcl(api, lowLabelSidPtr), descriptor, 'grantWrite', + oldAcl, { kind: 'apply', acl: label }, descriptor, 'grantWrite', ) }) } /** * Remove every ACE for the capability SID from the directory DACL (REVOKE_ACCESS - * merge — other entries are preserved) and clear the Low mandatory label in the - * same call. Returns whether an ACE removal was attempted (false when the + * merge — other entries are preserved). The shared Low label is cleared only + * when no other capability grant remains on the directory: two grants may + * target one directory, and the surviving one still needs the label for its + * child's writes. Returns whether an ACE removal was attempted (false when the * directory carries no DACL at all). * * Runs under the per-path lock (the whole get-merge-set sequence); the * descriptor/ACL allocation contract lives on {@link readCurrentSecurity}. * @param api - the binding table. - * @param path - the directory whose DACL loses the capability-SID ACEs and whose label is cleared. + * @param path - the directory whose DACL loses the capability-SID ACEs. * @param sidPtr - the capability SID whose ACEs are removed. * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). */ @@ -392,7 +438,11 @@ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr) } return false } - mergeAndApply(api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, null, descriptor, 'revokeWrite') + mergeAndApply( + api, path, buildExplicitAccess(sidPtr, abi.REVOKE_ACCESS, 0), oldAcl, + hasForeignGrant(oldAcl, sidPtr) ? { kind: 'keep' } : { kind: 'clear' }, + descriptor, 'revokeWrite', + ) return true }) } diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index 812fec2a43..7f7bcc4c57 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -7,7 +7,8 @@ * * Fail-closed: `add` throws on any grant failure and the caller disposes the * instance (revoking every path granted so far); `dispose` revokes every - * standing grant and reports every cleanup failure. + * revocable grant, leaves the standing workspace edits in place, and reports + * every cleanup failure. * @module @deepseek-ai/dsh-sandbox-windows-acl/grant */ @@ -69,13 +70,21 @@ export class AclWriteGrant { } const sidPtr = decodePtr(sidSlot) if (sidPtr === null) throwLastError(bindings, 'ConvertStringSidToSidW', `null SID for ${writeSid}`) - return new AclWriteGrant( - bindings, - sidPtr, - makeWellKnownSid(bindings, abi.WinLowLabelSid), - makeWellKnownSid(bindings, abi.WinWorldSid), - writeSid, - ) + try { + const lowLabelSidPtr = makeWellKnownSid(bindings, abi.WinLowLabelSid) + try { + const worldSidPtr = makeWellKnownSid(bindings, abi.WinWorldSid) + return new AclWriteGrant(bindings, sidPtr, lowLabelSidPtr, worldSidPtr, writeSid) + } catch (error) { + // The Low label SID is LocalAlloc'd: release it before the world-SID + // failure propagates to the sidPtr release below. + bindings.localFree(lowLabelSidPtr) + throw error + } + } catch (error) { + bindings.localFree(sidPtr) + throw error + } } /** diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index 10674afb26..d78bac049d 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -155,6 +155,7 @@ export function setTokenDefaultDaclGrant(api: Win32Bindings, token: NativePtr, s */ export function restrictTokenIntegrity(api: Win32Bindings, token: NativePtr, lowLabelSidPtr: NativePtr): void { const sidLength = api.getLengthSid(lowLabelSidPtr) + if (sidLength === 0) throwLastError(api, 'GetLengthSid', 'Low integrity label SID') const info = Buffer.alloc(abi.TOKEN_MANDATORY_LABEL_SIZE + sidLength) info.writeBigUInt64LE(ptrAddress(lowLabelSidPtr), 0) // Label.Sid info.writeUInt32LE(abi.SE_GROUP_INTEGRITY, 8) // Label.Attributes diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index bf8ad49851..2968ff5b1b 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -85,6 +85,8 @@ export const DENY_ACCESS = 3 export const REVOKE_ACCESS = 4 /** ACE inheritance flags for child containers and objects. */ export const SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x3 +/** ACE inheritance flag for child containers only (directories; files do not inherit). */ +export const CONTAINER_INHERIT_ACE = 0x2 /** Legacy Win32 maximum path character count used by GetTempPathW. */ export const MAX_PATH = 260 /** Successful Win32 status code. */ diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index 88306142c0..6b34293fcd 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -79,9 +79,17 @@ function craftWorldSid(): NativePtr { } /** Write one 16-byte ACE (AceType@0, AceFlags@1, AceSize@2, Mask@4, inline SID@8) at `offset`. */ -function writeAce(acl: NativePtr, offset: number, aceType: number, mask: number, sid: NativePtr, match: boolean): void { +function writeAce( + acl: NativePtr, + offset: number, + aceType: number, + mask: number, + sid: NativePtr, + match: boolean, + inheritance: number = abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT, +): void { koffi.encode(acl, offset + 0, 'uint8', aceType) - koffi.encode(acl, offset + 1, 'uint8', abi.SUB_CONTAINERS_AND_OBJECTS_INHERIT) + koffi.encode(acl, offset + 1, 'uint8', inheritance) koffi.encode(acl, offset + 2, 'uint16', 16) koffi.encode(acl, offset + 4, 'uint32', mask) for (let byte = 0; byte < 8; byte++) { @@ -122,7 +130,7 @@ function craftPair( koffi.encode(acl, 'uint8', 2) koffi.encode(acl, 2, 'uint16', 40) koffi.encode(acl, 4, 'uint16', 2) - writeAce(acl, 8, denyType, denyMask, world, denyMatches) + writeAce(acl, 8, denyType, denyMask, world, denyMatches, abi.CONTAINER_INHERIT_ACE) writeAce(acl, 24, abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, grantSid, grantMatches) return acl } @@ -405,6 +413,161 @@ describe('mergeAndApply failure paths', () => { expect((caught as Win32Error).api).toBe('LocalFree') expect(localFree).toHaveBeenLastCalledWith(11n) }) + + it('frees the label ACL when SetEntriesInAclW fails during a revoke', () => { + // The revoke carries no label ACL of its own: the early exit must still + // release the descriptor and leave the label edit untouched. + const localFree = vi.fn(() => 0n as NativePtr) + const sid = craftSid(1, 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, true), null, 6n), + setEntriesInAclW: vi.fn(() => 5), + localFree, + }) + let caught: unknown + try { + revokeWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('frees the descriptor on the null-merged-ACL path during a revoke', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const sid = craftSid(1, 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftAcl(abi.ACCESS_ALLOWED_ACE_TYPE, abi.GRANT_MASK, sid, true), null, 6n), + setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot + localFree, + }) + let caught: unknown + try { + revokeWrite(api, 'C:\\granted', sid) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + }) + + it('frees the descriptor when the label ACL build fails', () => { + // The read already owns a descriptor allocation; the label failure must + // not strand it. + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: readStub(null, null, 6n), + initializeAcl: vi.fn(() => 0), + localFree, + }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('InitializeAcl') + expect(localFree).toHaveBeenCalledWith(11n) // the half-built label ACL + expect(localFree).toHaveBeenCalledWith(6n) // the read descriptor + }) + + it('frees the label ACL when SetEntriesInAclW fails', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ setEntriesInAclW: vi.fn(() => 5), localFree }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(11n) // the label ACL this apply owns + }) + + it('frees the label ACL and the descriptor on the null-merged-ACL path', () => { + const localFree = vi.fn(() => 0n as NativePtr) + const api = aclApi({ + getNamedSecurityInfoW: readStub(null, null, 6n), + setEntriesInAclW: vi.fn(() => 0), // success without writing the out slot + localFree, + }) + let caught: unknown + try { + grantWrite(api, 'C:\\granted', craftSid(1, 0), craftLowLabelSid(), craftWorldSid()) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('SetEntriesInAclW') + expect(localFree).toHaveBeenCalledWith(6n) + expect(localFree).toHaveBeenCalledWith(11n) + }) +}) + +describe('revokeWrite label handling', () => { + /** The SECURITY_INFORMATION and SACL of the last apply. */ + function applyArgs(api: Win32Bindings): { information: number; sacl: unknown } { + const call = (api.setNamedSecurityInfoW as unknown as ReturnType).mock.calls.at(-1) + return { information: call?.[2] as number, sacl: call?.[6] } + } + + it('keeps the shared Low label while another capability grant stands', () => { + const sid = craftSid(1, 0) + const otherSid = craftSid(1, 0, [0, 0, 0, 0, 0, 6]) + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + // The directory carries the deny plus a grant for a DIFFERENT SID. + getNamedSecurityInfoW: readStub( + craftPair(otherSid, world, abi.ACCESS_DENIED_ACE_TYPE, abi.FILE_DELETE_CHILD, true, true), null, 6n, + ), + setNamedSecurityInfoW, + }) + expect(revokeWrite(api, 'C:\\granted', sid)).toBe(true) + const { information, sacl } = applyArgs(api) + expect(information & abi.LABEL_SECURITY_INFORMATION).toBe(0) // the label was left untouched + expect(sacl).toBeNull() + }) + + it('clears the Low label once the last capability grant is revoked', () => { + const sid = craftSid(1, 0) + const world = craftWorldSid() + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ + getNamedSecurityInfoW: readStub(craftGrantedAcl(sid, world, true), null, 6n), + setNamedSecurityInfoW, + }) + expect(revokeWrite(api, 'C:\\granted', sid)).toBe(true) + expect(applyArgs(api).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + }) + + it('clears the Low label when a malformed tiny DACL hides any foreign grant', () => { + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 4) // smaller than the ACL header + koffi.encode(acl, 4, 'uint16', 1) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ getNamedSecurityInfoW: readStub(acl, null, 6n), setNamedSecurityInfoW }) + expect(revokeWrite(api, 'C:\\granted', craftSid(1, 0))).toBe(true) + expect(applyArgs(api).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + }) + + it('clears the Low label when a lying ACE size hides any foreign grant', () => { + const acl = allocBytes(32) + koffi.encode(acl, 'uint8', 2) + koffi.encode(acl, 2, 'uint16', 8) + koffi.encode(acl, 4, 'uint16', 1) + koffi.encode(acl, 10, 'uint16', 100) + const setNamedSecurityInfoW = vi.fn(() => 0) + const api = aclApi({ getNamedSecurityInfoW: readStub(acl, null, 6n), setNamedSecurityInfoW }) + expect(revokeWrite(api, 'C:\\granted', craftSid(1, 0))).toBe(true) + expect(applyArgs(api).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + }) }) describe('the exact-ACE/exact-label skip and ACL-walk defenses', () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 9d5e6e697a..3bf86f4d43 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -9,7 +9,7 @@ * whose per-test lock file is removed in cleanup. */ -import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -157,6 +157,51 @@ function readLabelAces(api: Win32Bindings, path: string): DirectLabelAce[] { } } +/** One ACE of a directory DACL, inherited or not. */ +interface TypedAce { + sid: string + mask: number + type: number + flags: number +} + +/** + * Read every ACE of the object's DACL (inherited ones included) — the shape + * needed to observe what a grant propagated onto children. + */ +function readTypedAces(api: Win32Bindings, path: string): TypedAce[] { + const ownerSlot = allocPtrSlot() + const groupSlot = allocPtrSlot() + const daclSlot = allocPtrSlot() + const saclSlot = allocPtrSlot() + const descriptorSlot = allocPtrSlot() + const readResult = api.getNamedSecurityInfoW( + path, abi.SE_FILE_OBJECT, abi.DACL_SECURITY_INFORMATION, + ownerSlot, groupSlot, daclSlot, saclSlot, descriptorSlot, + ) + if (readResult !== abi.ERROR_SUCCESS) throw new Error(`GetNamedSecurityInfoW failed (${readResult}) for ${path}`) + const acl = decodePtr(daclSlot) + const descriptor = decodePtr(descriptorSlot) + try { + if (acl === null) return [] + const aclSize = koffi.decode(acl, 2, 'uint16') as number + const aces: TypedAce[] = [] + for (let offset = 8; offset + 8 <= aclSize;) { + const aceSize = koffi.decode(acl, offset + 2, 'uint16') as number + aces.push({ + sid: sidString(koffi.decode(acl, offset + 8, SID_STRUCT) as SidLayout), + mask: koffi.decode(acl, offset + 4, 'uint32') as number, + type: koffi.decode(acl, offset, 'uint8') as number, + flags: koffi.decode(acl, offset + 1, 'uint8') as number, + }) + offset += aceSize + } + return aces + } finally { + if (descriptor !== null) api.localFree(descriptor) + } +} + describe.skipIf(!isWin32)('ACL editing', () => { const scratchDirs: string[] = [] afterEach(() => { @@ -169,6 +214,59 @@ describe.skipIf(!isWin32)('ACL editing', () => { return dir } + it('the ambient-delete deny inherits to subdirectories only, never onto files', async () => { + // FILE_DELETE_CHILD is meaningless on a file and 0x40 is part of + // FILE_ALL_ACCESS, so a file-level copy would deny every FullControl open + // inside the granted root. + const api = await win32() + const dir = scratch() + const capabilitySid = sidFromString(api, 'S-1-4-4242-11') + const lowSid = lowLabelSid(api) + const world = worldSid(api) + const childDir = join(dir, 'child') + const childFile = join(dir, 'child.txt') + try { + grantWrite(api, dir, capabilitySid, lowSid, world) + mkdirSync(childDir) + writeFileSync(childFile, 'x') + const isWorldDeny = (ace: TypedAce): boolean => + ace.type === abi.ACCESS_DENIED_ACE_TYPE && ace.sid === 'S-1-1-0' && ace.mask === abi.FILE_DELETE_CHILD + expect(readTypedAces(api, dir).filter(isWorldDeny)).toHaveLength(1) // explicit on the root + expect(readTypedAces(api, childDir).filter(isWorldDeny)).toHaveLength(1) // inherited by the container + expect(readTypedAces(api, childFile).filter(isWorldDeny)).toHaveLength(0) // never by the file + const rootDeny = readTypedAces(api, dir).find(isWorldDeny) + expect(rootDeny?.flags).toBe(abi.CONTAINER_INHERIT_ACE) + } finally { + if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) + if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) + } + }) + + it('revoking one of two grants on a directory keeps the shared Low label', async () => { + const api = await win32() + const dir = scratch() + const sidA = sidFromString(api, 'S-1-4-4242-21') + const sidB = sidFromString(api, 'S-1-4-4242-22') + const lowSid = lowLabelSid(api) + const world = worldSid(api) + try { + grantWrite(api, dir, sidA, lowSid, world) + grantWrite(api, dir, sidB, lowSid, world) + revokeWrite(api, dir, sidA) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-21')).toBe(false) + expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-22')).toBe(true) + expect(readLabelAces(api, dir)).toHaveLength(1) // the surviving grant still needs it + revokeWrite(api, dir, sidB) + expect(readLabelAces(api, dir)).toEqual([]) // the last revoke clears it + } finally { + if (!isNullPtr(sidA)) api.localFree(sidA) + if (!isNullPtr(sidB)) api.localFree(sidB) + if (!isNullPtr(lowSid)) api.localFree(lowSid) + if (!isNullPtr(world)) api.localFree(world) + } + }) + it('grantWrite merges into the current DACL: an explicit Users ACE survives grant+revoke', async () => { const api = await win32() const dir = scratch() diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts index 8be8b30a86..87e88bbd0f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -108,15 +108,37 @@ describe('AclWriteGrant failure paths', () => { }) it('create fails closed when the Low label SID cannot be created', () => { + const localFree = vi.fn(() => 0n as NativePtr) const api = { convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, 42n) return 1 }), createWellKnownSid: vi.fn(() => 0), + localFree, getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), } as unknown as Win32Bindings expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/CreateWellKnownSid/) + expect(localFree).toHaveBeenCalledWith(42n) // the parsed capability SID is not stranded + }) + + it('create frees both earlier SIDs when the world SID cannot be created', () => { + const localFree = vi.fn(() => 0n as NativePtr) + let calls = 0 + const api = { + convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { + koffi.encode(slot, PVOID, 42n) + return 1 + }), + createWellKnownSid: vi.fn(() => (++calls === 1 ? 1 : 0)), // the Low label SID succeeds, the world SID fails + isValidSid: vi.fn(() => 1), + localFree, + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/CreateWellKnownSid/) + expect(localFree).toHaveBeenCalledTimes(2) + expect(localFree).toHaveBeenCalledWith(42n) }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index a654b107ab..9cf0517661 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -417,7 +417,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { + `'CMD-DEL: ' + $(if (Test-Path -LiteralPath ${quoted(victims.cmd)}) {'DENIED'} else {'DELETED'});`, `try{[System.IO.File]::Delete(${quoted(victims.dotnet)});'DOTNET-DELETE: DELETED'}catch{'DOTNET-DELETE: DENIED'};`, `try{Remove-Item -LiteralPath ${quoted(victims.remove)} -ErrorAction Stop;'REMOVE-ITEM: DELETED'}catch{'REMOVE-ITEM: DENIED'};`, - `node -e "const fs=require('node:fs');try{fs.unlinkSync(process.argv[1]);console.log('NODE-UNLINK: DELETED')}catch(e){console.log('NODE-UNLINK: DENIED')}" ${quoted(victims.unlink)};`, + `& "${process.execPath}" -e "const fs=require('node:fs');try{fs.unlinkSync(process.argv[1]);console.log('NODE-UNLINK: DELETED')}catch(e){console.log('NODE-UNLINK: DENIED')}" ${quoted(victims.unlink)};`, `try{[System.IO.File]::Delete(${quoted(victims.inside)});'INSIDE-DELETE: DELETED'}catch{'INSIDE-DELETE: DENIED'}`, ].join('') const result = runRunner([ @@ -505,6 +505,95 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 60_000) + it('NUL writes stay ambient under the Low token in BOTH modes', () => { + // The device DACL grants Everyone write and carries no higher label, so + // the documented `> NUL` redirection must survive the lowered token. + const cmd = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32\\cmd.exe') + const probe = [ + "$ErrorActionPreference='SilentlyContinue';", + `& '${cmd}' /c echo ok>nul 2>&1; 'CMD-NUL: exit=' + $LASTEXITCODE;`, + `& "${process.execPath}" -e "try{require('node:fs').writeFileSync(process.argv[1],'x');console.log('NODE-NUL: OK')}catch(e){console.log('NODE-NUL: DENIED '+e.code)}" '\\\\.\\NUL'`, + ].join('') + for (const mode of ['read-only', 'workspace-write'] as const) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, + ]) + expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) + expect(result.stdout, `mode: ${mode}`).toContain('CMD-NUL: exit=0') + expect(result.stdout, `mode: ${mode}`).toContain('NODE-NUL: OK') + } + }, 30_000) + + it('a FullControl open inside a granted root still works for files (the deny inherits to containers only)', () => { + // The ambient-delete deny is 0x40, a member of FILE_ALL_ACCESS: inheriting + // it onto files would deny every GENERIC_ALL/FullControl open by the user, + // Administrators, SYSTEM, or the DSH host. Directories inside a granted + // root keep the deny (that is where FILE_DELETE_CHILD is evaluated), so a + // FullControl open of a DIRECTORY is the documented cost of the deny. + const granted = join(scratchRoot, 'fullcontrol-root') + const child = join(granted, 'child') + mkdirSync(granted) + mkdirSync(child) + writeFileSync(join(granted, 'file.txt'), 'x') + writeFileSync(join(child, 'deep.txt'), 'x') + const grant = AclWriteGrant.create(workspaceWriteSid(granted)) + grant.add(granted, true) + try { + const probe = ` +$ErrorActionPreference='SilentlyContinue' +Add-Type -Namespace P -Name F -MemberDefinition @' +[DllImport("kernel32.dll", SetLastError=true, CharSet=CharSet.Unicode, EntryPoint="CreateFileW")] +public static extern IntPtr CreateFileW(string n, uint a, uint s, IntPtr sa, uint d, uint f, IntPtr t); +[DllImport("kernel32.dll", SetLastError=true)] +public static extern bool CloseHandle(IntPtr h); +'@ | Out-Null +function TryOpen([string]$label, [string]$path) { + $h = [P.F]::CreateFileW($path, 0x10000000, 7, [IntPtr]::Zero, 3, 0x02000000, [IntPtr]::Zero) + if ($h -eq [IntPtr]::new(-1)) { "$($label): DENIED" } else { [void][P.F]::CloseHandle($h); "$($label): OK" } +} +TryOpen 'FILE' '${join(granted, 'file.txt')}' +TryOpen 'NESTED-FILE' '${join(child, 'deep.txt')}' +TryOpen 'DIRECTORY' '${child}' +` + const result = spawnSync('pwsh', ['-NoLogo', '-NonInteractive', '-NoProfile', '-Command', probe], { encoding: 'utf8', timeout: 60_000 }) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(result.stdout).toContain('FILE: OK') + expect(result.stdout).toContain('NESTED-FILE: OK') + expect(result.stdout).toContain('DIRECTORY: DENIED') + } finally { + grant.dispose() + rmSync(granted, { recursive: true, force: true }) + } + }, 60_000) + + it('revoking one of two grants on a directory leaves the shared Low label usable', () => { + // Two capabilities may target one directory; the revoke of the first must + // not strip the label the second one's child still writes through. + const shared = join(scratchRoot, 'shared-root') + mkdirSync(shared) + const sidA = workspaceWriteSid(shared) + const sidB = `${sidA}-1` + const grantA = AclWriteGrant.create(sidA) + const grantB = AclWriteGrant.create(sidB) + grantA.add(shared, true) + grantB.add(shared) + try { + grantB.dispose() // revokes B's ACE, must keep the shared label + const target = join(shared, 'after-revoke.txt') + const result = runRunner([ + '--workspace', shared, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--write-sid', sidA, '--temp-write-sid', tempWriteSid(isolatedTemp), + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'written')", target, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(existsSync(target)).toBe(true) + } finally { + grantA.dispose() + rmSync(shared, { recursive: true, force: true }) + } + }, 60_000) + it('the Low mandatory label closes the Everyone-Modify ambient boundary under BOTH modes', () => { // Everyone is a required keep-alive restricting SID: without it early DLL // initialization and CNG fail. A normal DACL that grants Everyone Modify diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts index 36f18ff03d..62e7ccde7e 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -388,6 +388,22 @@ describe('restrictTokenIntegrity', () => { expect(setTokenInformation).toHaveBeenCalledTimes(1) }) + it('fails closed when the Low label SID has no length', () => { + const api = { + getLengthSid: vi.fn(() => 0), + getLastError: vi.fn(() => 87), + formatMessageW: vi.fn(() => 0), + } as unknown as Win32Bindings + let caught: unknown + try { + restrictTokenIntegrity(api, 5n as NativePtr, allocBytes(12)) + } catch (error) { + caught = error + } + expect(caught).toBeInstanceOf(Win32Error) + expect((caught as Win32Error).api).toBe('GetLengthSid') + }) + it('fails closed when SetTokenInformation rejects the integrity level', () => { const api = { getLengthSid: vi.fn(() => 12), diff --git a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp index 55a3d9c9d8..b38017f1ac 100644 --- a/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp +++ b/packages/sandbox/sandbox-windows-acl/verify/abi-probe.cpp @@ -50,12 +50,32 @@ int wmain() P((int)GRANT_ACCESS); P((int)REVOKE_ACCESS); P(SUB_CONTAINERS_AND_OBJECTS_INHERIT); + P(CONTAINER_INHERIT_ACE); P(MAX_PATH); P(ERROR_SUCCESS); + P((int)ACCESS_DENIED_ACE_TYPE); + P((int)DENY_ACCESS); + P(LABEL_SECURITY_INFORMATION); + P((int)SYSTEM_MANDATORY_LABEL_ACE_TYPE); + P(SYSTEM_MANDATORY_LABEL_NO_WRITE_UP); + P((int)TokenIntegrityLevel); + P(SE_GROUP_INTEGRITY); + P((int)WinLowLabelSid); + P(sizeof(TOKEN_MANDATORY_LABEL)); + P(offsetof(TOKEN_MANDATORY_LABEL, Label)); + P(sizeof(ACL)); + P(sizeof(SYSTEM_MANDATORY_LABEL_ACE)); + P(ACL_REVISION); + P(LPTR); static_assert(sizeof(EXPLICIT_ACCESS_W) == 48, "EXPLICIT_ACCESS_W size"); static_assert(sizeof(TRUSTEE_W) == 32, "TRUSTEE_W size"); static_assert(sizeof(SID_AND_ATTRIBUTES) == 16, "SID_AND_ATTRIBUTES size"); + static_assert(sizeof(TOKEN_MANDATORY_LABEL) == 16, "TOKEN_MANDATORY_LABEL size"); + static_assert(sizeof(ACL) == 8, "ACL header size"); + static_assert(sizeof(SYSTEM_MANDATORY_LABEL_ACE) == 12, "mandatory label ACE body size"); + static_assert(ACL_REVISION == 2, "ACL revision"); + static_assert(LPTR == 0x40, "LocalAlloc flags"); static_assert(SECURITY_MAX_SID_SIZE == 68, "SECURITY_MAX_SID_SIZE"); static_assert(TOKEN_QUERY == 0x8 && TOKEN_DUPLICATE == 0x2 && TOKEN_ADJUST_DEFAULT == 0x80 && TOKEN_ASSIGN_PRIMARY == 0x1, "token rights"); static_assert(SE_GROUP_LOGON_ID == 0xC0000000, "logon id attr"); diff --git a/packages/shell/pwsh-sandbox/README.i18n.yaml b/packages/shell/pwsh-sandbox/README.i18n.yaml index a322e49d44..289ac2384f 100644 --- a/packages/shell/pwsh-sandbox/README.i18n.yaml +++ b/packages/shell/pwsh-sandbox/README.i18n.yaml @@ -1,6 +1,6 @@ # Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write packages/shell/pwsh-sandbox/README.md -README.md: cddcce511d01f3c10208304ea55bf6156ccf2429 -README.zh.md: 40aa055770e526cc41607f0e4cd63273b80ebc15 +# pnpm run verify-translation-pairing --write packages//shell//pwsh-sandbox//README.md +README.md: 34845dd2cc5eee1dd36a76bf63e266d6b06c7276 +README.zh.md: edf99c6b0930a7d603523e86af4c453992fc578a diff --git a/packages/shell/pwsh-sandbox/README.md b/packages/shell/pwsh-sandbox/README.md index cddcce511d..34845dd2cc 100644 --- a/packages/shell/pwsh-sandbox/README.md +++ b/packages/shell/pwsh-sandbox/README.md @@ -35,7 +35,7 @@ Choose it when a deployment needs file-level confinement for PowerShell commands | Mode | File effects | |---|---| -| `read-only` (default) | Writes are denied; the boundary stays partial because the restricted token retains Everyone | +| `read-only` (default) | Writes are denied; the boundary stays partial for the shared hard-link, unconfined-read, and AppContainer-ACL limits | | `workspace-write` | Writes under the policy's workspace root plus a private temp directory; `TMP`/`TEMP` are rewritten to it before spawning | | `danger-full-access` | No confinement; the provider is never consulted, and results carry `sandbox: { mode, denied: false }` | @@ -140,7 +140,7 @@ These limits define when this executor is only a partial boundary on Windows. Th - **Reads are unrestricted on Windows** — the ACL runner restricts writes only; the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. - **Windows workspace-write temp authority is private** — per live session/workspace pair; agentless calls receive a fresh private directory per invocation; the ambient temp root is never granted, and the runner rewrites `TMP`/`TEMP` to the private directory before spawning. -- **Windows read-only grants no explicit writable root but remains partial** — the restricted token must retain Everyone; objects whose DACL grants Everyone write access — including compatible opens of the NUL device — remain ambient authority, while PowerShell's `> $null` redirection still works without opening NUL. +- **Windows read-only grants no explicit writable root but remains partial** — NTFS hard links alias one file object across paths, reads stay unconfined, and a tree another AppContainer tool has ACL'd with a package SID is unreadable to the Low-integrity child. NUL stays writable in both modes because the device DACL grants Everyone write and carries no higher label; PowerShell's `> $null` redirection still works without opening it. ### Dev Note diff --git a/packages/shell/pwsh-sandbox/README.zh.md b/packages/shell/pwsh-sandbox/README.zh.md index 40aa055770..edf99c6b09 100644 --- a/packages/shell/pwsh-sandbox/README.zh.md +++ b/packages/shell/pwsh-sandbox/README.zh.md @@ -35,7 +35,7 @@ kind: "package-reference" | 模式 | 文件影响 | |---|---| -| `read-only`(默认) | 写入被拒绝;由于受限令牌必须保留 Everyone,边界仍是不完整的 | +| `read-only`(默认) | 写入被拒绝;边界仍受共享的硬链接、读取不受限与 AppContainer ACL 限制 | | `workspace-write` | 只能写入策略的工作区根目录加一个私有临时目录;spawn 前 `TMP`/`TEMP` 会被重写到该目录 | | `danger-full-access` | 不作限制;绝不咨询提供方,结果携带 `sandbox: { mode, denied: false }` | @@ -140,7 +140,7 @@ kind: "package-reference" - **Windows 上读不受限**——ACL runner 只限写;读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 - **Windows workspace-write 的临时权限按每个活跃的会话/工作区对私有**——无 agent(智能体)的调用每次都获得一个新的私有目录;环境临时根目录绝不会被授权,runner 会在 spawn 前将 `TMP`/`TEMP` 重写为该私有目录。 -- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**——受限令牌必须保留 Everyone;DACL 向 Everyone 授予写访问的对象——包括以兼容方式打开的 NUL 设备——仍构成环境权限来源,而 PowerShell 的 `> $null` 重定向仍可工作,且不会打开 NUL。 +- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**——NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。NUL 在两种模式下仍可写,因为设备 DACL 向 Everyone 授予写权限且不带更高的标签;PowerShell 的 `> $null` 重定向仍然工作,且不会打开 NUL。 ### 开发备注 From 4753e45f9564c9893f97a2d47a8d632c6da07736 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 13:54:48 +0800 Subject: [PATCH 06/20] test(sandbox): probe cmd's own > NUL redirection The CI Windows runner reports an empty \0 for a PowerShell-level '>nul' redirection around the cmd call, and PowerShell's '>nul' opens a file named nul rather than exercising cmd's redirection. Pass the whole command to cmd so it parses '>NUL' itself and prove it with cmd's own output. --- packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 9cf0517661..7632c301c0 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -507,11 +507,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { it('NUL writes stay ambient under the Low token in BOTH modes', () => { // The device DACL grants Everyone write and carries no higher label, so - // the documented `> NUL` redirection must survive the lowered token. + // the documented `> NUL` redirection must survive the lowered token. The + // redirection stays inside cmd's own command line: PowerShell's `>nul` + // opens a file named nul instead, which is a different probe. const cmd = join(process.env.SystemRoot ?? 'C:\\Windows', 'System32\\cmd.exe') const probe = [ "$ErrorActionPreference='SilentlyContinue';", - `& '${cmd}' /c echo ok>nul 2>&1; 'CMD-NUL: exit=' + $LASTEXITCODE;`, + `& '${cmd}' /c 'echo ok>NUL&&echo CMD-NUL: OK';`, `& "${process.execPath}" -e "try{require('node:fs').writeFileSync(process.argv[1],'x');console.log('NODE-NUL: OK')}catch(e){console.log('NODE-NUL: DENIED '+e.code)}" '\\\\.\\NUL'`, ].join('') for (const mode of ['read-only', 'workspace-write'] as const) { @@ -520,7 +522,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, ]) expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) - expect(result.stdout, `mode: ${mode}`).toContain('CMD-NUL: exit=0') + expect(result.stdout, `mode: ${mode}`).toContain('CMD-NUL: OK') expect(result.stdout, `mode: ${mode}`).toContain('NODE-NUL: OK') } }, 30_000) From 85724fb5b03de665437beaebbda626af2f4868d6 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 14:05:18 +0800 Subject: [PATCH 07/20] docs(config-catalog): refresh the sandbox-local Config source line The comment edit in sandbox-local/src/index.ts moved the Config block from line 44 to 45, and the generated catalog records that line. --- docs/config-catalog.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 77c80e50f9..e3ca3de577 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2077,7 +2077,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) From 7201569aef390be22277207a4afb093150d73592 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sat, 19 Sep 2026 14:17:59 +0800 Subject: [PATCH 08/20] docs(config-catalog): carry the source-line refresh to the zh pair The generated catalog is a bilingual pair, so the regenerated English side must be brought over with its Chinese counterpart before the pairing record is re-written. --- docs/config-catalog.i18n.yaml | 4 ++-- docs/config-catalog.zh.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 67d822716e..54b6408d9b 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 77c80e50f9174da638a13a8554ede5e9eb806853 -config-catalog.zh.md: d7b315342f5ebdd2c2830fbeb4ac6be987f7de45 +config-catalog.md: e3ca3de5778065b46c4b8e82726055041f7b40b7 +config-catalog.zh.md: 99a5ef9aa196c69ac7df21a1fc0450d1eff6edc5 diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index d7b315342f..99a5ef9aa1 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -2079,7 +2079,7 @@ export interface Config { } ``` -来源:[`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/sandbox-local/src/index.ts) +来源:[`packages/sandbox/sandbox-local/src/index.ts:45`](../packages/sandbox/sandbox-local/src/index.ts) From e553344063bc939568c65801adc614dcfa1668fc Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Sun, 20 Sep 2026 13:42:58 +0800 Subject: [PATCH 09/20] test(sandbox): drop new unknown casts after the merge-forward The no-unknown-casts ratchet landed on master after this branch, so the new stub tables in the ACL failure paths can no longer assert through unknown. Route them through a typed partial stub, type the bigint handles as NativePtr, and prune the three retired baseline entries. --- .../tests/acl-failure-paths.spec.ts | 13 +++++---- .../tests/grant-failure-paths.spec.ts | 29 +++++++++++-------- .../tests/index-failure-paths.spec.ts | 13 ++++++--- .../tests/token-failure-paths.spec.ts | 15 ++++++---- scripts/no-unknown-casts.baseline.json | 5 ---- 5 files changed, 43 insertions(+), 32 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts index b4c295e825..741e7e283f 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-failure-paths.spec.ts @@ -11,6 +11,7 @@ import { tmpdir } from 'node:os' import { Win32Error } from '@deepseek-ai/dsh-win32-process' import { describe, expect, it, vi } from 'vitest' +import type { Mock } from 'vitest' import koffi from 'koffi' import { grantWrite, revokeWrite, withPathLock } from '../src/acl.ts' @@ -511,8 +512,8 @@ describe('mergeAndApply failure paths', () => { describe('revokeWrite label handling', () => { /** The SECURITY_INFORMATION and SACL of the last apply. */ - function applyArgs(api: Win32Bindings): { information: number; sacl: unknown } { - const call = (api.setNamedSecurityInfoW as unknown as ReturnType).mock.calls.at(-1) + function applyArgs(setNamedSecurityInfoW: Mock): { information: number; sacl: unknown } { + const call = setNamedSecurityInfoW.mock.calls.at(-1) return { information: call?.[2] as number, sacl: call?.[6] } } @@ -529,7 +530,7 @@ describe('revokeWrite label handling', () => { setNamedSecurityInfoW, }) expect(revokeWrite(api, 'C:\\granted', sid)).toBe(true) - const { information, sacl } = applyArgs(api) + const { information, sacl } = applyArgs(setNamedSecurityInfoW) expect(information & abi.LABEL_SECURITY_INFORMATION).toBe(0) // the label was left untouched expect(sacl).toBeNull() }) @@ -543,7 +544,7 @@ describe('revokeWrite label handling', () => { setNamedSecurityInfoW, }) expect(revokeWrite(api, 'C:\\granted', sid)).toBe(true) - expect(applyArgs(api).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + expect(applyArgs(setNamedSecurityInfoW).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) }) it('clears the Low label when a malformed tiny DACL hides any foreign grant', () => { @@ -554,7 +555,7 @@ describe('revokeWrite label handling', () => { const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ getNamedSecurityInfoW: readStub(acl, null, 6n), setNamedSecurityInfoW }) expect(revokeWrite(api, 'C:\\granted', craftSid(1, 0))).toBe(true) - expect(applyArgs(api).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + expect(applyArgs(setNamedSecurityInfoW).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) }) it('clears the Low label when a lying ACE size hides any foreign grant', () => { @@ -566,7 +567,7 @@ describe('revokeWrite label handling', () => { const setNamedSecurityInfoW = vi.fn(() => 0) const api = aclApi({ getNamedSecurityInfoW: readStub(acl, null, 6n), setNamedSecurityInfoW }) expect(revokeWrite(api, 'C:\\granted', craftSid(1, 0))).toBe(true) - expect(applyArgs(api).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) + expect(applyArgs(setNamedSecurityInfoW).information & abi.LABEL_SECURITY_INFORMATION).toBe(abi.LABEL_SECURITY_INFORMATION) }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts index 87e88bbd0f..208110863e 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts @@ -15,10 +15,15 @@ import { AclWriteGrant } from '../src/index.ts' const PVOID = koffi.pointer('void') +/** Stub binding table: only the members a test drives, so the rest are never called. */ +function stubBindings(overrides: Partial): Win32Bindings { + return overrides as Win32Bindings +} + /** The stub the grant-then-fail-revoke sequence needs: every call succeeds until the DACL read is flipped off. */ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { const state = { failReads: false } - const api = { + const api = stubBindings({ convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, 42n) return 1 @@ -26,7 +31,7 @@ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { createWellKnownSid: vi.fn(() => 1), isValidSid: vi.fn(() => 1), getLengthSid: vi.fn(() => 12), - localAlloc: vi.fn(() => 11n), + localAlloc: vi.fn(() => 11n as NativePtr), initializeAcl: vi.fn(() => 1), addMandatoryAce: vi.fn(() => 1), getTempPathW: vi.fn((_length: number, buffer: Buffer) => { @@ -34,7 +39,7 @@ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { buffer.write(temp, 'utf16le') return temp.length }), - createFileW: vi.fn(() => 7n), + createFileW: vi.fn(() => 7n as NativePtr), lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1), closeHandle: vi.fn(() => 1), @@ -53,10 +58,10 @@ function grantThenFailApi(): { api: Win32Bindings; failReads: () => void } { return 0 }), setNamedSecurityInfoW: vi.fn(() => 0), - localFree: vi.fn(() => 0n), + localFree: vi.fn(() => 0n as NativePtr), getLastError: vi.fn(() => 2), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) return { api, failReads: () => { state.failReads = true } } } @@ -92,24 +97,24 @@ describe('AclWriteGrant failure paths', () => { }) it('dispose aggregates a failing SID free into an AggregateError', () => { - const api = { + const api = stubBindings({ convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, 42n) return 1 }), createWellKnownSid: vi.fn(() => 1), isValidSid: vi.fn(() => 1), - localFree: vi.fn(() => 1n), // non-NULL: LocalFree "failed" + localFree: vi.fn(() => 1n as NativePtr), // non-NULL: LocalFree "failed" getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) const grant = AclWriteGrant.create('S-1-4-42-42', api) expect(() =>{ grant.dispose() }).toThrow(AggregateError) }) it('create fails closed when the Low label SID cannot be created', () => { const localFree = vi.fn(() => 0n as NativePtr) - const api = { + const api = stubBindings({ convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, 42n) return 1 @@ -118,7 +123,7 @@ describe('AclWriteGrant failure paths', () => { localFree, getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/CreateWellKnownSid/) expect(localFree).toHaveBeenCalledWith(42n) // the parsed capability SID is not stranded }) @@ -126,7 +131,7 @@ describe('AclWriteGrant failure paths', () => { it('create frees both earlier SIDs when the world SID cannot be created', () => { const localFree = vi.fn(() => 0n as NativePtr) let calls = 0 - const api = { + const api = stubBindings({ convertStringSidToSidW: vi.fn((_sid: string, slot: NativePtr) => { koffi.encode(slot, PVOID, 42n) return 1 @@ -136,7 +141,7 @@ describe('AclWriteGrant failure paths', () => { localFree, getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) expect(() => AclWriteGrant.create('S-1-4-42-42', api)).toThrow(/CreateWellKnownSid/) expect(localFree).toHaveBeenCalledTimes(2) expect(localFree).toHaveBeenCalledWith(42n) diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 95f639df88..64aac56080 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -61,6 +61,11 @@ function scratch(): string { return dir } +/** Stub binding table: only the members a test drives, so the rest are never called. */ +function stubBindings(overrides: Partial): Win32Bindings { + return overrides as Win32Bindings +} + /** * The stub the whole happy pipeline needs: token opening, capability-SID * parsing, workspace+temp grants, logon-SID scan, well-known SID, restricted token, @@ -69,7 +74,7 @@ function scratch(): string { */ function happyStubs(): HappyStubs { let next = 0n - const fresh = () => ++next + const fresh = (): NativePtr => ++next as NativePtr const openProcess = vi.fn(() => fresh()) const openProcessToken = vi.fn((_process: unknown, _access: unknown, slot: NativePtr) => { @@ -155,12 +160,12 @@ function happyStubs(): HappyStubs { const resumeThread = vi.fn(() => 0) const terminateProcess = vi.fn(() => 1) const getStdHandle = vi.fn(() => fresh()) - const localFree = vi.fn(() => 0n) + const localFree = vi.fn(() => 0n as NativePtr) const closeHandle = vi.fn(() => 1) const getLastError = vi.fn(() => ERROR_BROKEN_PIPE) // the drains' clean EOF const formatMessageW = vi.fn(() => 0) - const api = { + const api = stubBindings({ openProcess, openProcessToken, convertStringSidToSidW, getTempPathW, createFileW, lockFileEx: vi.fn(() => 1), unlockFileEx: vi.fn(() => 1), getNamedSecurityInfoW, setEntriesInAclW, setNamedSecurityInfoW, getTokenInformation, @@ -171,7 +176,7 @@ function happyStubs(): HappyStubs { setInformationJobObject, assignProcessToJobObject, resumeThread, terminateProcess, getStdHandle, localFree, closeHandle, getLastError, formatMessageW, - } as unknown as Win32Bindings + }) return { api, setNamedSecurityInfoW, convertStringSidToSidW, closeHandle, localFree, createRestrictedToken, createJobObjectW, getNamedSecurityInfoW, setTokenInformation, addMandatoryAce, diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts index 62e7ccde7e..480f514952 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -21,6 +21,11 @@ import * as abi from '../src/win32-abi.ts' const PVOID = koffi.pointer('void') +/** Stub binding table: only the members a test drives, so the rest are never called. */ +function stubBindings(overrides: Partial): Win32Bindings { + return overrides as Win32Bindings +} + describe('openCurrentProcessToken failure paths', () => { it('reports when OpenProcess yields no handle', () => { const api = { @@ -383,17 +388,17 @@ describe('restrictTokenIntegrity', () => { expect(info.readUInt32LE(8)).toBe(abi.SE_GROUP_INTEGRITY) return 1 }) - const api = { getLengthSid: vi.fn(() => 12), setTokenInformation } as unknown as Win32Bindings + const api = stubBindings({ getLengthSid: vi.fn(() => 12), setTokenInformation }) restrictTokenIntegrity(api, 5n as NativePtr, lowSid) expect(setTokenInformation).toHaveBeenCalledTimes(1) }) it('fails closed when the Low label SID has no length', () => { - const api = { + const api = stubBindings({ getLengthSid: vi.fn(() => 0), getLastError: vi.fn(() => 87), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) let caught: unknown try { restrictTokenIntegrity(api, 5n as NativePtr, allocBytes(12)) @@ -405,12 +410,12 @@ describe('restrictTokenIntegrity', () => { }) it('fails closed when SetTokenInformation rejects the integrity level', () => { - const api = { + const api = stubBindings({ getLengthSid: vi.fn(() => 12), setTokenInformation: vi.fn(() => 0), getLastError: vi.fn(() => 5), formatMessageW: vi.fn(() => 0), - } as unknown as Win32Bindings + }) let caught: unknown try { restrictTokenIntegrity(api, 5n as NativePtr, allocBytes(12)) diff --git a/scripts/no-unknown-casts.baseline.json b/scripts/no-unknown-casts.baseline.json index f5ecfea159..132d00b292 100644 --- a/scripts/no-unknown-casts.baseline.json +++ b/scripts/no-unknown-casts.baseline.json @@ -1702,14 +1702,9 @@ "9c749359b9458267505dc9d87b2b4e2a73e874c0e3908d7fa06e4fd4fd11cad4": 1 }, "packages/sandbox/sandbox-windows-acl/tests/grant-failure-paths.spec.ts": { - "0ee37bb1bf169e1de1030cd3449529c550283dccba4ea2d9d2af967b9ac51895": 1, "24defbb8f6adc4ec8ebd3443b233aceadae85ab486413320de0967eaf7d43032": 1, - "d7b28f469106e3e88d52c1742fe001ac7807bc1bc21c0fd4ddcb5bd9b97bf31b": 1, "ff7b125eae197db9cb8866293613a5de2a984d42b4487b2cc37eeacc8c300783": 1 }, - "packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts": { - "916c26b0b6721f519ca6c95b93446c1b16b174f9bfb067e79f9cb03cec8fc87c": 1 - }, "packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts": { "d187b3ab8af0b780660e2b764b553ccff82e3af3254598ee18a059e5bd0381f5": 1 }, From 90a1b23e4ad1ce2286b74b195c5534482be03eb4 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Mon, 21 Sep 2026 13:18:20 +0800 Subject: [PATCH 10/20] feat(web): preview changed-file diffs on hover --- apps/web/tests/changed-files-turn.e2e.ts | 76 ++++++ .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/ChangedFiles.module.css | 6 + .../src/client/ChangedFiles.tsx | 63 +++-- .../src/client/Deliverables.tsx | 8 +- .../ui-deliverables/src/client/FileDiff.tsx | 253 ++++++++++++++++++ .../ui-deliverables/src/client/ReviewTab.tsx | 253 +----------------- .../ui-deliverables/src/client/index.ts | 3 +- .../tests/changed-files-hover.client.spec.tsx | 94 +++++++ .../tests/deliverables.client.spec.tsx | 6 +- .../tests/review-tab.client.spec.tsx | 3 +- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/HoverCard.module.css | 28 ++ .../client/ui-primitives/src/HoverCard.tsx | 93 +++++-- .../tests/hover-card.client.spec.tsx | 88 +++++- .../web/changed-files-turn/hover.expected.md | 1 + 20 files changed, 693 insertions(+), 298 deletions(-) create mode 100644 packages/client/ui-deliverables/src/client/FileDiff.tsx create mode 100644 packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx create mode 100644 snapshots/web/changed-files-turn/hover.expected.md diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index a5b45a3511..6a5d394cc1 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -32,6 +32,28 @@ async function seedRepository(cwd: string): Promise { git('commit', '-q', '-m', 'seed') } +/** Sample the real opacity transition at its midpoint without depending on wall-clock scheduling. */ +function hoverOpacityTransition(page: Page, closing: boolean): Promise<{ duration: number; opacity: number }> { + return page.evaluate(closing => new Promise((resolve) => { + const observer = new MutationObserver(() => { + const card = document.querySelector('[data-changes-hover-preview]')?.parentElement + if (card === undefined || card === null || card.hasAttribute('data-closing') !== closing) return + const initialOpacity = Number(getComputedStyle(card).opacity) + const animation = card.getAnimations().find(animation => animation instanceof CSSTransition && animation.transitionProperty === 'opacity') + observer.disconnect() + if (animation === undefined) { resolve({ duration: 0, opacity: initialOpacity }); return } + animation.pause() + animation.currentTime = 50 + const opacity = Number(getComputedStyle(card).opacity) + const duration = Number(animation.effect!.getTiming().duration) + animation.finish() + resolve({ duration, opacity }) + }) + observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-closing'] }) + if (closing) window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })) + }), closing) +} + describe('web e2e: a git workspace turn ends with its changed files', () => { let scaffold: WebScaffold let browser: Browser @@ -180,6 +202,60 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { expect(tripwire.warnings).toEqual([]) }) + it('previews a single column with a scrollable path and no file notes or hunk headers', async () => { + const card = page.locator('[data-changed-files]') + const row = card.getByRole('button', { name: '查看 notes.txt 的改动' }) + const preview = page.locator('[data-changes-hover-preview]') + const entrance = hoverOpacityTransition(page, false) + await row.hover() + expect(await entrance).toEqual({ duration: 100, opacity: 0.5 }) + await preview.locator('[data-review-view="unified"]').waitFor({ state: 'visible' }) + expect(await preview.locator('[data-diff-side]').count()).toBe(0) + expect(await preview.locator('[data-diff-hunk-header]').isVisible()).toBe(false) + const geometry = await preview.evaluate((element) => { + const popup = element.parentElement!.getBoundingClientRect() + const card = document.querySelector('[data-changed-files]')!.getBoundingClientRect() + return { inset: popup.left - card.left, width: popup.width, cardWidth: card.width, height: popup.height } + }) + expect(geometry.inset).toBe(24) + expect(geometry.width).toBe(geometry.cardWidth - 48) + expect(geometry.height).toBeLessThanOrEqual(420) + const path = preview.locator('[data-changes-preview-path]') + const pathStyle = await path.evaluate((element) => { + const style = getComputedStyle(element) + return { family: style.fontFamily, overflow: style.overflowX, whiteSpace: style.whiteSpace, ellipsis: style.textOverflow } + }) + expect(pathStyle.family).toMatch(/mono/i) + expect(pathStyle).toMatchObject({ overflow: 'auto', whiteSpace: 'nowrap', ellipsis: 'clip' }) + await path.evaluate((element) => { element.style.maxWidth = '160px'; element.scrollLeft = 50 }) + expect(await path.evaluate(element => element.scrollLeft)).toBe(50) + await path.evaluate((element) => { element.style.removeProperty('max-width') }) + await compareOrRefreshGolden(join(DIR, 'hover.expected.md'), await preview.locator('[data-review-view]').ariaSnapshot(), MODE) + await preview.hover() + await preview.locator('[data-review-view]').evaluate((element) => { + const line = element.querySelector('[data-diff-line]')! + for (let index = 0; index < 40; index++) element.appendChild(line.cloneNode(true)) + }) + await expect.poll(() => preview.evaluate(element => element.parentElement!.getBoundingClientRect().height)).toBe(420) + const scroll = preview.locator('[data-review-view]') + await scroll.evaluate((element) => { element.scrollTop = 80 }) + await expect.poll(() => scroll.evaluate(element => element.scrollTop)).toBe(80) + await row.click() + await preview.waitFor({ state: 'detached' }) + const review = page.locator('[data-changes-review]') + await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' }) + await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' }) + await card.getByRole('button', { name: '查看 src/util.ts 的改动' }).hover() + await preview.locator('[data-diff-code]').first().waitFor({ state: 'visible' }) + expect(await preview.locator('[data-review-view="unified"]').count()).toBe(1) + expect(await preview.locator('[data-diff-note]').isVisible()).toBe(false) + expect(await preview.locator('[data-diff-hunk-header]').isVisible()).toBe(false) + await expect.poll(() => preview.evaluate(element => Number(getComputedStyle(element.parentElement!).opacity))).toBe(1) + expect(await hoverOpacityTransition(page, true)).toEqual({ duration: 100, opacity: 0.5 }) + await preview.waitFor({ state: 'detached' }) + expect(tripwire.pageErrors).toEqual([]) + }) + it('reviews a shell-appended file from the snapshots and an ignored file from its captured copies in one tab', async () => { const card = page.locator('[data-changed-files]') const column = page.locator('[data-rightbar-col]') diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index c3c62898a1..e10d0408ba 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: ce006783310c257957bc8cfddcfd18226972d18f -README.zh.md: 82239ba169f4ada188e8a5ba8e1889c61b336f96 +README.md: 17b0d77b365a7198ff81dac4c51adddf94957859 +README.zh.md: 0399a355cb9ecee78ee992df3b6a614b9e9ceb86 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index ce00678331..17b0d77b36 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -38,7 +38,7 @@ The `present` tool row retains its document icon and recorded result across prog Developer tools must be enabled to show this card or request its summary. Turning the preference off removes the card immediately while explicit delivery cards, inline file links, and recorded workspace changes remain available. -The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count and summed added and deleted lines; hovering or focusing it replaces the counts with “Preview in sidebar”. Each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Four rows show before a fold; the control reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. +The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count and summed added and deleted lines; hovering or focusing it replaces the counts with “Preview in sidebar”. Each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Four rows show before a fold; the control reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. Hovering a file row for 500ms opens a scrollable, single-column preview of the comparison, independent of the Sidebar’s view. Its full path uses muted monospace text and scrolls horizontally when needed. File-status notes and hunk headers are hidden in the preview. The preview is 48px narrower than the card, inset 24px on each side, and at most 420px high, further constrained by the viewport. The whole preview fades in and out over 100ms. Moving into the preview keeps it open; leaving it or pressing Escape dismisses it. Comparisons load only when the preview opens and share the review tab’s cache and retry states. ### The review tab diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 82239ba169..0399a355cb 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -38,7 +38,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明 只有开启开发者工具才展示此卡片并请求其摘要。关闭该偏好会立即隐藏卡片,同时保留显式交付卡片、行内文件链接和已记录的工作区改动。 -卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计;鼠标悬停或键盘聚焦时,统计信息替换为“在侧边栏预览”。每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示四行;控件展开全部记录文件,展开后从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。 +卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计;鼠标悬停或键盘聚焦时,统计信息替换为“在侧边栏预览”。每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示四行;控件展开全部记录文件,展开后从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。 在文件行上悬停 500ms 后,会出现可滚动的单列改动浮层,不受 Sidebar 视图设置影响。顶部完整路径使用较浅的等宽字体,超出宽度时可横向滚动。浮层不显示文件状态说明和 hunk 头。浮层比卡片窄 48px,左右各内缩 24px,高度最多 420px,并受视口可用空间限制。整个浮层的淡入和淡出各持续 100ms。鼠标移入浮层后保持打开,移出或按 Escape 关闭。对比只在浮层打开时加载,与 review tab 共用缓存及重试状态。 ### review tab diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css index 536bed4f6d..7ec998b044 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css @@ -29,3 +29,9 @@ button.header:disabled { cursor: progress; } .toggle:hover { background: var(--dsw-alias-interactive-bg-hover); } .toggle svg { flex: none; width: 14px; height: 14px; } @media (pointer: coarse) { .row, .toggle { min-height: 44px; } } + +.preview { height: auto; max-height: 100%; overflow: hidden; } + +.previewPath { flex: 1 1 auto; min-width: 0; overflow-x: auto; overflow-y: hidden; color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code); font-size: 12px; line-height: 20px; white-space: nowrap; } +.previewPath:focus-visible { outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } +.preview [data-diff-note], .preview [data-diff-hunk-header] { display: none; } diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index 2db82abd1f..79b8bb6e48 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -1,13 +1,19 @@ /** The changed-files card: a header and per-file rows that open the turn's review, and a four-row fold. */ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' -import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' -import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' -import type { ChangesSummary } from '../changes.ts' +import { HoverCard, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' +import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' +import { changesDiffUrl, type ChangesSummary } from '../changes.ts' +import type { DeliverablesInjected } from './Deliverables.tsx' +import { FileDiff } from './FileDiff.tsx' +import reviewCss from './ReviewTab.module.css' import { IconCodeBracketsOutline16 } from './icons.tsx' import type { NS } from './locales.ts' import css from './ChangedFiles.module.css' +type PreviewProps = Pick, 'useChangesDiff' | 'loadChangesDiff'> + & Pick + /** Rows shown before the fold; the design's summary height for a closing message. */ const COLLAPSED_ROWS = 4 @@ -23,21 +29,23 @@ function Counts({ added, deleted, t }: { added: number; deleted: number } & Prop /** * Render one turn's changed files. The header opens the turn's review in the - * right Sidebar on its first file; each row opens it on that row's file. + * right Sidebar on its first file; each row previews its comparison after a + * 500ms hover and opens the review on click. * @param props - the recorded summary, the review opener, and localized copy. * @returns the card. */ -export function ChangedFiles({ changes, cwd, openReview, t }: { +export function ChangedFiles({ changes, cwd, openReview, t, sessionId, useChangesDiff, loadChangesDiff }: { /** The served summary with the sequence of the event that announced it. */ changes: Pick & { seq: number } cwd: string | undefined /** Open the turn's review on the file at an original summary index. */ openReview: (index: number) => void -} & PropsLocale) { +} & PropsLocale & PreviewProps) { + const cardRef = useRef(null) const [expanded, setExpanded] = useState(false) const foldable = changes.files.length > COLLAPSED_ROWS const rows = foldable && !expanded ? changes.files.slice(0, COLLAPSED_ROWS) : changes.files - return
+ return
+ } + anchor={} /> ))} @@ -75,3 +86,19 @@ export function ChangedFiles({ changes, cwd, openReview, t }: { }
} + +/** Mounted only while its hover card is open, so passing over a row does not read a comparison. */ +function ChangedFilePreview({ sessionId, seq, index, display, useChangesDiff, loadChangesDiff, t }: PreviewProps & { + seq: number + index: number + display: string +} & PropsLocale) { + const state = useChangesDiff(value => value[changesDiffUrl(sessionId, seq, index)]) + useEffect(() => { + if (state === undefined) void loadChangesDiff(sessionId, seq, index) + }, [state, sessionId, seq, index, loadChangesDiff]) + return
+
{display}
+ { void loadChangesDiff(sessionId, seq, index) }} /> +
+} diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 3fae39bd01..5bcdb34946 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -5,6 +5,7 @@ import { Button, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } fr import type { GlobalStandardProps, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { PresentedOpenController } from './present-open.ts' +import type { ChangesDiffStore } from './changes-diff.ts' import type { ChangesSummaryStore } from './changes-summary.ts' import { ChangedFiles } from './ChangedFiles.tsx' import { changesForClosing, presentedForClosing, type ChangesTurnData, type PresentedPath } from './turn-deliverables.ts' @@ -21,12 +22,14 @@ const COLLAPSED_PRESENTED_COUNT = 4 /** Summary reads, native-open callbacks, and shared gesture status supplied by the plugin. */ export interface DeliverablesInjected { hooks: { + changesDiff: ObservableSnapshot> showCodeDiff: ObservableSnapshot presentedOpen: ObservableSnapshot> presentedHost: ObservableSnapshot> changesSummary: ObservableSnapshot> } reloadPresentedHost: PresentedOpenController['loadHost'] + loadChangesDiff: ChangesDiffStore['load'] loadChangesSummary: ChangesSummaryStore['load'] openPresented: PresentedOpenController['open'] openChanged: PresentedOpenController['openChanged'] @@ -64,7 +67,7 @@ export function DeliverablesTail(props: PropsRuntime<'conversation.chat.turnTail */ export function Deliverables({ matched, openFile, t, sessionId, useSessions, openPresented, openChangesReview, usePresentedOpen, usePresentedHost, - useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot, + useChangesDiff, loadChangesDiff, useChangesSummary, reloadPresentedHost, loadChangesSummary, useShowCodeDiff, renderSlot, }: Pick & { matched: DeliverablesMatch } & PropsLocale & Pick & Pick & InjectFace & PropsRenderSlots<'deliverables.file.actions'>) { @@ -89,7 +92,8 @@ export function Deliverables({ if (host === null) void reloadPresentedHost() }, [host, reloadPresentedHost]) return <> - {changes !== null && { openChangesReview({ sessionId, seq: changes.seq, turn: changes.turn }, index) }} />} {matched.presented.length > 0 &&
| undefined + new: ReadonlyMap | undefined +} + +function highlightedSide(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): HunkHighlights['old'] { + const source = rows.flatMap((row) => { + const no = row[side] + return no === undefined ? [] : [{ no, text: row.text }] + }) + if (source.length === 0) return new Map() + const highlighted = highlighter(source.map(line => line.text).join('\n')) + if (highlighted === undefined) return undefined + return new Map(source.map((line, index) => { + // Shiki omits one terminal empty token row; retain an aligned empty run for that source line. + return [line.no, highlighted[index] ?? []] + })) +} + +function hunkHighlights(hunk: WorkspaceDiffHunk, highlighter: CodeHighlighter): HunkHighlights { + const rows = hunkRows(hunk) + return { old: highlightedSide(rows, 'old', highlighter), new: highlightedSide(rows, 'new', highlighter) } +} + +function DiffText({ text, spans }: { text: string; spans: readonly HighlightSpan[] | undefined }): ReactNode { + return + {spans === undefined ? text : spans.map((span, index) => {span.text})} + +} + +/** + * Number a hunk's lines: context lines count on both sides, deletions on the + * old side, additions on the new side. + * @param hunk - a served hunk. + * @returns the rows in order. + */ +export function hunkRows(hunk: WorkspaceDiffHunk): DiffRow[] { + let oldNo = hunk.oldStart + let newNo = hunk.newStart + return hunk.lines.map((line) => { + const text = line.slice(1) + switch (line[0]) { + case '+': return { kind: 'add', old: undefined, new: newNo++, text } + case '-': return { kind: 'del', old: oldNo++, new: undefined, text } + default: return { kind: 'context', old: oldNo++, new: newNo++, text } + } + }) +} + +/** + * Pair a hunk's lines for the side-by-side view: each run of deletions is + * aligned with the run of additions that follows it, row by row, and context + * lines sit on both sides. + * @param hunk - a served hunk. + * @returns the rows in order. + */ +export function splitRows(hunk: WorkspaceDiffHunk): SplitRow[] { + const rows: SplitRow[] = [] + let dels: NonNullable[] = [] + let adds: NonNullable[] = [] + const flush = (): void => { + for (let at = 0; at < Math.max(dels.length, adds.length); at += 1) { + const left = dels[at] + const right = adds[at] + rows.push({ ...left === undefined ? {} : { left }, ...right === undefined ? {} : { right } }) + } + dels = [] + adds = [] + } + for (const row of hunkRows(hunk)) { + if (row.kind === 'del') dels.push({ no: row.old as number, text: row.text, kind: 'del' }) + else if (row.kind === 'add') adds.push({ no: row.new as number, text: row.text, kind: 'add' }) + else { + flush() + rows.push({ left: { no: row.old as number, text: row.text, kind: 'context' }, right: { no: row.new as number, text: row.text, kind: 'context' } }) + } + } + flush() + return rows +} + +/** + * The hunks to draw, cut at {@link MAX_RENDERED_LINES} lines in total. + * @param hunks - served hunks. + * @returns the hunks with the last one shortened as needed, and whether anything was cut. + */ +export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } { + let budget = MAX_RENDERED_LINES + const kept: WorkspaceDiffHunk[] = [] + for (const hunk of hunks) { + if (budget === 0) return { hunks: kept, truncated: true } + kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) }) + budget -= Math.min(budget, hunk.lines.length) + } + return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) } +} + +/** The one-line fact about a text comparison worth stating above its hunks, if any. */ +function noteOf(diff: Extract): 'diff.created' | 'diff.deleted' | 'diff.unchanged' | undefined { + if (!diff.before) return 'diff.created' + if (!diff.after) return 'diff.deleted' + if (diff.hunks.length === 0) return 'diff.unchanged' + return undefined +} + +/** + * Render a file comparison with the same states and highlighting in previews and review tabs. + * @param props - comparison state, layout choices, retry action, and localized copy. + * @returns the comparison or its loading, unavailable, or error state. + */ +export function FileDiff({ state, split, wrap, retry, t }: { + state: ChangesDiffState | undefined + split: boolean + wrap: boolean + retry: () => void +} & PropsLocale): ReactNode { + if (state === undefined || state === 'loading') return

{t('diff.loading')}

+ if (state === 'missing') return

{t('diff.missing')}

+ if (state === 'error') { + return
{t('diff.error')}
+ } + if (state.kind === 'binary') return

{t('diff.binary')}

+ if (state.kind === 'oversized') return

{t('diff.oversized')}

+ return +} + +/** The kind a paired row carries: a deletion or addition on either side, otherwise context. */ +function splitRowKind(row: SplitRow): DiffRow['kind'] { + return row.left?.kind === 'del' ? 'del' : row.right?.kind === 'add' ? 'add' : 'context' +} + +function hunkHeader(hunk: WorkspaceDiffHunk): string { + return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@` +} + +/** + * The side-by-side view without wrapping: two columns that clip their long + * lines and scroll together on both axes, so a long line on one side never + * runs under the other and both sides show the same rows and columns of text. + * Every line is one fixed-height row, which keeps the sides aligned. + */ +function SplitColumns({ hunks, highlights }: { hunks: readonly WorkspaceDiffHunk[]; highlights: readonly HunkHighlights[] }): ReactNode { + const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) + const columns = useRef>({ left: null, right: null }) + const offsets = useRef({ left: { scrollLeft: 0, scrollTop: 0 }, right: { scrollLeft: 0, scrollTop: 0 } }) + const follow = (side: 'left' | 'right') => (event: UIEvent): void => { + const peer = side === 'left' ? 'right' : 'left' + const other = columns.current[peer] + /* v8 ignore next -- Both column refs are attached before browser scroll events can run. */ + if (other === null) return + for (const axis of ['scrollLeft', 'scrollTop'] as const) { + const value = event.currentTarget[axis] + if (offsets.current[side][axis] === value) continue + offsets.current[side][axis] = value + other[axis] = value + // Record the browser-clamped offset so its scroll event cannot pull the source back. + offsets.current[peer][axis] = other[axis] + } + } + return ( +
+ {(['left', 'right'] as const).map(side => ( +
{ columns.current[side] = element }} onScroll={follow(side)}> + {paired.map((hunk, position) => ( +
+
{hunk.header}
+ {hunk.rows.map((row, at) => { + const cell = row[side] + const spans = cell === undefined ? undefined : highlights[position]?.[side === 'left' ? 'old' : 'new']?.get(cell.no) + return ( +
+ {cell?.no ?? ''} + +
+ ) + })} +
+ ))} +
+ ))} +
+ ) +} + +/** The hunks of a text comparison with their line numbers, unified or side by side. */ +function TextDiff({ diff, split, wrap, t }: { diff: Extract; split: boolean; wrap: boolean } & PropsLocale): ReactNode { + const note = noteOf(diff) + const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks]) + const highlighter = useCodeHighlighter(languageForPath(diff.path)) + const highlights = useMemo(() => hunks.map(hunk => hunkHighlights(hunk, highlighter)), [hunks, highlighter]) + return ( +
+ {note !== undefined &&

{t(note)}

} + {diff.coarse &&

{t('diff.coarse')}

} + {truncated &&

{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}

} + {split && !wrap ? : hunks.map((hunk, position) => { + const highlighted = highlights[position] + return
+
{hunkHeader(hunk)}
+ {split ? splitRows(hunk).map((row, at) => ( +
+ + {row.left?.no ?? ''} + + + + {row.right?.no ?? ''} + + +
+ )) : hunkRows(hunk).map((row, at) => ( +
+ {row.old ?? ''} + {row.new ?? ''} + {row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '} + +
+ ))} +
+ })} +
+ ) +} diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 362f88ec9d..158b2495f5 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -3,30 +3,26 @@ * selected file's turn-start and turn-end comparison drawn unified or side by * side, wrapped or scrolling, and controls to open the file itself. */ -import { useEffect, useMemo, useRef, useState } from 'react' -import type { ReactNode, UIEvent } from 'react' +import { useEffect, useMemo, useState } from 'react' +import type { ReactNode } from 'react' import { - Button, IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular, + IconChevronDownOutlineRegular, IconCompareSplitOutlineRegular, IconInspectOutlineRegular, IconNowrapFillRegular, IconWrapFillRegular, Menu, Tooltip, - languageForPath, useCodeHighlighter, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { CodeHighlighter, HighlightSpan } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { fileAddressFor } from '@deepseek-ai/dsh-util-workspace-path' -import type { WorkspaceChangedFile, WorkspaceDiffHunk } from '@deepseek-ai/dsh-workspace-changes/types' -import { changedFileUrl, changesDiffUrl, changesSummaryUrl, parseChangesReviewAddress, type ChangesDiff } from '../changes.ts' +import type { WorkspaceChangedFile } from '@deepseek-ai/dsh-workspace-changes/types' +import { changedFileUrl, changesDiffUrl, changesSummaryUrl, parseChangesReviewAddress } from '../changes.ts' import type { ChangesDiffStore } from './changes-diff.ts' import type { ChangesSummaryStore } from './changes-summary.ts' import type { PresentedOpenController } from './present-open.ts' import type { ChangesReviewParams } from './review-definition.ts' import type { createReviewStore } from './review-store.ts' import type { NS } from './locales.ts' +import { FileDiff } from './FileDiff.tsx' import css from './ReviewTab.module.css' -/** Lines drawn before the tab stops; a coarse comparison of a file near the byte cap would otherwise draw every line. */ -export const MAX_RENDERED_LINES = 5000 - const GROUPED = new Intl.NumberFormat('en-US') /** Summary and comparison reads, desktop metadata, and the native open supplied by the plugin. */ @@ -47,125 +43,6 @@ export interface ReviewInjected { export type ReviewTabProps = PropsRuntime<'sidebar.right.pane.tab'> & PropsStore> & InjectFace & PropsLocale & PropsRenderSlots<'deliverables.review.file.actions'> -/** One drawn line of a hunk with its line numbers on each side. */ -export interface DiffRow { - kind: 'add' | 'del' | 'context' - old: number | undefined - new: number | undefined - text: string -} - -/** One side-by-side row: the old side, the new side, or both. */ -export interface SplitRow { - left?: { no: number; text: string; kind: 'del' | 'context' } - right?: { no: number; text: string; kind: 'add' | 'context' } -} - -interface HunkHighlights { - old: ReadonlyMap | undefined - new: ReadonlyMap | undefined -} - -function highlightedSide(rows: readonly DiffRow[], side: 'old' | 'new', highlighter: CodeHighlighter): HunkHighlights['old'] { - const source = rows.flatMap((row) => { - const no = row[side] - return no === undefined ? [] : [{ no, text: row.text }] - }) - if (source.length === 0) return new Map() - const highlighted = highlighter(source.map(line => line.text).join('\n')) - if (highlighted === undefined) return undefined - return new Map(source.map((line, index) => { - // Shiki omits one terminal empty token row; retain an aligned empty run for that source line. - return [line.no, highlighted[index] ?? []] - })) -} - -function hunkHighlights(hunk: WorkspaceDiffHunk, highlighter: CodeHighlighter): HunkHighlights { - const rows = hunkRows(hunk) - return { old: highlightedSide(rows, 'old', highlighter), new: highlightedSide(rows, 'new', highlighter) } -} - -function DiffText({ text, spans }: { text: string; spans: readonly HighlightSpan[] | undefined }): ReactNode { - return - {spans === undefined ? text : spans.map((span, index) => {span.text})} - -} - -/** - * Number a hunk's lines: context lines count on both sides, deletions on the - * old side, additions on the new side. - * @param hunk - a served hunk. - * @returns the rows in order. - */ -export function hunkRows(hunk: WorkspaceDiffHunk): DiffRow[] { - let oldNo = hunk.oldStart - let newNo = hunk.newStart - return hunk.lines.map((line) => { - const text = line.slice(1) - switch (line[0]) { - case '+': return { kind: 'add', old: undefined, new: newNo++, text } - case '-': return { kind: 'del', old: oldNo++, new: undefined, text } - default: return { kind: 'context', old: oldNo++, new: newNo++, text } - } - }) -} - -/** - * Pair a hunk's lines for the side-by-side view: each run of deletions is - * aligned with the run of additions that follows it, row by row, and context - * lines sit on both sides. - * @param hunk - a served hunk. - * @returns the rows in order. - */ -export function splitRows(hunk: WorkspaceDiffHunk): SplitRow[] { - const rows: SplitRow[] = [] - let dels: NonNullable[] = [] - let adds: NonNullable[] = [] - const flush = (): void => { - for (let at = 0; at < Math.max(dels.length, adds.length); at += 1) { - const left = dels[at] - const right = adds[at] - rows.push({ ...left === undefined ? {} : { left }, ...right === undefined ? {} : { right } }) - } - dels = [] - adds = [] - } - for (const row of hunkRows(hunk)) { - if (row.kind === 'del') dels.push({ no: row.old as number, text: row.text, kind: 'del' }) - else if (row.kind === 'add') adds.push({ no: row.new as number, text: row.text, kind: 'add' }) - else { - flush() - rows.push({ left: { no: row.old as number, text: row.text, kind: 'context' }, right: { no: row.new as number, text: row.text, kind: 'context' } }) - } - } - flush() - return rows -} - -/** - * The hunks to draw, cut at {@link MAX_RENDERED_LINES} lines in total. - * @param hunks - served hunks. - * @returns the hunks with the last one shortened as needed, and whether anything was cut. - */ -export function renderedHunks(hunks: readonly WorkspaceDiffHunk[]): { hunks: WorkspaceDiffHunk[]; truncated: boolean } { - let budget = MAX_RENDERED_LINES - const kept: WorkspaceDiffHunk[] = [] - for (const hunk of hunks) { - if (budget === 0) return { hunks: kept, truncated: true } - kept.push(hunk.lines.length <= budget ? hunk : { ...hunk, lines: hunk.lines.slice(0, budget) }) - budget -= Math.min(budget, hunk.lines.length) - } - return { hunks: kept, truncated: hunks.some((hunk, at) => kept[at] !== hunk) } -} - -/** The one-line fact about a text comparison worth stating above its hunks, if any. */ -function noteOf(diff: Extract): 'diff.created' | 'diff.deleted' | 'diff.unchanged' | undefined { - if (!diff.before) return 'diff.created' - if (!diff.after) return 'diff.deleted' - if (diff.hunks.length === 0) return 'diff.unchanged' - return undefined -} - /** The file index a navigation names, when it names one. */ function navigatedIndex(params: unknown): number | undefined { const index = (params as ChangesReviewParams | undefined)?.index @@ -275,124 +152,8 @@ export function ReviewTab({
{summaryState === 'loading' &&

{t('diff.loading')}

} {summaryState === 'missing' &&

{t('diff.missing')}

} - {file !== undefined && { void loadChangesDiff(sessionId, seq, index) }} />}
) } - -/** The selected file's comparison, or the state that stands in for it. */ -function FileBody({ state, split, wrap, retry, t }: { - state: ChangesDiffStore['state'] extends { getSnapshot(): infer S } ? (S extends Record ? V : never) : never - split: boolean - wrap: boolean - retry: () => void -} & PropsLocale): ReactNode { - if (state === undefined || state === 'loading') return

{t('diff.loading')}

- if (state === 'missing') return

{t('diff.missing')}

- if (state === 'error') { - return
{t('diff.error')}
- } - if (state.kind === 'binary') return

{t('diff.binary')}

- if (state.kind === 'oversized') return

{t('diff.oversized')}

- return -} - -/** The kind a paired row carries: a deletion or addition on either side, otherwise context. */ -function splitRowKind(row: SplitRow): DiffRow['kind'] { - return row.left?.kind === 'del' ? 'del' : row.right?.kind === 'add' ? 'add' : 'context' -} - -function hunkHeader(hunk: WorkspaceDiffHunk): string { - return `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@` -} - -/** - * The side-by-side view without wrapping: two columns that clip their long - * lines and scroll together on both axes, so a long line on one side never - * runs under the other and both sides show the same rows and columns of text. - * Every line is one fixed-height row, which keeps the sides aligned. - */ -function SplitColumns({ hunks, highlights }: { hunks: readonly WorkspaceDiffHunk[]; highlights: readonly HunkHighlights[] }): ReactNode { - const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) - const columns = useRef>({ left: null, right: null }) - const offsets = useRef({ left: { scrollLeft: 0, scrollTop: 0 }, right: { scrollLeft: 0, scrollTop: 0 } }) - const follow = (side: 'left' | 'right') => (event: UIEvent): void => { - const peer = side === 'left' ? 'right' : 'left' - const other = columns.current[peer] - /* v8 ignore next -- Both column refs are attached before browser scroll events can run. */ - if (other === null) return - for (const axis of ['scrollLeft', 'scrollTop'] as const) { - const value = event.currentTarget[axis] - if (offsets.current[side][axis] === value) continue - offsets.current[side][axis] = value - other[axis] = value - // Record the browser-clamped offset so its scroll event cannot pull the source back. - offsets.current[peer][axis] = other[axis] - } - } - return ( -
- {(['left', 'right'] as const).map(side => ( -
{ columns.current[side] = element }} onScroll={follow(side)}> - {paired.map((hunk, position) => ( -
-
{hunk.header}
- {hunk.rows.map((row, at) => { - const cell = row[side] - const spans = cell === undefined ? undefined : highlights[position]?.[side === 'left' ? 'old' : 'new']?.get(cell.no) - return ( -
- {cell?.no ?? ''} - -
- ) - })} -
- ))} -
- ))} -
- ) -} - -/** The hunks of a text comparison with their line numbers, unified or side by side. */ -function TextDiff({ diff, split, wrap, t }: { diff: Extract; split: boolean; wrap: boolean } & PropsLocale): ReactNode { - const note = noteOf(diff) - const { hunks, truncated } = useMemo(() => renderedHunks(diff.hunks), [diff.hunks]) - const highlighter = useCodeHighlighter(languageForPath(diff.path)) - const highlights = useMemo(() => hunks.map(hunk => hunkHighlights(hunk, highlighter)), [hunks, highlighter]) - return ( -
- {note !== undefined &&

{t(note)}

} - {diff.coarse &&

{t('diff.coarse')}

} - {truncated &&

{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}

} - {split && !wrap ? : hunks.map((hunk, position) => { - const highlighted = highlights[position] - return
-
{hunkHeader(hunk)}
- {split ? splitRows(hunk).map((row, at) => ( -
- - {row.left?.no ?? ''} - - - - {row.right?.no ?? ''} - - -
- )) : hunkRows(hunk).map((row, at) => ( -
- {row.old ?? ''} - {row.new ?? ''} - {row.kind === 'add' ? '+' : row.kind === 'del' ? '-' : ' '} - -
- ))} -
- })} -
- ) -} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index f000547756..886456e328 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -66,8 +66,9 @@ export function apply(ctx: ClientContext): void { locale: NS, children: { 'deliverables.file.actions': { kind: 'list', scope: 'session' } }, inject: (): DeliverablesInjected => ({ - hooks: { presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state, + hooks: { changesDiff: diffs.state, presentedOpen: opener.state, presentedHost: opener.host, changesSummary: summaries.state, showCodeDiff: ctx.settingsScope.developerTools.enabled }, + loadChangesDiff: (sessionId, seq, index) => diffs.load(sessionId, seq, index), reloadPresentedHost: () => opener.loadHost(), loadChangesSummary: (sessionId, seq) => summaries.load(sessionId, seq), openPresented: (sessionId, seq, index, action, application) => opener.open(sessionId, seq, index, action, application), diff --git a/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx b/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx new file mode 100644 index 0000000000..a78c38cbcb --- /dev/null +++ b/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx @@ -0,0 +1,94 @@ +// @vitest-environment jsdom +/** Hover dwell, lazy comparison reads, and Sidebar navigation from turn-tail rows. */ +import { useSyncExternalStore } from 'react' +import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import { ChangedFiles } from '../src/client/ChangedFiles.tsx' +import { ChangesDiffStore } from '../src/client/changes-diff.ts' +import { changesDiffUrl } from '../src/changes.ts' +import { en } from '../src/client/locales.ts' + +beforeEach(() => { vi.useFakeTimers() }) +afterEach(() => { cleanup(); vi.useRealTimers() }) + +const sessionId = SessionId('hover-session') +const files = ['main.ts', 'second.ts'].map(path => ({ path, display: path, added: 1, deleted: 1 })) + +function mount() { + const diffs = new ChangesDiffStore() + const loadChangesDiff = vi.fn().mockResolvedValue(undefined) + const openReview = vi.fn() + const subscribe = diffs.state.subscribe.bind(diffs.state) + const getSnapshot = diffs.state.getSnapshot.bind(diffs.state) + const view = render( select(useSyncExternalStore(subscribe, getSnapshot))} />) + return { view, diffs, loadChangesDiff, openReview, row: screen.getByRole('button', { name: 'View changes to main.ts' }) } +} + +it('reads only after 500ms and renders the selected comparison in a single column', () => { + const { row, diffs, loadChangesDiff, openReview } = mount() + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(499) }) + expect(loadChangesDiff).not.toHaveBeenCalled() + expect(document.querySelector('[data-changes-hover-preview]')).toBeNull() + act(() => { vi.advanceTimersByTime(1) }) + expect(loadChangesDiff).toHaveBeenCalledExactlyOnceWith(sessionId, 5, 0) + expect(screen.getByText('Reading changes…')).toBeTruthy() + act(() => { diffs.state.set({ [changesDiffUrl(sessionId, 5, 0)]: { + kind: 'text', path: 'main.ts', display: 'main.ts', before: true, after: true, coarse: false, + hunks: [{ oldStart: 1, oldLines: 1, newStart: 1, newLines: 1, lines: ['-const value = 1', '+const value = 2'] }], + } }) }) + const preview = document.querySelector('[data-changes-hover-preview]')! + expect(preview.querySelector('[data-review-view="unified"]')).not.toBeNull() + expect(preview.querySelector('[data-diff-side]')).toBeNull() + expect(preview.querySelector('[data-diff-line="del"]')?.textContent).toContain('const value = 1') + expect(preview.querySelector('[data-diff-line="add"]')?.textContent).toContain('const value = 2') + expect(preview.textContent).toContain('/workspace/main.ts') + fireEvent.pointerLeave(row) + fireEvent.pointerEnter(preview) + act(() => { vi.advanceTimersByTime(500) }) + expect(document.querySelector('[data-changes-hover-preview]')).toBe(preview) + fireEvent.pointerDown(row) + fireEvent.click(row) + expect(preview.parentElement?.hasAttribute('data-closing')).toBe(true) + act(() => { vi.advanceTimersByTime(99) }) + expect(document.querySelector('[data-changes-hover-preview]')).toBe(preview) + act(() => { vi.advanceTimersByTime(1) }) + expect(document.querySelector('[data-changes-hover-preview]')).toBeNull() + expect(openReview).toHaveBeenCalledExactlyOnceWith(0) +}) + +it('cancels a short hover and the pending dwell when unmounted', () => { + const { row, view, loadChangesDiff } = mount() + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(499) }) + fireEvent.pointerLeave(row) + act(() => { vi.advanceTimersByTime(500) }) + expect(loadChangesDiff).not.toHaveBeenCalled() + fireEvent.pointerEnter(row) + view.unmount() + act(() => { vi.advanceTimersByTime(500) }) + expect(loadChangesDiff).not.toHaveBeenCalled() +}) + +it('previews each row independently, retries failed reads, and closes on Escape', () => { + const { row, diffs, loadChangesDiff } = mount() + diffs.state.set({ [changesDiffUrl(sessionId, 5, 0)]: 'error', [changesDiffUrl(sessionId, 5, 1)]: 'missing' }) + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.click(screen.getByRole('button', { name: 'Retry' })) + expect(loadChangesDiff).toHaveBeenCalledExactlyOnceWith(sessionId, 5, 0) + fireEvent.pointerLeave(row) + fireEvent.pointerEnter(screen.getByRole('button', { name: 'View changes to second.ts' })) + act(() => { vi.advanceTimersByTime(200) }) + act(() => { vi.advanceTimersByTime(300) }) + expect(document.querySelectorAll('[data-changes-hover-preview]')).toHaveLength(1) + expect(screen.getByText('/workspace/second.ts')).toBeTruthy() + fireEvent.keyDown(screen.getByRole('button', { name: 'View changes to second.ts' }), { key: 'Escape' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(document.querySelector('[data-changes-hover-preview]')).toBeNull() +}) diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index 831c32a74d..4756af65c6 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -26,6 +26,7 @@ import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-clie import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { Deliverables, DeliverablesTail, selectDeliverables, type DeliverablesInjected } from '../src/client/Deliverables.tsx' import type { ReviewInjected } from '../src/client/ReviewTab.tsx' +import { ChangesDiffStore } from '../src/client/changes-diff.ts' import { ChangesSummaryStore } from '../src/client/changes-summary.ts' import { changesSummaryUrl, type ChangesSummary } from '../src/changes.ts' import { PresentedOpenController } from '../src/client/present-open.ts' @@ -40,10 +41,13 @@ import type { SessionEvent } from '@deepseek-ai/dsh-session/types' function openProps(controller = new PresentedOpenController(), summaries = new ChangesSummaryStore()) { controller.host.set({ name: 'desktop', available: true, fileManager: 'finder' }) + const diffs = new ChangesDiffStore() const sessions: SessionListState = { ids: [], byId: {}, phase: 'ready', projectionsBySession: {} } return { SessionProvider: ({ children }: { children?: import('react').ReactNode }) => <>{children}, renderSlot: renderFileActions, + useChangesDiff: (select: (state: ReturnType) => T): T => select(diffs.state.getSnapshot()), + loadChangesDiff: vi.fn((...args: Parameters) => diffs.load(...args)), useShowCodeDiff: (select: (value: boolean) => T): T => select(true), useSessions: (select: (state: SessionListState) => T): T => select(sessions), reloadPresentedHost: vi.fn(() => controller.loadHost()), @@ -616,7 +620,7 @@ describe('ChangedFiles card', () => { fireEvent.click(expand) expect(within(card).getAllByRole('listitem')).toHaveLength(5) expect(within(card).getByText('binary')).toBeTruthy() - expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBe('/home/u/.zshrc') + expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBeNull() fireEvent.click(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' })) expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 4) const collapse = within(card).getByRole('button', { name: 'Collapse changed files' }) diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 2af2a12b4a..de97d56dac 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -18,8 +18,9 @@ import { ChangesDiffStore } from '../src/client/changes-diff.ts' import { ChangesSummaryStore } from '../src/client/changes-summary.ts' import { PresentedOpenController } from '../src/client/present-open.ts' import { - hunkRows, MAX_RENDERED_LINES, renderedHunks, ReviewTab, splitRows, type ReviewInjected, type ReviewTabProps, + ReviewTab, type ReviewInjected, type ReviewTabProps, } from '../src/client/ReviewTab.tsx' +import { hunkRows, MAX_RENDERED_LINES, renderedHunks, splitRows } from '../src/client/FileDiff.tsx' import { changesReviewDefinition } from '../src/client/review-definition.ts' import { createReviewStore } from '../src/client/review-store.ts' import { en, zh } from '../src/client/locales.ts' diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index e9518efb50..499ec1d975 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: 6ba9e8f79b9b8f8473fec2094a724e9d371e0f4e -README.zh.md: 919659266ca729dfdaf50b00783cff065112ee07 +README.md: f639551b1ff008e226a9e9fc42784160c87b41cd +README.zh.md: 8054de52d2bd1dac6a2550e94906f4a15c269a72 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index 6ba9e8f79b..f639551b1f 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -72,7 +72,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel caps its height at 420px and follows content and anchor resizing; Escape dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 919659266c..8054de52d2 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -72,7 +72,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 8d425ca85c..551b25c89a 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -52,3 +52,31 @@ clip: rect(0 0 0 0); white-space: nowrap; } + +.preview { + display: flex; + flex-direction: column; + max-height: 420px; + padding: 0; + overflow: hidden; + border: 0; + border-radius: 12px; + background: var(--dsw-alias-bg-layer-1); + color: var(--dsw-alias-label-primary); + box-shadow: var(--dsw-elevation-panel); + opacity: 1; + transition: opacity var(--dsh-hover-preview-fade) linear; +} + +@starting-style { + .preview { opacity: 0; } +} + +.preview[data-closing] { + opacity: 0; + pointer-events: none; +} + +@media (prefers-reduced-motion: reduce) { + .preview { transition: none; } +} diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index 216df4c5d5..b08e5dd5ec 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,17 +1,24 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { ReactNode } from 'react' +import type { CSSProperties, ReactNode, RefObject } from 'react' +import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' import css from './HoverCard.module.css' +/** Preview opacity transition and retained lifetime during dismissal. */ +const PREVIEW_FADE_MS = 100 + /** * Render an anchor with a hover-triggered preview card. * @param props.anchor - the hover target (rendered in place inside a wrapper span). * @param props.content - card content; the pointer may rest on it, so it is * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). - * @param props.disabled - suppress opening; turning true closes an open card. + * @param props.variant - compact card beside the anchor, or a preview above/below it + * with 24px side insets, a 420px height cap, and 100ms opacity transitions. + * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. + * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.copyText - optional primary value copied by activation and * included in the card's accessible name. * @param props.copyLabel - localized accessible activation-label prefix. @@ -20,16 +27,19 @@ import css from './HoverCard.module.css' */ export function HoverCard({ anchor, content, openDelayMs = 500, disabled = false, - copyText, copyLabel, copiedLabel, + copyText, copyLabel, copiedLabel, variant = 'compact', widthAnchorRef, }: { anchor: ReactNode content: ReactNode openDelayMs?: number disabled?: boolean - copyText?: string | undefined - copyLabel: string - copiedLabel: string -}) { + variant?: 'compact' | 'preview' + widthAnchorRef?: RefObject +} & ({ copyText?: string | undefined; copyLabel: string; copiedLabel: string } | { + copyText?: undefined + copyLabel?: string + copiedLabel?: string +})) { const rootRef = useRef(null) const cardRef = useRef(null) const timerRef = useRef | null>(null) @@ -38,8 +48,11 @@ export function HoverCard({ const copyEpochRef = useRef(0) const copyingRef = useRef(false) const mountedRef = useRef(true) - const [open, setOpen] = useState(false) - const [pos, setPos] = useState<{ left: number; top: number } | null>(null) + const [phase, setPhase] = useState<'closed' | 'open' | 'closing'>('closed') + const open = phase !== 'closed' + const closing = phase === 'closing' + const [pos, setPos] = useState<{ left: number; top: number; width?: number; maxHeight?: number } | null>(null) + const positioned = pos !== null const [copied, setCopied] = useState(false) const clearCopied = useCallback(() => { @@ -54,8 +67,8 @@ export function HoverCard({ const close = useCallback(() => { copyEpochRef.current += 1 clearCopied() - setOpen(false) - }, [clearCopied]) + setPhase(current => variant === 'preview' && current !== 'closed' ? 'closing' : 'closed') + }, [clearCopied, variant]) const { arm: armClose, cancel: cancelClose } = usePointerGrace(close) @@ -66,7 +79,13 @@ export function HoverCard({ } } - // Owner disabling mid-hover (menu opened, drag started) closes immediately. + useEffect(() => { + if (!closing) return + const timer = setTimeout(() => { setPhase('closed') }, PREVIEW_FADE_MS) + return () => { clearTimeout(timer) } + }, [closing]) + + // Owner disabling mid-hover (menu opened, drag started) starts dismissal. useEffect(() => { if (!disabled) return clearTimer() @@ -87,6 +106,17 @@ export function HoverCard({ } }, []) + useEffect(() => { + if (!open || variant !== 'preview') return + const dismiss = (event: KeyboardEvent): void => { + if (event.key !== 'Escape') return + cancelClose() + close() + } + window.addEventListener('keydown', dismiss) + return () => { window.removeEventListener('keydown', dismiss) } + }, [open, variant, cancelClose, close]) + // Fixed-position from the anchor rect before paint; track the anchor while // open (capture-phase scroll catches nested panes), as in Menu portal mode. useLayoutEffect(() => { @@ -97,29 +127,48 @@ export function HoverCard({ if (wrapper === null) return const r = wrapper.getBoundingClientRect() const h = cardRef.current?.offsetHeight ?? 0 + if (variant === 'preview') { + const bounds = widthAnchorRef?.current?.getBoundingClientRect() ?? r + const width = Math.max(0, Math.min(bounds.width - 48, window.innerWidth - 16)) + const above = Math.max(0, r.top - 16) + const below = Math.max(0, window.innerHeight - r.bottom - 16) + const onTop = above >= Math.min(420, below) + const maxHeight = Math.min(420, onTop ? above : below) + setPos({ + left: Math.max(8, Math.min(bounds.left + 24, window.innerWidth - width - 8)), + top: onTop ? Math.max(8, r.top - Math.min(h, maxHeight) - 8) : r.bottom + 8, + width, maxHeight, + }) + return + } const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top setPos({ left: r.right + 8, top }) } place() + const observer = variant === 'preview' && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null + for (const element of [cardRef.current, rootRef.current, widthAnchorRef?.current]) { + if (element !== null && element !== undefined) observer?.observe(element) + } window.addEventListener('scroll', place, true) window.addEventListener('resize', place) return () => { + observer?.disconnect() window.removeEventListener('scroll', place, true) window.removeEventListener('resize', place) } - }, [open]) + }, [open, variant, widthAnchorRef, positioned]) // The first placement ran before the card mounted (height read 0): once the // card's real height is measurable, correct the bottom-edge clamp. The // correction converges — a clamped top satisfies the guard, so it runs once. useLayoutEffect(() => { - if (!open || pos === null) return + if (!open || pos === null || variant === 'preview') return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 if (pos.top + h > window.innerHeight - 8) { setPos({ left: pos.left, top: window.innerHeight - h - 8 }) } - }, [open, pos]) + }, [open, pos, variant]) const copy = async (text: string): Promise => { if (copied || copyingRef.current) return @@ -139,8 +188,12 @@ export function HoverCard({ const card = open && pos !== null && (
{ setOpen(true) }, openDelayMs) + timerRef.current = setTimeout(() => { setPhase('open') }, openDelayMs) }} onPointerLeave={() => { clearTimer() @@ -187,7 +240,7 @@ export function HoverCard({ if (open) armClose() }} // A press inside the anchor (row click, menu trigger) dismisses the - // card immediately, without waiting for the owner to flip `disabled`. + // card, without waiting for the owner to flip `disabled`. // Capture presses reach this handler from the card too — it is a React // child of the wrapper — but a press there starts a selection, so the // card must stay mounted under it (and the browser's click with it). diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 5bb6f5786e..7a2195949f 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -6,7 +6,7 @@ import { POINTER_GRACE_MS } from '../src/pointer-grace.ts' afterEach(cleanup) beforeEach(() => { vi.useFakeTimers() }) -afterEach(() => { vi.useRealTimers() }) +afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals() }) /** Anchor wrapper rect: the card positions from this (jsdom rects are all-zero by default). */ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number }): void { @@ -18,6 +18,8 @@ function stubAnchorRect(anchor: HTMLElement, rect: { top: number; right: number } function mount(props: { + widthAnchorRef?: { current: HTMLElement | null } + variant?: 'compact' | 'preview' openDelayMs?: number disabled?: boolean copyText?: string @@ -65,6 +67,90 @@ describe('HoverCard', () => { expect(card.style.top).toBe('40px') }) + it('insets a preview by 24px on each side and follows anchor resizing', () => { + const { wrapper } = mount({ variant: 'preview' }) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 600, height: 32 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.style.width).toBe('552px') + expect(card.style.left).toBe('124px') + expect(card.style.maxHeight).toBe('420px') + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 400, height: 32 }) + fireEvent(window, new Event('resize')) + expect(card.style.width).toBe('352px') + fireEvent.keyDown(wrapper, { key: 'Escape' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('places previews below rows near the top and dismisses without anchor focus', () => { + const { wrapper } = mount({ variant: 'preview' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(card.style.top).toBe('82px') + expect(card.style.maxHeight).toBe('420px') + fireEvent.keyDown(document.body, { key: 'a' }) + expect(screen.queryByText('card body')).not.toBeNull() + fireEvent.keyDown(document.body, { key: 'Escape' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).toBeNull() + }) + + it('observes preview and container sizes until dismissal', () => { + let resize = () => {} + const observe = vi.fn() + const disconnect = vi.fn() + vi.stubGlobal('ResizeObserver', class { + constructor(callback: () => void) { resize = callback } + observe = observe + disconnect = disconnect + }) + const container = document.createElement('div') + container.getBoundingClientRect = () => DOMRect.fromRect({ x: 10, width: 700 }) + const { wrapper, view } = mount({ variant: 'preview', widthAnchorRef: { current: container } }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + expect(observe).toHaveBeenCalledWith(card) + expect(observe).toHaveBeenCalledWith(container) + expect(card.style.width).toBe('652px') + expect(card.style.left).toBe('34px') + container.getBoundingClientRect = () => DOMRect.fromRect({ x: 10, width: 500 }) + act(() => { resize() }) + expect(card.style.width).toBe('452px') + const previousDisconnects = disconnect.mock.calls.length + view.unmount() + expect(disconnect.mock.calls.length).toBe(previousDisconnects + 1) + }) + + it('retains a dismissing preview for 100ms and cancels dismissal on re-entry', () => { + const { wrapper, view } = mount({ variant: 'preview' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + fireEvent.pointerLeave(wrapper) + act(() => { vi.advanceTimersByTime(POINTER_GRACE_MS) }) + expect(card.hasAttribute('data-closing')).toBe(true) + act(() => { vi.advanceTimersByTime(99) }) + expect(screen.queryByText('card body')).not.toBeNull() + fireEvent.pointerEnter(wrapper) + expect(card.hasAttribute('data-closing')).toBe(false) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).not.toBeNull() + fireEvent.keyDown(document.body, { key: 'Escape' }) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).toBeNull() + fireEvent.pointerDown(wrapper) + expect(vi.getTimerCount()).toBe(0) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.keyDown(document.body, { key: 'Escape' }) + view.unmount() + expect(vi.getTimerCount()).toBe(0) + }) + it('honors a custom openDelayMs', () => { const { wrapper } = mount({ openDelayMs: 50 }) fireEvent.pointerEnter(wrapper) diff --git a/snapshots/web/changed-files-turn/hover.expected.md b/snapshots/web/changed-files-turn/hover.expected.md new file mode 100644 index 0000000000..b5bf46a909 --- /dev/null +++ b/snapshots/web/changed-files-turn/hover.expected.md @@ -0,0 +1 @@ +- text: 1 1 start 2 + done From c890269a19f0d4dff5f5666885c63be7eb7f6c0d Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 04:16:21 +0800 Subject: [PATCH 11/20] feat(client): add definition-owned conversation groups --- docs/subsystems/conversation.i18n.yaml | 4 +- docs/subsystems/conversation.md | 24 ++ docs/subsystems/conversation.zh.md | 24 ++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/contract/conversation.ts | 17 ++ .../src/client/contract/groups.ts | 119 ++++++++ .../src/client/contract/snapshot.ts | 2 +- .../src/client/conversation/assembler.ts | 128 +++++++-- .../src/client/conversation/assembly.ts | 8 +- .../src/client/conversation/group-registry.ts | 48 ++++ .../src/client/conversation/group-store.ts | 226 +++++++++++++++ .../src/client/conversation/location-index.ts | 26 +- .../ui-conversation/src/client/index.ts | 9 +- .../conversation-assembler.client.spec.ts | 4 + .../conversation-group-store.client.spec.ts | 170 ++++++++++++ .../tests/conversation-groups.client.spec.ts | 258 ++++++++++++++++++ .../tests/plan-review-panel.client.spec.tsx | 2 +- .../user-questions-composer.client.spec.tsx | 2 +- 20 files changed, 1043 insertions(+), 36 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/contract/groups.ts create mode 100644 packages/client/ui-conversation/src/client/conversation/group-registry.ts create mode 100644 packages/client/ui-conversation/src/client/conversation/group-store.ts create mode 100644 packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts create mode 100644 packages/client/ui-conversation/tests/conversation-groups.client.spec.ts diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index 5ea67b1086..efb7d6d43a 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/conversation.md -conversation.md: 4d6324e71b4383dfc640c1873dd9ef31023a1098 -conversation.zh.md: 49f2d7dbdc67a926f88ca72ed8ae6a0622c54fb3 +conversation.md: 0478b546154bddffaa7003df77a995fdcbfa9c39 +conversation.zh.md: 386e25dcfbaa2f9137f723d7d6a3b03963a1e5a2 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index 4d6324e71b..0478b54615 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -16,6 +16,7 @@ The Session Controller owns the contiguous loaded logical-event window. Each `Se | Context | The engine-owned ordered Matches and current State for one `(kind, id)`. A transient event occupies one update Match; update-only evidence may remain pending until pagination supplies its unique durable start. | | Location | The engine-owned Session, Turn, or Step coordinates derived from durable boundary events. Definitions may publish typed data onto one Turn or Step. | | View Definition | A target package creates one incremental builder per Session and owns the final snapshot type for that target. | +| Group Definition | A business package derives one target's root references and group snapshots from materialized Nodes; it owns membership, segmentation, summaries, and incremental caches. | | View | A Slot entry such as Chat or Trajectory reads only its target snapshot and renders target-owned nodes. | Chat and Trajectory may recognize the same durable event family, but each keeps its own Definition State and final node payload. Shared target-neutral machinery is limited to identity routing, ordered replay, Location data, predecessor dependencies, and publication cadence. @@ -26,6 +27,29 @@ Each Session keeps a monotonic set of active targets. Creating or reading a targ The shell owns View selection and resolves the registered preferred View or Chat fallback before rendering when a binding is created or selected as current, and after View-roster changes. The assembler receives only the resolved target id and does not select Chat or another default target. A third-party View participates through the same selection and activation operations. A View Definition may expose `toolCallFocus(callId)`; the shell supplies Inspect only for a visible target that declares this capability, and the target maps the call id to its own focus identity. + +## Group Definitions + +The optional `ctx.uiConversation.groups.register(definition)` contribution uses the same effect-owned registry lifecycle as event and view registrations. It requires an existing View target and rejects duplicate target registrations without constructing a Builder. Each Session creates its own Group context only when that target activates. Registration replacement discards the old context and clears its observed groups during the existing registry rebuild. + +The [group types](../../packages/client/ui-conversation/src/client/contract/groups.ts) define the complete protocol: + +| Type | Meaning | +|---|---| +| `ConversationGroupDefinition` | `create()` initializes Session-local State; `update(context, input)` returns its next State; `buildGroups(context)` returns pending output or `null`. Replacement input requires entries and a complete group replacement. | +| `ConversationGroupInput` | `replace` supplies the ordered target Node keys, synchronous `readNode`, and timeline. `apply` also supplies projected `previous/current` Node changes and `changedTurns`, including lifecycle-only changes. Do not retain the reader in State. | +| `NodeReference` / `GroupReference` | Branded `NodeKey` or `GroupKey`, distinguished by `kind`. A Node reference may select a renderer-owned `groupPart`; omission selects the whole Node. | +| `GroupSnapshot` | Immutable group key, business data, and ordered Node references. Groups cannot contain other groups or own source Node data. | +| `GroupUpdate` | Optional `entries` replaces the complete root sequence; omission preserves it. `groups.replace.snapshots` replaces all groups; `groups.apply.upserts/removes` updates only named group records. Removal does not delete Nodes. | +| `ConversationGroupDataMap` | Declaration-merged target-to-data association shared by registration and `views.grouped(target)`. Undeclared targets have no group payload type. | +| `ConversationGroupedView` | Stable root `entries` and keyed `groupSource(key)` readers; a removed group reads as `undefined`. | + +The Builder exposes `groupInput()` when grouping is registered; missing input fails at first activation. It records the Node values after its own projections and preserves content-only order identity. The assembler supplies changed Turns with every update; direct ungrouped Builder callers may omit them. Builders with independent sources defer their notifications to `publish()`. First activation and ordinary flush share the same sequence: materialize Location data and Nodes, update the Builder, call the Group Definition, validate and install grouping, then publish every affected target and Location source. The group phase reuses the existing publication cadence. + +The Group store validates the whole submitted result before installation: root Group references and records correspond one-to-one, all referenced Nodes exist, and each `(NodeKey, groupPart)` occupies at most one root or member position. A whole Node cannot coexist with one of its parts. Duplicate upserts, duplicate removals, and simultaneous upsert/removal of a group fail. Data-only upserts retain root and member arrays, do not read Nodes, and notify only changed group sources; full replacement revalidates every reference. + +Renderers switch on the two reference kinds and choose components in the owning View, not through a renderer field in group data. The group body reads members separately from summary data. Presentation modes must retain component types, keys, and member parents; changing data membership may legitimately remount a moved member. The framework does not interpret part completeness, segmentation, or presentation policies. + ## Replayable event families Choose one stable business id before writing the Definition. Every event that contributes to the same Node must carry that id or derive it independently from its own payload; the client must never assign an update to “the latest unfinished” Context. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 49f2d7dbdc..386e25dcfb 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -16,6 +16,7 @@ Session Controller 拥有连续的已加载逻辑 event window。每个 `Session | Context | Engine 为一个 `(kind, id)` 拥有的有序 Match 与当前 State。一个瞬态 event 只占一个 update Match;只有 update 的证据可以保持 pending,直到分页补齐其唯一持久 start。 | | Location | Engine 根据持久 boundary event 推导的 Session、Turn 或 Step 坐标。Definition 可以向一个 Turn 或 Step 发布类型化数据。 | | View Definition | Target 包为每个 Session 创建一个增量 builder,并拥有该 target 的最终 snapshot 类型。 | +| Group Definition | 业务包从物化后的 Node 派生一个目标的根引用和组快照,拥有成员归属、分段、摘要及增量缓存。 | | View | Chat 或 Trajectory 等 Slot entry 只读取自身 target snapshot,并渲染 target 自有 node。 | Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留自己的 Definition State 与最终 node payload。共享的 target-neutral 机制只包括 identity routing、有序 replay、Location data、predecessor dependency 与 publication cadence。 @@ -26,6 +27,29 @@ Chat 与 Trajectory 可以识别同一个持久 event family,但各自保留 shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View roster 变化时,于渲染前解析已注册的偏好 View 或 Chat fallback。assembler 只接收解析后的 target id,不自行选择 Chat 或其他默认 target。第三方 View 使用相同的选择与激活操作。View Definition 可以提供 `toolCallFocus(callId)`;shell 仅为声明了此能力且可见的目标提供 Inspect,由目标将调用 id 映射为自己的焦点标识。 + +## Group Definition + +可选的 `ctx.uiConversation.groups.register(definition)` 贡献沿用事件及视图注册的 effect 生命周期。它要求已有 View 目标,拒绝重复目标注册,不构造 Builder。每个 Session 仅在目标激活时创建自己的 Group 上下文。注册替换通过既有注册重建流程丢弃旧上下文,并清空其被观察的组。 + +[分组类型](../../packages/client/ui-conversation/src/client/contract/groups.ts)定义完整协议: + +| 类型 | 含义 | +|---|---| +| `ConversationGroupDefinition` | `create()` 初始化 Session 内的 State;`update(context, input)` 返回下一份 State;`buildGroups(context)` 返回待发布输出或 `null`。替换输入要求输出 entries 与完整组替换。 | +| `ConversationGroupInput` | `replace` 提供目标节点的有序键、同步 `readNode` 及时间线。`apply` 还提供投影后的 `previous/current` 节点变化和 `changedTurns`,覆盖仅生命周期变化。不要把读取函数留在 State 中。 | +| `NodeReference` / `GroupReference` | 以 `kind` 区分的品牌类型 `NodeKey` 或 `GroupKey`。Node 引用可选择渲染器拥有的 `groupPart`,省略表示整个 Node。 | +| `GroupSnapshot` | 不可变的组键、业务数据和有序 Node 引用。Group 不包含其他 Group,也不拥有原 Node 数据。 | +| `GroupUpdate` | 可选的 `entries` 替换完整根序列,省略则保留。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 | +| `ConversationGroupDataMap` | 通过声明合并关联目标与数据类型,注册及 `views.grouped(target)` 共用。未声明的目标没有组载荷类型。 | +| `ConversationGroupedView` | 稳定的根 `entries` 与按键 `groupSource(key)` 读取器;已删除的组返回 `undefined`。 | + +注册分组时 Builder 必须提供 `groupInput()`,缺少输入方法在首次激活时报错。它记录自身投影后的节点值,并在仅正文更新时保留顺序数组身份。assembler 每次更新都提供变化轮次,直接调用未分组 Builder 的调用方可以省略。拥有独立来源的 Builder 把通知推迟到 `publish()`。首次激活与普通 flush 共用同一顺序:物化 Location 数据和 Node,更新 Builder,调用 Group Definition,校验并安装分组,再发布全部受影响目标及 Location 来源。分组阶段复用既有发布节奏。 + +Group 存储在安装前校验完整提交结果:根 Group 引用与记录一一对应,引用的 Node 全部存在,每个 `(NodeKey, groupPart)` 在根和成员位置中最多占位一次。整 Node 不能与自身任一部分共存。重复 upsert、重复删除以及同时 upsert 和删除一个组都会报错。仅数据 upsert 保留根及成员数组、不读取 Node,并只通知变化组的来源;完整替换重新校验全部引用。 + +渲染器按两种引用 kind 切换,由所属 View 选择组件,不通过组数据中的 renderer 字段选择。组 body 将成员与摘要数据分开读取。展示模式必须保留组件类型、key 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。 + ## 可回放 event family 编写 Definition 前先选定稳定的业务 id。构成同一个 Node 的每条事件都必须携带该 id,或只凭自身 payload 独立推导出该 id;Client 绝不能把 update 猜测为属于“最近一个未完成”的 Context。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2f52a3e505..e4719a752f 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 31b35ef7460c9fecd119e32ae5f696f55585a726 -README.zh.md: 1fb648bf9539cebc307e094c2fe46b1d1749edab +README.md: 496c60e29582573abe15253ad0efb46e5bd7125f +README.zh.md: 71d2afefd9b22e0af21d2cd496d53cda9ac6a46c diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 31b35ef746..496c60e295 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -31,6 +31,8 @@ The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its o A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it. +`UiConversation.groups` registers one optional business Group Definition per existing target. It consumes the Builder's projected Node changes and changed Turns after Node materialization, including first activation, and owns all segmentation and group data. The assembler validates and installs root references and keyed group snapshots before publishing Node, Group, and Location data sources. Ungrouped targets retain their original path. [Grouping](../../../docs/subsystems/conversation.md#group-definitions) defines input lifetime, typed registration, atomic updates, and renderer responsibilities. + Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. The shared request inspection serves every target: `ctx.uiConversation.inspectSystemPrompt(previous, event)` interprets system messages and positional replacements as immutable loaded-surface state. It selects the last nonempty surviving system node in surface order, retains only surviving replacement positions for chained rewrites, and withholds the prompt after an unindexed older endpoint until prepend replay supplies its order. Target-owned Definitions retain historical cards independently. `ctx.uiConversation.inspectRequestPrompt(previous, header, system)` classifies request changes against that effective prompt; ordinary messages and stream chunks require no system-state work. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 1fb648bf95..71d2afefd9 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -31,6 +31,8 @@ kind: "package-reference" shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 +`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费 Builder 投影后的节点变化及变化轮次,覆盖首次激活,并拥有全部分段规则与组数据。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。 + target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。共享的请求检查服务于每个 target:`ctx.uiConversation.inspectSystemPrompt(previous, event)` 将系统消息与位置替换解释为不可变的已加载 surface 状态。它按 surface 顺序选择最后一个非空的存活系统节点,为连续重写只保留存活的替换位置;遇到未建立索引的更早端点后,提示词保持不可用,直到向前补页回放提供其顺序。target 自有的 Definition 独立保留历史卡片。`ctx.uiConversation.inspectRequestPrompt(previous, header, system)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。 diff --git a/packages/client/ui-conversation/src/client/contract/conversation.ts b/packages/client/ui-conversation/src/client/contract/conversation.ts index d5cbe27108..333dd1e5ae 100644 --- a/packages/client/ui-conversation/src/client/contract/conversation.ts +++ b/packages/client/ui-conversation/src/client/contract/conversation.ts @@ -1,5 +1,8 @@ import type { SessionEventLike } from '@deepseek-ai/dsh-api-session-controller/client' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { + ConversationGroupData, ConversationGroupedView, ConversationGroupInput, +} from './groups.ts' /** Definition-local identity and lifecycle role extracted from one event. */ export interface ConversationMatchResult { @@ -141,6 +144,14 @@ export interface ConversationViewSnapshotStore { get>( target: Target, ): ConversationViewSnapshotMap[Target] | undefined + /** + * Read grouping for an activated target without activating another target. + * @param target - registered View target. + * @returns its grouped reader, when a Group Definition is active. + */ + grouped( + target: Target, + ): ConversationGroupedView> | undefined } /** Immutable public view of an assembled business Context. */ @@ -261,6 +272,7 @@ export interface ConversationViewBuilder + /** Publish local sources after all target snapshots and grouping results have been installed. */ + publish?(): void } /** Registry contribution that creates an isolated builder when a Session first uses this target. */ diff --git a/packages/client/ui-conversation/src/client/contract/groups.ts b/packages/client/ui-conversation/src/client/contract/groups.ts new file mode 100644 index 0000000000..4a7e532c0c --- /dev/null +++ b/packages/client/ui-conversation/src/client/contract/groups.ts @@ -0,0 +1,119 @@ +/** Definition-owned grouping over a target's already materialized Nodes. */ +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { ConversationTimelineSnapshot, ConversationViewNode } from './conversation.ts' + +/** Existing Node identity, without allocating another execution Node. */ +export type NodeKey = Branded<'ConversationNodeKey'> + +/** Definition-local group identity within one Session and target. */ +export type GroupKey = Branded<'ConversationGroupKey'> + +/** One whole Node or a renderer-owned part of that Node. */ +export interface NodeReference { + readonly kind: 'node' + readonly key: NodeKey + readonly groupPart?: string +} + +/** Root-list reference to a separately observed group. */ +export interface GroupReference { + readonly kind: 'group' + readonly key: GroupKey +} + +/** The two supported root rendering positions. */ +export type RenderEntry = NodeReference | GroupReference + +/** Immutable group data and its ordered, non-nested Node references. */ +export interface GroupSnapshot { + readonly key: GroupKey + readonly data: Data + readonly members: readonly NodeReference[] +} + +/** + * One atomic grouping update. Omitted entries preserve the root list; + * supplied entries replace it. Group removal never deletes source Nodes. + */ +export interface GroupUpdate { + readonly entries?: readonly RenderEntry[] + readonly groups: + | { readonly kind: 'replace'; readonly snapshots: readonly GroupSnapshot[] } + | { + readonly kind: 'apply' + readonly upserts: readonly GroupSnapshot[] + readonly removes: readonly GroupKey[] + } +} + +/** Target-processed Node values before and after one update. */ +export interface NodeChange { + readonly previous: Node | undefined + readonly current: Node +} + +interface GroupInputNodes { + readonly order: readonly NodeKey[] + /** @param key - target Node identity. @returns its current value during this synchronous update. */ + readonly readNode: (key: NodeKey) => Node | undefined + readonly timeline: ConversationTimelineSnapshot +} + +/** Target-local inputs; apply includes lifecycle changes without Node upserts. */ +export type ConversationGroupInput = GroupInputNodes & ( + | { readonly kind: 'replace' } + | { + readonly kind: 'apply' + readonly changes: readonly NodeChange[] + readonly changedTurns: readonly number[] + } +) + +/** Framework-owned State for one Session's registered Group Definition. */ +export interface ConversationGroupContext { + readonly state: State +} + +/** Business grouping rules, driven after the target Builder has installed Nodes. */ +export interface ConversationGroupDefinition< + Node extends ConversationViewNode = ConversationViewNode, + State = unknown, + Data = unknown, +> { + readonly kind: string + readonly target: string + /** @returns initial Session-local grouping State. */ + create(): State + /** + * Consume target Node changes, not raw Session events or presentation modes. + * @param context - current Session-local State. + * @param input - current target inputs; readNode is valid only during this call. + * @returns State adopted by the framework. + */ + update(context: ConversationGroupContext, input: ConversationGroupInput): State + /** + * Materialize pending grouping changes without changing the business State. + * @param context - State adopted after update. + * @returns complete output for replace input; an update or null when unchanged for apply input. + */ + buildGroups(context: ConversationGroupContext): GroupUpdate | null +} + +/** Merge-extensible group data associated with each registered target. */ +export interface ConversationGroupDataMap {} + +/** Group data declared by a target; undeclared targets have no group payload. */ +export type ConversationGroupData = + Target extends keyof ConversationGroupDataMap ? ConversationGroupDataMap[Target] : never + +/** Stable grouped reader; data-only updates preserve entries and member arrays. */ +export interface ConversationGroupedView { + readonly entries: readonly RenderEntry[] + /** + * Observe a group without subscribing to the root sequence. + * @param key - group identity within this target. + * @returns an identity-stable source, whose value is absent after removal. + */ + groupSource(key: GroupKey): ObservableSnapshot | undefined> +} diff --git a/packages/client/ui-conversation/src/client/contract/snapshot.ts b/packages/client/ui-conversation/src/client/contract/snapshot.ts index 4b3fa13a9f..9982999e30 100644 --- a/packages/client/ui-conversation/src/client/contract/snapshot.ts +++ b/packages/client/ui-conversation/src/client/contract/snapshot.ts @@ -10,7 +10,7 @@ export interface ConversationSnapshot { /** Empty Conversation value used before a Session binding is available. */ export const EMPTY_CONVERSATION_SNAPSHOT: ConversationSnapshot = { - views: { get: () => undefined }, + views: { get: () => undefined, grouped: () => undefined }, activeTargets: new Set(), } diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index 5259d1af0f..68b5ad9316 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -11,6 +11,10 @@ import type { ConversationViewSnapshotStore, } from '../contract/conversation.ts' import { conversationContextKey } from '../contract/conversation.ts' +import type { + ConversationGroupData, ConversationGroupDefinition, ConversationGroupedView, +} from '../contract/groups.ts' +import { ConversationGroupStore } from './group-store.ts' import { ConversationLocationIndex, type ConversationLocationDataChange, } from './location-index.ts' @@ -46,11 +50,18 @@ interface PendingMatch { interface ViewState { readonly target: string readonly definition: ConversationViewDefinition + readonly groupDefinition: ConversationGroupDefinition | undefined readonly isActive: ((snapshot: unknown) => boolean) | undefined builder: ConversationViewBuilder | undefined snapshot: unknown } +interface GroupState { + readonly definition: ConversationGroupDefinition + state: unknown + readonly store: ConversationGroupStore +} + const PUBLICATION_RANK: Record = { none: 0, 'animation-frame': 1, @@ -152,6 +163,16 @@ export interface ConversationViewDefinitions { entries(): readonly ConversationViewDefinition[] } +/** Registered grouping rules consumed by the target-neutral assembler. */ +export interface ConversationGroupDefinitions { + /** @returns grouping Definitions in registration order. */ + entries(): readonly ConversationGroupDefinition[] + /** @param target - View target. @returns its optional Group Definition. */ + forTarget(target: string): ConversationGroupDefinition | undefined +} + +const NO_GROUPS: ConversationGroupDefinitions = { entries: () => [], forTarget: () => undefined } + /** * Session-owned incremental engine that assembles business Contexts from a * contiguous Event window and materializes registered view snapshots. @@ -168,6 +189,8 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore private readonly revised = new Set() private readonly dependents = new Map>() private readonly views = new Map() + private readonly groups = new Map() + private readonly pendingGroupStores = new Set>() private readonly activeTargets = new Set() private hasMore = false private replacePending = true @@ -176,10 +199,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore /** * @param eventDefinitions - live Event Definition registry. * @param viewDefinitions - live view builder registry. + * @param groupDefinitions - optional registered grouping rules, independent of presentation modes. */ constructor( private readonly eventDefinitions: ConversationEventDefinitions, private readonly viewDefinitions: ConversationViewDefinitions, + private readonly groupDefinitions: ConversationGroupDefinitions = NO_GROUPS, ) { this.resetViewBuilders() } @@ -333,28 +358,24 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore if (!this.replacePending && this.dirty.size === 0 && !this.timelineDirty) return false if (this.replacePending) { this.replaceLocationData() - let published = false + const updated: ViewState[] = [] + const changedTurns = this.locationIndex.takeChangedTurns() for (const target of this.activeTargets) { const view = this.views.get(target) if (view === undefined) continue - const builder = view.builder ?? view.definition.create() - view.builder = builder - view.snapshot = builder.replace({ - nodes: this.buildTargetNodes(target, this.contextsByTarget.get(target)), - timeline: this.locationIndex.snapshot(), - }) - published = true + this.updateView(view, true, this.buildTargetNodes(target, this.contextsByTarget.get(target)), changedTurns) + updated.push(view) } - this.locationIndex.publishData() this.replacePending = false this.dirty.clear() this.dirtyByTarget.clear() this.timelineDirty = false - return published + return this.publishViews(updated) } - let published = false + const updated: ViewState[] = [] if (this.applyDirtyLocationData()) this.timelineDirty = true + const changedTurns = this.locationIndex.takeChangedTurns() const timelineDirty = this.timelineDirty for (const target of this.activeTargets) { const view = this.views.get(target) @@ -363,17 +384,13 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore if (builder === undefined) continue const upserts = this.buildTargetUpserts(target, this.dirtyByTarget.get(target)) if (upserts.length === 0 && !timelineDirty) continue - view.snapshot = builder.apply({ - upserts, - timeline: this.locationIndex.snapshot(), - }) - published = true + this.updateView(view, false, upserts, changedTurns) + updated.push(view) } - this.locationIndex.publishData() this.dirty.clear() this.dirtyByTarget.clear() this.timelineDirty = false - return published + return this.publishViews(updated) } /** @@ -389,6 +406,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore this.activeTargets.add(target) if (view === undefined) return published this.replaceView(view) + this.publishViews([view]) return true } @@ -407,6 +425,12 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined } + grouped( + target: Target, + ): ConversationGroupedView> | undefined { + return this.groups.get(target)?.store as ConversationGroupedView> | undefined + } + /** * Read targets whose owners classify their latest snapshot as visible activity. * @returns target ids contributing visible activity. @@ -827,12 +851,55 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore } private replaceView(view: ViewState): void { + this.updateView(view, true, this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)), []) + } + + private updateView( + view: ViewState, + replacing: boolean, + nodes: readonly ConversationViewNode[], + changedTurns: readonly number[], + ): void { const builder = view.builder ?? view.definition.create() + const definition = view.groupDefinition + if (definition !== undefined && builder.groupInput === undefined) { + throw new Error(`conversation group target "${view.target}" requires builder.groupInput()`) + } view.builder = builder - view.snapshot = builder.replace({ - nodes: this.buildTargetNodes(view.target, this.contextsByTarget.get(view.target)), - timeline: this.locationIndex.snapshot(), - }) + const timeline = this.locationIndex.snapshot() + const snapshot = replacing + ? builder.replace({ nodes, timeline, changedTurns }) + : builder.apply({ upserts: nodes, timeline, changedTurns }) + if (definition !== undefined && builder.groupInput !== undefined) { + let context = this.groups.get(view.target) + const initial = context === undefined + if (context === undefined) { + context = { definition, state: definition.create(), store: new ConversationGroupStore() } + } + const input = builder.groupInput() + context.state = definition.update(context, input) + const change = definition.buildGroups(context) + if ((initial || input.kind === 'replace') + && (change === null || change.entries === undefined || change.groups.kind !== 'replace')) { + throw new Error(`conversation group target "${view.target}" requires complete grouping for replacement input`) + } + if (change !== null) { + context.store.prepareAndInstall(change, input.readNode) + this.pendingGroupStores.add(context.store) + } + this.groups.set(view.target, context) + } + view.snapshot = snapshot + } + + private publishViews(updated: readonly ViewState[]): boolean { + const changed = updated.length > 0 || this.pendingGroupStores.size > 0 + const stores = [...this.pendingGroupStores] + this.pendingGroupStores.clear() + for (const view of updated) view.builder?.publish?.() + for (const store of stores) store.publish() + this.locationIndex.publishData() + return changed } private buildTargetNodes( @@ -925,11 +992,26 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore } private resetViewBuilders(): void { + const definitions = this.viewDefinitions.entries() + const targets = new Set(definitions.map(definition => definition.target)) + for (const definition of this.groupDefinitions.entries()) { + if (!targets.has(definition.target)) { + throw new Error(`conversation group target "${definition.target}" is not registered`) + } + } + for (const [target, group] of this.groups) { + if (!targets.has(target) || this.groupDefinitions.forTarget(target) !== group.definition) { + group.store.clear() + this.pendingGroupStores.add(group.store) + this.groups.delete(target) + } + } this.views.clear() - for (const definition of this.viewDefinitions.entries()) { + for (const definition of definitions) { const view: ViewState = { target: definition.target, definition, + groupDefinition: this.groupDefinitions.forTarget(definition.target), isActive: definition.isActive === undefined ? undefined : snapshot => definition.isActive?.(snapshot) === true, diff --git a/packages/client/ui-conversation/src/client/conversation/assembly.ts b/packages/client/ui-conversation/src/client/conversation/assembly.ts index b305c9e817..fe4d49505c 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembly.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembly.ts @@ -23,6 +23,7 @@ import { ConversationNodeAssembler } from './assembler.ts' import { ConversationEventRegistry } from './event-registry.ts' import { HistoricalImageCache } from './historical-images.ts' import { ConversationViewRegistry } from './view-registry.ts' +import { ConversationGroupRegistry } from './group-registry.ts' /** Observable faces published for one Session's Conversation assembly. */ export interface ConversationBinding { @@ -178,6 +179,8 @@ export class UiConversation extends Service { readonly events: ConversationEventRegistry /** Registry of target View definitions. */ readonly views: ConversationViewRegistry + /** Business grouping rules over already materialized target Nodes. */ + readonly groups: ConversationGroupRegistry private readonly bindings = new WeakMapWithValues() private readonly images: HistoricalImageCache @@ -189,6 +192,7 @@ export class UiConversation extends Service { super(ctx, 'uiConversation') this.events = new ConversationEventRegistry(ctx) this.views = new ConversationViewRegistry(ctx) + this.groups = new ConversationGroupRegistry(ctx, this.views) this.images = new HistoricalImageCache(ctx, sessions) const rebuild = (): void => { for (const record of this.bindings.values) record.binding.rebuild() @@ -205,7 +209,9 @@ export class UiConversation extends Service { ctx.effect(() => { const disposeEvents = this.events.subscribe(scheduleRebuild) const disposeViews = this.views.subscribe(scheduleRebuild) + const disposeGroups = this.groups.subscribe(scheduleRebuild) return () => { + disposeGroups() disposeViews() disposeEvents() for (const record of [...this.bindings.values]) this.drop(record, true) @@ -229,7 +235,7 @@ export class UiConversation extends Service { if (current !== undefined) return current.binding const binding = new BoundConversation( owner.eventSource, - new ConversationNodeAssembler(this.events, this.views), + new ConversationNodeAssembler(this.events, this.views, this.groups), ) const record: BindingRecord = { source: owner, binding, disposeScope: () => {} } this.bindings.set(owner, record) diff --git a/packages/client/ui-conversation/src/client/conversation/group-registry.ts b/packages/client/ui-conversation/src/client/conversation/group-registry.ts new file mode 100644 index 0000000000..1e52ef8b96 --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/group-registry.ts @@ -0,0 +1,48 @@ +/** Target-indexed business grouping registrations with the existing effect lifecycle. */ +import type { Context } from '@deepseek-ai/cordis' +import type { ConversationViewNode } from '../contract/conversation.ts' +import type { + ConversationGroupData, ConversationGroupDefinition, +} from '../contract/groups.ts' +import { ConversationDefinitionRegistry } from './definition-registry.ts' +import type { ConversationViewRegistry } from './view-registry.ts' + +/** One Group Definition per target; registration never instantiates its Builder. */ +export class ConversationGroupRegistry extends ConversationDefinitionRegistry { + /** + * @param ctx - owning plugin context. + * @param views - registered target Builder definitions. + */ + constructor(ctx: Context, private readonly views: ConversationViewRegistry) { + super(ctx) + } + + /** + * Register grouping rules for an existing target. + * @param definition - business State and grouping output for the declared target data. + * @returns the effect-owned, idempotent registration disposer. + */ + register( + definition: ConversationGroupDefinition> + & { readonly target: Target }, + ): () => void { + if (!this.views.entries().some(view => view.target === definition.target)) { + throw new Error(`conversation group target "${definition.target}" is not registered`) + } + return this.registerDefinition( + definition.target, + definition, + `conversation group target "${definition.target}" is already registered`, + `uiConversation.groups.register(${JSON.stringify(definition.target)})`, + ) + } + + /** + * Find the grouping rules registered for one target. + * @param target - View target. + * @returns its grouping Definition, when registered. + */ + forTarget(target: string): ConversationGroupDefinition | undefined { + return this.definitions.get(target) + } +} diff --git a/packages/client/ui-conversation/src/client/conversation/group-store.ts b/packages/client/ui-conversation/src/client/conversation/group-store.ts new file mode 100644 index 0000000000..2a38f2a4df --- /dev/null +++ b/packages/client/ui-conversation/src/client/conversation/group-store.ts @@ -0,0 +1,226 @@ +/** Keyed group publication and incremental validation of rendering positions. */ +import { createSnapshotStore, type ObservableSnapshot, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import { assertNever } from '@deepseek-ai/dsh-util-values' +import type { ConversationViewNode } from '../contract/conversation.ts' +import type { + ConversationGroupedView, GroupKey, GroupSnapshot, GroupUpdate, NodeKey, NodeReference, RenderEntry, +} from '../contract/groups.ts' + +type Parts = Set + +interface GroupSource { + readonly observable: ObservableSnapshot | undefined> + readonly publication: SnapshotStore | undefined> +} + +interface GroupChanges { + readonly upserts: Map> + readonly removes: Set +} + +function sameNodeReference(left: NodeReference, right: NodeReference): boolean { + return left.key === right.key && left.groupPart === right.groupPart +} + +function sameEntry(left: RenderEntry, right: RenderEntry): boolean { + return left.kind === right.kind && left.key === right.key + && (left.kind === 'group' || (right.kind === 'node' && left.groupPart === right.groupPart)) +} + +function reuseReferences( + previous: readonly Value[], + next: readonly Value[], + equal: (left: Value, right: Value) => boolean, +): readonly Value[] { + return previous === next || (previous.length === next.length + && previous.every((value, index) => equal(value, next[index] as Value))) + ? previous + : next +} + +/** Validates a complete batch before installing its group and root-list changes. */ +export class ConversationGroupStore implements ConversationGroupedView { + private root: readonly RenderEntry[] = [] + private rootGroups = new Set() + private readonly groups = new Map>() + private readonly placements = new Map() + private readonly sources = new Map>() + private readonly dirty = new Set() + + /** @returns the identity-stable ordered root references. */ + get entries(): readonly RenderEntry[] { + return this.root + } + + groupSource(key: GroupKey): ObservableSnapshot | undefined> { + let source = this.sources.get(key) + if (source === undefined) { + const publication = createSnapshotStore(this.groups.get(key)) + source = { + publication, + observable: { + getSnapshot: () => this.groups.get(key), + subscribe: listener => publication.subscribe(listener), + }, + } + this.sources.set(key, source) + } + return source.observable + } + + /** + * Install one validated update without notifying readers. + * @param update - root replacement and complete or incremental group records. + * @param readNode - synchronous reader of the current target Nodes. + */ + prepareAndInstall( + update: GroupUpdate, + readNode: (key: NodeKey) => ConversationViewNode | undefined, + ): void { + const nextRoot = update.entries === undefined + ? this.root + : reuseReferences(this.root, update.entries, sameEntry) + const nextRootGroups = nextRoot === this.root ? this.rootGroups : this.collectRootGroups(nextRoot) + const { upserts, removes } = this.collectChanges(update) + const replaceReferences = update.groups.kind === 'replace' + const size = this.groups.size - removes.size + + [...upserts.keys()].filter(key => !this.groups.has(key)).length + if (size !== nextRootGroups.size) { + throw new Error('conversation group records and root references must correspond one-to-one') + } + for (const key of removes) { + if (nextRootGroups.has(key)) throw new Error(`conversation group "${key}" is still referenced`) + } + for (const key of upserts.keys()) { + if (!nextRootGroups.has(key)) throw new Error(`conversation group "${key}" has no root reference`) + } + if (nextRootGroups !== this.rootGroups) { + for (const key of nextRootGroups) { + if (!upserts.has(key) && !this.groups.has(key)) { + throw new Error(`conversation root references missing group "${key}"`) + } + } + } + + const affectedParts = new Map() + const partsOf = (key: NodeKey): Parts => { + let parts = affectedParts.get(key) + if (parts === undefined) { + parts = new Set(this.placements.get(key)) + affectedParts.set(key, parts) + } + return parts + } + const removeReference = (reference: NodeReference): void => { + partsOf(reference.key).delete(reference.groupPart) + } + const addReference = (reference: NodeReference): void => { + if (readNode(reference.key) === undefined) { + throw new Error(`conversation group references missing Node "${reference.key}"`) + } + const parts = partsOf(reference.key) + if (parts.has(reference.groupPart) + || (reference.groupPart === undefined && parts.size > 0) + || parts.has(undefined)) { + throw new Error(`conversation Node "${reference.key}" has overlapping rendering positions`) + } + parts.add(reference.groupPart) + } + + if (replaceReferences || nextRoot !== this.root) { + for (const entry of this.root) if (entry.kind === 'node') removeReference(entry) + } + for (const key of removes) { + for (const member of (this.groups.get(key) as GroupSnapshot).members) removeReference(member) + } + for (const [key, next] of upserts) { + const previous = this.groups.get(key) + if (previous !== undefined && (replaceReferences || previous.members !== next.members)) { + for (const member of previous.members) removeReference(member) + } + } + if (replaceReferences || nextRoot !== this.root) { + for (const entry of nextRoot) if (entry.kind === 'node') addReference(entry) + } + for (const [key, next] of upserts) { + if (replaceReferences || this.groups.get(key)?.members !== next.members) { + for (const member of next.members) addReference(member) + } + } + + for (const [key, parts] of affectedParts) { + if (parts.size === 0) this.placements.delete(key) + else this.placements.set(key, parts) + } + for (const key of removes) { + this.groups.delete(key) + this.dirty.add(key) + } + for (const [key, next] of upserts) { + if (this.groups.get(key) === next) continue + this.groups.set(key, next) + this.dirty.add(key) + } + this.root = nextRoot + this.rootGroups = nextRootGroups + } + + /** Publish changed group sources after all related target data has been installed. */ + publish(): void { + const keys = [...this.dirty] + this.dirty.clear() + for (const key of keys) this.sources.get(key)?.publication.set(this.groups.get(key)) + } + + /** Remove grouping without deleting its source Nodes; publication remains deferred. */ + clear(): void { + /* v8 ignore next -- an empty replacement never reads a Node reference. */ + this.prepareAndInstall({ entries: [], groups: { kind: 'replace', snapshots: [] } }, () => undefined) + } + + private collectRootGroups(entries: readonly RenderEntry[]): Set { + const keys = new Set() + for (const entry of entries) { + if (entry.kind !== 'group') continue + if (keys.has(entry.key)) throw new Error(`conversation group "${entry.key}" has duplicate root references`) + keys.add(entry.key) + } + return keys + } + + private collectChanges(update: GroupUpdate): GroupChanges { + const upserts = new Map>() + const removes = new Set() + const add = (snapshot: GroupSnapshot): void => { + if (upserts.has(snapshot.key)) throw new Error(`conversation group "${snapshot.key}" has duplicate upserts`) + const previous = this.groups.get(snapshot.key) + if (previous === undefined) { + upserts.set(snapshot.key, snapshot) + return + } + const members = reuseReferences(previous.members, snapshot.members, sameNodeReference) + upserts.set(snapshot.key, previous.data === snapshot.data && previous.members === members + ? previous + : { ...snapshot, members }) + } + switch (update.groups.kind) { + case 'replace': + for (const snapshot of update.groups.snapshots) add(snapshot) + for (const key of this.groups.keys()) if (!upserts.has(key)) removes.add(key) + break + case 'apply': + for (const snapshot of update.groups.upserts) add(snapshot) + for (const key of update.groups.removes) { + if (removes.has(key)) throw new Error(`conversation group "${key}" has duplicate removals`) + if (!this.groups.has(key)) throw new Error(`conversation group "${key}" cannot be removed because it is absent`) + if (upserts.has(key)) throw new Error(`conversation group "${key}" cannot be upserted and removed together`) + removes.add(key) + } + break + /* v8 ignore next 2 -- closed update union; TypeScript rejects other operation tags. */ + default: + assertNever(update.groups) + } + return { upserts, removes } + } +} diff --git a/packages/client/ui-conversation/src/client/conversation/location-index.ts b/packages/client/ui-conversation/src/client/conversation/location-index.ts index f7bac8eb1c..2f6eb70d9b 100644 --- a/packages/client/ui-conversation/src/client/conversation/location-index.ts +++ b/packages/client/ui-conversation/src/client/conversation/location-index.ts @@ -183,6 +183,7 @@ export class ConversationLocationIndex { private readonly turnDataStores = new Map() private readonly stepDataStores = new Map() private readonly dirtyDataStores = new Set() + private readonly changedTurns = new Set() private currentTurn: number | undefined private currentStep: number | undefined @@ -194,6 +195,16 @@ export class ConversationLocationIndex { return this.timeline } + /** + * Drain the Turn changes accumulated for one assembly flush. + * @returns Turn identities changed since the preceding drain. + */ + takeChangedTurns(): readonly number[] { + const turns = [...this.changedTurns] + this.changedTurns.clear() + return turns + } + /** * Replace all Definition-owned Location values while preserving reader identities. * @param entries - complete current set of Definition-owned Location values. @@ -386,6 +397,9 @@ export class ConversationLocationIndex { } } } + for (const turn of new Set([...previousTurns.keys(), ...nextTurns.keys()])) { + if (previousTurns.get(turn) !== nextTurns.get(turn)) this.changedTurns.add(turn) + } this.timeline = sameMap && turnOrder === this.timeline.turnOrder ? this.timeline : { turnOrder, turns: nextTurns } @@ -480,6 +494,7 @@ export class ConversationLocationIndex { ? [...this.timeline.turnOrder, turnNumber] : this.timeline.turnOrder this.timeline = { turnOrder, turns } + this.changedTurns.add(turnNumber) const changed = new Set() for (const seq of this.seqsByTurn.get(turnNumber) ?? []) { @@ -572,19 +587,22 @@ export class ConversationLocationIndex { } private mutableTurnData(turn: number): MutableLocationDataStore { - const current = this.turnDataStores.get(turn) ?? this.createDataStore() + const current = this.turnDataStores.get(turn) ?? this.createDataStore(turn) this.turnDataStores.set(turn, current) return current } private mutableStepData(key: string): MutableLocationDataStore { - const current = this.stepDataStores.get(key) ?? this.createDataStore() + const current = this.stepDataStores.get(key) ?? this.createDataStore(Number(key.slice(0, key.indexOf(':')))) this.stepDataStores.set(key, current) return current } - private createDataStore(): MutableLocationDataStore { - return new MutableLocationDataStore(store => this.dirtyDataStores.add(store)) + private createDataStore(turn: number): MutableLocationDataStore { + return new MutableLocationDataStore((store) => { + this.dirtyDataStores.add(store) + this.changedTurns.add(turn) + }) } private storeFor(data: ConversationLocationData): MutableLocationDataStore { diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 73ddc6d7d3..998169610e 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -3,6 +3,13 @@ export { apply, Config, inject } from './apply.ts' export type { Config as ConversationConfig } from './apply.ts' export { UiConversation } from './conversation/assembly.ts' export type { ConversationBinding } from './conversation/assembly.ts' +export type { ConversationGroupRegistry } from './conversation/group-registry.ts' +export type { + ConversationGroupContext, ConversationGroupData, ConversationGroupDataMap, + ConversationGroupDefinition, ConversationGroupedView, ConversationGroupInput, + GroupKey, GroupReference, GroupSnapshot, GroupUpdate, NodeChange, NodeKey, + NodeReference, RenderEntry, +} from './contract/groups.ts' export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' export type { IConversation } from './service.ts' export type { @@ -40,7 +47,7 @@ export type { ConversationStoreState, ConversationViewRequest, ViewTab } from '. export { ConversationNodeAssembler } from './conversation/assembler.ts' export type { - ConversationEventDefinitions, ConversationViewDefinitions, + ConversationEventDefinitions, ConversationGroupDefinitions, ConversationViewDefinitions, } from './conversation/assembler.ts' export { ConversationDefinitionRegistry } from './conversation/definition-registry.ts' export { ConversationEventRegistry } from './conversation/event-registry.ts' diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index 1b12d7f1a0..7b6441f217 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -177,6 +177,8 @@ describe('ConversationNodeAssembler', () => { const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 }) const stepStart = at(SessionSeq(2), 'step/start', { turn: 1, step: 1 }) index.rebuild([input(turnStart), input(stepStart)]) + expect(index.takeChangedTurns()).toEqual([1]) + expect(index.takeChangedTurns()).toEqual([]) const location = index.locationOf(stepStart) if (location.kind !== 'step') throw new Error('scope probe requires a Step Location') const source = location.step.data.source('scope-probe') @@ -192,6 +194,7 @@ describe('ConversationNodeAssembler', () => { }])).toBe(true) expect(source.getSnapshot()).toBe(initial) expect(listener).not.toHaveBeenCalled() + expect(index.takeChangedTurns()).toEqual([1]) index.publishData() expect(listener).toHaveBeenCalledOnce() @@ -204,6 +207,7 @@ describe('ConversationNodeAssembler', () => { }]) index.publishData() expect(listener).not.toHaveBeenCalled() + expect(index.takeChangedTurns()).toEqual([1]) const changed = { value: 2 } index.replaceData([{ diff --git a/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts b/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts new file mode 100644 index 0000000000..2a165bfb93 --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from 'vitest' +import { brandString } from '@deepseek-ai/dsh-brand' +import type { ConversationViewNode } from '../src/client/contract/conversation.ts' +import type { GroupKey, GroupSnapshot, GroupUpdate, NodeKey, NodeReference, RenderEntry } from '../src/client/contract/groups.ts' +import { ConversationGroupStore } from '../src/client/conversation/group-store.ts' + +const key = (value: string): GroupKey => brandString(value) +const node = (value: string, groupPart?: string): NodeReference => ({ + kind: 'node', key: brandString(value), ...groupPart === undefined ? {} : { groupPart }, +}) +const reference = (value: string): RenderEntry => ({ kind: 'group', key: key(value) }) +const readNode = (id: NodeKey): ConversationViewNode | undefined => id === 'missing' + ? undefined + : { key: id, kind: 'test', id, target: 'test', data: id } +const group = (id: string, members: readonly NodeReference[], data = 0): GroupSnapshot => ({ key: key(id), members, data }) + +function replace(store: ConversationGroupStore, entries: readonly RenderEntry[], groups: readonly GroupSnapshot[]) { + store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: groups } }, readNode) +} + +describe('ConversationGroupStore', () => { + it('publishes one changed group without changing root or member identities', () => { + const store = new ConversationGroupStore() + const a = group('a', [node('a')]) + const b = group('b', [node('b')]) + replace(store, [reference('a'), node('reply'), reference('b')], [a, b]) + store.publish() + const root = store.entries + const source = store.groupSource(key('a')) + const other = store.groupSource(key('b')) + expect(store.groupSource(key('a'))).toBe(source) + const changed = vi.fn() + const otherChanged = vi.fn() + const unsubscribe = source.subscribe(changed) + const unsubscribeOther = other.subscribe(otherChanged) + const reads = vi.fn(readNode) + store.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...a, data: 1 }], removes: [] } }, reads) + expect(source.getSnapshot()).toEqual({ ...a, data: 1 }) + expect(source.getSnapshot()?.members).toBe(a.members) + expect(other.getSnapshot()).toBe(b) + expect(store.entries).toBe(root) + expect(reads).not.toHaveBeenCalled() + expect(changed).not.toHaveBeenCalled() + store.publish() + expect(changed).toHaveBeenCalledOnce() + expect(otherChanged).not.toHaveBeenCalled() + unsubscribe() + unsubscribeOther() + store.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...a, data: 2 }], removes: [] } }, readNode) + store.publish() + expect(changed).toHaveBeenCalledOnce() + }) + + it('reuses equivalent snapshots and accepts parts in separate positions', () => { + const store = new ConversationGroupStore() + const reasoning = node('assistant', 'reasoning') + const reply = node('assistant', 'response') + const original = group('a', [reasoning]) + replace(store, [reference('a'), reply], [original]) + const root = store.entries + const source = store.groupSource(key('a')) + const changed = vi.fn() + source.subscribe(changed) + store.publish() + replace(store, [reference('a'), node('assistant', 'response')], [group('a', [node('assistant', 'reasoning')])]) + store.publish() + expect(store.entries).toBe(root) + expect(source.getSnapshot()).toBe(original) + expect(changed).not.toHaveBeenCalled() + store.prepareAndInstall({ groups: { kind: 'apply', upserts: [group('a', [node('assistant', 'reasoning')], 1)], removes: [] } }, readNode) + expect(source.getSnapshot()?.members).toBe(original.members) + store.publish() + expect(changed).toHaveBeenCalledOnce() + }) + + it('moves references atomically and preserves surviving group sources', () => { + const store = new ConversationGroupStore() + const a = node('a') + const b = node('b') + replace(store, [reference('old')], [group('old', [a, b])]) + store.publish() + const oldSource = store.groupSource(key('old')) + const nextSource = store.groupSource(key('next')) + const oldChanged = vi.fn() + oldSource.subscribe(oldChanged) + store.prepareAndInstall({ + entries: [a, reference('next')], + groups: { kind: 'apply', removes: [key('old')], upserts: [group('next', [b])] }, + }, readNode) + expect(oldSource.getSnapshot()).toBeUndefined() + expect(nextSource.getSnapshot()?.members).toEqual([b]) + expect(oldChanged).not.toHaveBeenCalled() + store.publish() + expect(oldChanged).toHaveBeenCalledOnce() + const root = store.entries + store.prepareAndInstall({ groups: { kind: 'apply', removes: [], upserts: [group('next', [node('c')])] } }, readNode) + expect(store.entries).toBe(root) + expect(store.groupSource(key('next'))).toBe(nextSource) + store.prepareAndInstall({ entries: [node('c'), a], groups: { kind: 'apply', removes: [key('next')], upserts: [] } }, readNode) + store.publish() + expect(store.entries).toEqual([node('c'), a]) + store.clear() + store.publish() + expect(store.entries).toEqual([]) + expect(nextSource.getSnapshot()).toBeUndefined() + store.clear() + store.publish() + }) + + it('keeps omitted entries and allows an empty complete replacement', () => { + const store = new ConversationGroupStore() + replace(store, [node('a'), node('b')], []) + const root = store.entries + store.prepareAndInstall({ groups: { kind: 'apply', upserts: [], removes: [] } }, readNode) + expect(store.entries).toBe(root) + store.prepareAndInstall({ entries: [node('b'), node('a')], groups: { kind: 'apply', upserts: [], removes: [] } }, readNode) + expect(store.entries).toEqual([node('b'), node('a')]) + replace(store, [], []) + expect(store.entries).toEqual([]) + }) + + it('validates references again on complete replacement, even when arrays are reused', () => { + const store = new ConversationGroupStore() + const initial = group('a', [node('a')]) + const entries = [reference('a'), node('b')] + replace(store, entries, [initial]) + expect(() =>{ store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: [initial] } }, () => undefined) }) + .toThrow('missing Node') + expect(store.entries).toBe(entries) + expect(store.groupSource(key('a')).getSnapshot()).toBe(initial) + }) + + it.each([ + [node('a'), node('a')], + [node('a', 'reasoning'), node('a', 'reasoning')], + [node('a'), node('a', 'reasoning')], + [node('a', 'reasoning'), node('a')], + ])('rejects overlapping Node positions without changing installed data: %j', (first, second) => { + const store = new ConversationGroupStore() + replace(store, [node('before')], []) + const root = store.entries + expect(() =>{ replace(store, [first, reference('a')], [group('a', [second])]) }).toThrow('overlapping') + expect(store.entries).toBe(root) + replace(store, [reference('a')], [group('a', [first])]) + expect(store.groupSource(key('a')).getSnapshot()?.members).toEqual([first]) + }) + + it.each<{ name: string; update: GroupUpdate; error: string }>([ + { name: 'duplicate group roots', update: { entries: [reference('a'), reference('a')], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'duplicate root' }, + { name: 'unreferenced group', update: { entries: [reference('a'), reference('c')], groups: { kind: 'apply', upserts: [group('b', [])], removes: [] } }, error: 'no root reference' }, + { name: 'missing group', update: { entries: [reference('missing')], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'missing group' }, + { name: 'removed referenced group', update: { groups: { kind: 'apply', upserts: [group('b', [])], removes: [key('a')] } }, error: 'still referenced' }, + { name: 'record count mismatch', update: { entries: [], groups: { kind: 'apply', upserts: [], removes: [] } }, error: 'one-to-one' }, + { name: 'duplicate upserts', update: { groups: { kind: 'apply', upserts: [group('a', []), group('a', [])], removes: [] } }, error: 'duplicate upserts' }, + { name: 'duplicate removals', update: { entries: [], groups: { kind: 'apply', upserts: [], removes: [key('a'), key('a')] } }, error: 'duplicate removals' }, + { name: 'absent removal', update: { groups: { kind: 'apply', upserts: [], removes: [key('missing')] } }, error: 'absent' }, + { name: 'upsert and remove', update: { groups: { kind: 'apply', upserts: [group('a', [])], removes: [key('a')] } }, error: 'upserted and removed' }, + { name: 'missing Node', update: { groups: { kind: 'apply', upserts: [group('a', [node('missing')])], removes: [] } }, error: 'missing Node' }, + ])('rejects $name atomically', ({ update, error }) => { + const store = new ConversationGroupStore() + const initial = group('a', [node('a')]) + replace(store, [reference('a')], [initial]) + store.publish() + const root = store.entries + const source = store.groupSource(key('a')) + expect(() =>{ store.prepareAndInstall(update, readNode) }).toThrow(error) + expect(store.entries).toBe(root) + expect(source.getSnapshot()).toBe(initial) + }) +}) diff --git a/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts new file mode 100644 index 0000000000..b10e8a420f --- /dev/null +++ b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts @@ -0,0 +1,258 @@ +import { Context } from '@deepseek-ai/cordis' +import { brandString } from '@deepseek-ai/dsh-brand' +import { SessionSeq, type SessionEvent } from '@deepseek-ai/dsh-session/types' +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import type { + ConversationTimelineSnapshot, ConversationViewBuilder, ConversationViewDefinition, ConversationViewNode, +} from '../src/client/contract/conversation.ts' +import type { + ConversationGroupDefinition, ConversationGroupInput, GroupKey, NodeKey, +} from '../src/client/contract/groups.ts' +import { ConversationNodeAssembler } from '../src/client/conversation/assembler.ts' +import { ConversationGroupRegistry } from '../src/client/conversation/group-registry.ts' +import { ConversationViewRegistry } from '../src/client/conversation/view-registry.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationGroupDataMap { + 'group-test': number + } +} + +const groupKey = brandString('group') +const noEvents = { entries: () => [], fallbackEntry: () => undefined } + +class GroupTestBuilder implements ConversationViewBuilder> { + readonly empty = new Map() + readonly nodes = new Map() + readonly publish = vi.fn() + private input: ConversationGroupInput = { + kind: 'replace', order: [], readNode: key => this.nodes.get(key), timeline: { turnOrder: [], turns: new Map() }, + } + + replace({ nodes, timeline }: { nodes: readonly ConversationViewNode[]; timeline: ConversationTimelineSnapshot }) { + this.nodes.clear() + for (const node of nodes) this.nodes.set(node.key, node) + this.input = { kind: 'replace', order: [...this.nodes.keys()] as NodeKey[], readNode: key => this.nodes.get(key), timeline } + return this.nodes + } + + apply({ upserts, timeline, changedTurns = [] }: { + upserts: readonly ConversationViewNode[] + timeline: ConversationTimelineSnapshot + changedTurns?: readonly number[] + }) { + const changes = upserts.map(current => ({ previous: this.nodes.get(current.key), current })) + for (const node of upserts) this.nodes.set(node.key, node) + this.input = { kind: 'apply', changes, changedTurns, order: [...this.nodes.keys()] as NodeKey[], readNode: key => this.nodes.get(key), timeline } + return this.nodes + } + + groupInput() { return this.input } +} + +function grouping(seen: ConversationGroupInput[] = []) { + return { + kind: 'test-group', + target: 'group-test' as const, + create: (): ConversationGroupInput | null => null, + update: (_context, input) => { seen.push(input); return input }, + buildGroups: ({ state }) => state === null ? null : { + entries: [{ kind: 'group', key: groupKey }], + groups: { + kind: 'replace', + snapshots: [{ + key: groupKey, + data: state.timeline.turns.get(1)?.status === 'closed' ? 1 : 0, + members: state.order.map(key => ({ kind: 'node', key })), + }], + }, + }, + } satisfies ConversationGroupDefinition | null, number> +} + +function start(seq: number, turn = 1): SessionEvent<'turn/start'> { + return { seq: SessionSeq(seq), time: seq, type: 'turn/start', data: { turn } } +} + +function setup(definition: ConversationGroupDefinition = grouping()) { + const builder = new GroupTestBuilder() + const create = vi.fn(() => builder) + let groupDefinitions = [definition] + const views = [{ target: 'group-test', create }] + const groups = { + entries: () => groupDefinitions, + forTarget: (target: string) => groupDefinitions.find(candidate => candidate.target === target), + } + const assembler = new ConversationNodeAssembler(noEvents, { entries: () => views }, groups) + return { + assembler, builder, create, views, + removeGroups: () => { groupDefinitions = [] }, + replaceGroup: (next: ConversationGroupDefinition) => { groupDefinitions = [next] }, + } +} + +describe('group Definition dispatch', () => { + it('creates grouping on first activation without eagerly constructing a Builder', () => { + const seen: ConversationGroupInput[] = [] + const { assembler, builder, create } = setup(grouping(seen)) + assembler.replaceWindow([{ type: 'event', event: start(1) }], false) + expect(assembler.flush()).toBe(false) + expect(create).not.toHaveBeenCalled() + expect(assembler.grouped('group-test')).toBeUndefined() + builder.publish.mockImplementation(() => { + expect(assembler.snapshot('group-test')).toBe(builder.nodes) + expect(assembler.grouped('group-test')?.groupSource(groupKey).getSnapshot()?.data).toBe(0) + }) + expect(assembler.activateTarget('group-test')).toBe(true) + expect(create).toHaveBeenCalledOnce() + expect(seen.map(input => input.kind)).toEqual(['replace']) + expect(builder.publish).toHaveBeenCalledOnce() + expect(assembler.activateTarget('group-test')).toBe(false) + expect(seen).toHaveLength(1) + }) + + it('delivers lifecycle changes without Node upserts and installs groups before publication', () => { + const seen: ConversationGroupInput[] = [] + const { assembler, builder } = setup(grouping(seen)) + assembler.replaceWindow([{ type: 'event', event: start(1) }], false) + assembler.activateTarget('group-test') + const grouped = assembler.grouped('group-test')! + const entries = grouped.entries + const source = grouped.groupSource(groupKey) + const changed = vi.fn() + source.subscribe(changed) + const end: SessionEvent<'turn/end'> = { type: 'turn/end', seq: SessionSeq(2), time: 2, data: { turn: 1, reason: { kind: 'completed' } } } + assembler.append({ type: 'event', event: end }) + builder.publish.mockImplementation(() => { expect(source.getSnapshot()?.data).toBe(1) }) + assembler.flush() + const input = seen.at(-1)! + expect(input.kind).toBe('apply') + if (input.kind !== 'apply') throw new Error('expected apply') + expect(input.changes).toEqual([]) + expect(input.changedTurns).toEqual([1]) + expect(grouped.entries).toBe(entries) + expect(changed).toHaveBeenCalledOnce() + expect(assembler.flush()).toBe(false) + }) + + it('rebuilds through the same path and invalidates sources when grouping is removed', () => { + const seen: ConversationGroupInput[] = [] + const { assembler, removeGroups } = setup(grouping(seen)) + assembler.activateTarget('group-test') + const source = assembler.grouped('group-test')!.groupSource(groupKey) + const changed = vi.fn() + source.subscribe(changed) + assembler.rebuildRegistry() + assembler.flush() + expect(seen.map(input => input.kind)).toEqual(['replace', 'replace']) + expect(assembler.grouped('group-test')!.groupSource(groupKey)).toBe(source) + removeGroups() + assembler.rebuildRegistry() + assembler.flush() + expect(source.getSnapshot()).toBeUndefined() + expect(assembler.grouped('group-test')).toBeUndefined() + expect(changed).toHaveBeenCalledOnce() + }) + + it('does not ask ungrouped Builders for grouping input', () => { + const builder = new GroupTestBuilder() + const input = vi.spyOn(builder, 'groupInput') + const assembler = new ConversationNodeAssembler(noEvents, { entries: () => [{ target: 'group-test', create: () => builder }] }) + assembler.activateTarget('group-test') + expect(input).not.toHaveBeenCalled() + }) + + it('keeps the installed group context until the registry rebuild replaces it', () => { + const first = grouping() + const firstUpdate = vi.spyOn(first, 'update') + const next = grouping() + const nextCreate = vi.spyOn(next, 'create') + const { assembler, replaceGroup } = setup(first) + assembler.activateTarget('group-test') + const source = assembler.grouped('group-test')!.groupSource(groupKey) + const changed = vi.fn() + source.subscribe(changed) + replaceGroup(next) + assembler.append({ type: 'event', event: start(1) }) + assembler.flush() + expect(firstUpdate).toHaveBeenCalledTimes(2) + expect(nextCreate).not.toHaveBeenCalled() + assembler.rebuildRegistry() + assembler.flush() + expect(nextCreate).toHaveBeenCalledOnce() + expect(source.getSnapshot()).toBeUndefined() + expect(changed).toHaveBeenCalledOnce() + expect(assembler.grouped('group-test')!.groupSource(groupKey)).not.toBe(source) + }) + + it('keeps published grouping when a later buildGroups returns null', () => { + const definition = grouping() + const build = vi.spyOn(definition, 'buildGroups') + const { assembler } = setup(definition) + assembler.activateTarget('group-test') + const grouped = assembler.grouped('group-test')! + const root = grouped.entries + const source = grouped.groupSource(groupKey) + const value = source.getSnapshot() + const changed = vi.fn() + source.subscribe(changed) + build.mockReturnValueOnce(null) + assembler.append({ type: 'event', event: start(1) }) + assembler.flush() + expect(grouped.entries).toBe(root) + expect(source.getSnapshot()).toBe(value) + expect(changed).not.toHaveBeenCalled() + }) + + it('rejects a missing Builder input method only after target activation', () => { + const create = vi.fn(() => ({ empty: null, replace: () => null, apply: () => null })) + const definition = grouping() + const assembler = new ConversationNodeAssembler(noEvents, { entries: () => [{ target: 'group-test', create }] }, { + entries: () => [definition], forTarget: () => definition, + }) + expect(create).not.toHaveBeenCalled() + expect(() => assembler.activateTarget('group-test')).toThrow('requires builder.groupInput()') + }) + + it('rejects missing targets and incomplete initial output', () => { + const definition = grouping() + expect(() => new ConversationNodeAssembler(noEvents, { entries: () => [] }, { + entries: () => [definition], forTarget: () => definition, + })).toThrow('is not registered') + const missing = setup({ ...definition, buildGroups: () => null }) + expect(() => missing.assembler.activateTarget('group-test')).toThrow('complete grouping for replacement input') + const retained = grouping() + const build = vi.spyOn(retained, 'buildGroups') + const { assembler } = setup(retained) + assembler.activateTarget('group-test') + build.mockReturnValueOnce(null) + assembler.replaceWindow([], false) + expect(() => assembler.flush()).toThrow('complete grouping for replacement input') + }) + + it('registers grouping without creating its Builder and disposes it with its effect', async () => { + const ctx = new Context() + onTestFinished(async () => { await ctx.fiber.dispose() }) + const views = new ConversationViewRegistry(ctx) + const groups = new ConversationGroupRegistry(ctx, views) + const definition = grouping() + expect(() => groups.register(definition)).toThrow('is not registered') + const create = vi.fn(() => new GroupTestBuilder()) + views.register({ target: 'group-test', create } satisfies ConversationViewDefinition) + const changed = vi.fn() + const unsubscribe = groups.subscribe(changed) + const dispose = groups.register(definition) + expect(create).not.toHaveBeenCalled() + expect(groups.forTarget('group-test')).toBe(definition) + expect(() => groups.register(definition)).toThrow('already registered') + dispose() + dispose() + expect(groups.forTarget('group-test')).toBeUndefined() + expect(changed).toHaveBeenCalledTimes(2) + unsubscribe() + groups.register(definition) + await ctx.fiber.dispose() + expect(groups.entries()).toEqual([]) + expect(changed).toHaveBeenCalledTimes(2) + }) +}) diff --git a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx index fff2d81a6b..c645d276d8 100644 --- a/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/plan-review-panel.client.spec.tsx @@ -64,7 +64,7 @@ const workspaceState = { error: null, } const conversationState: ConversationState = { - views: { get: () => undefined }, + views: { get: () => undefined, grouped: () => undefined }, activeTargets: new Set(), } const emptyKeys: readonly string[] = [] diff --git a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx index ac9fe34d22..ef55a45abb 100644 --- a/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx +++ b/packages/client/ui-user-questions/tests/user-questions-composer.client.spec.tsx @@ -62,7 +62,7 @@ const workspaceState = { error: null, } const conversationState: ConversationState = { - views: { get: () => undefined }, + views: { get: () => undefined, grouped: () => undefined }, activeTargets: new Set(), } const emptyKeys: readonly string[] = [] From 1b3b56c8f14cb14807b4ba399a05c5dccbff04ff Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 04:17:14 +0800 Subject: [PATCH 12/20] feat(client): render stable conversation group references --- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 8 ++ packages/client/ui-chat/README.zh.md | 8 ++ packages/client/ui-chat/package.json | 1 + packages/client/ui-chat/src/client/apply.ts | 3 + .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 29 ++++ .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 14 +- .../ui-chat/src/client/chat/ChatView.tsx | 33 +++-- .../ui-chat/src/client/chat/render-entry.ts | 16 +++ .../ui-chat/src/client/contract/slots.ts | 5 + .../chat-snapshot-builder.ts | 37 ++++- .../tests/chat-node-source.client.spec.ts | 34 +++++ .../ui-chat/tests/chat-view.client.spec.tsx | 132 +++++++++++++++++- packages/client/ui-chat/tsconfig.json | 3 + .../src/client/slot-catalog.ts | 12 +- pnpm-lock.yaml | 3 + 16 files changed, 316 insertions(+), 26 deletions(-) create mode 100644 packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx create mode 100644 packages/client/ui-chat/src/client/chat/render-entry.ts diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 1eefd3ba94..5589484177 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 2beb4dfadddec0c834c3ef6f4b56826be120baa6 -README.zh.md: 5d643f4e90b77d2b79ab1bb1c003b399c202e004 +README.md: 64ca5bb9d6da1fa6ea34b5022d2f045c0a816d5d +README.zh.md: 17885c3cf954630e1210c377d621b6612ce6735d diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 2beb4dfadd..64ca5bb9d6 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -20,6 +20,7 @@ File-mention providers receive the viewed Session ID with the closing-turn owner - [Turn token usage](#turn-token-usage) - [Completed-turn footer](#completed-turn-footer) - [Turn Process Folding](#turn-process-folding) +- [Grouped rendering](#grouped-rendering) - [Scroll ownership](#scroll-ownership) - [Model Experience](#model-experience) - [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) @@ -85,6 +86,13 @@ Stable Chat Node Seats keep every renderer mounted, hidden members add no flow s ----- + +## Grouped rendering + +Chat can render optional Conversation Group Definition output as a mixed `node`/`group` root list. A group seat subscribes only to its member array; each member retains the existing keyed Node source and renderer. `groupPart` reaches that renderer as a business-owned part selector, with distinct DOM anchors for different parts. Presentation modes do not select the root branch or alter member parents. No process Group Definition is registered by this package yet; the default transcript remains ungrouped. + +----- + ## Scroll ownership diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 5d643f4e90..17885c3cf9 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -20,6 +20,7 @@ kind: "package-reference" - [轮次 token 用量](#turn-token-usage) - [已完成轮次的页脚](#completed-turn-footer) - [轮次过程折叠](#turn-process-folding) +- [分组渲染](#grouped-rendering) - [滚动归属](#scroll-ownership) - [模型体验](#model-experience) - [已知限制与暂缓事项](#known-limitations-and-deferred-work) @@ -85,6 +86,13 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行和 `perm ----- + +## 分组渲染 + +Chat 可以把可选的 Conversation Group Definition 输出渲染为混合 `node`/`group` 根列表。组容器只订阅成员数组,每个成员保留既有的按键 Node 来源与渲染器。`groupPart` 作为业务拥有的部分选择器传给渲染器,不同部分具有不同 DOM 锚点。展示模式不选择根分支,也不改变成员父级。本包尚未注册过程 Group Definition,默认文本记录仍未分组。 + +----- + ## 滚动归属 diff --git a/packages/client/ui-chat/package.json b/packages/client/ui-chat/package.json index f966be3c21..28fa4da05d 100644 --- a/packages/client/ui-chat/package.json +++ b/packages/client/ui-chat/package.json @@ -86,6 +86,7 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-util-workspace-path": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^", "@tanstack/react-virtual": "^3.14.9", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", diff --git a/packages/client/ui-chat/src/client/apply.ts b/packages/client/ui-chat/src/client/apply.ts index 70390ec889..da926ba05a 100644 --- a/packages/client/ui-chat/src/client/apply.ts +++ b/packages/client/ui-chat/src/client/apply.ts @@ -3,6 +3,7 @@ import type { Context } from '@deepseek-ai/cordis' import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type { SessionBinding } from '@deepseek-ai/dsh-api-session-controller/client' +import type { GroupKey } from '@deepseek-ai/dsh-client-ui-conversation/client' import { createSnapshotStore, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type {} from '@deepseek-ai/dsh-client-ui-sidebar-right/client' @@ -156,11 +157,13 @@ export function apply(ctx: Context): void { if (binding === undefined) throw new Error(`ui-chat: unknown session "${sessionId}"`) const session = binding.session const chat = chatSource(binding) + const conversation = ctx.uiConversation.binding(binding) return { hooks: { presentation }, keyedHooks: { chatNode: key => chat.getSnapshot().nodes.source(key), chatNodeProcess: key => chat.getSnapshot().nodes.processSource(key), + chatGroup: key => conversation.snapshot.getSnapshot().views.grouped('chat')?.groupSource(key as GroupKey), }, fileMentions: (owner: TurnTailOwnerProps) => ctx.get('chatFileMentions')?.forClosing(owner, sessionId), // Files open in the right Sidebar, not in a desktop application: the diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx new file mode 100644 index 0000000000..a4f4c1bf1d --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -0,0 +1,29 @@ +/** Stable parent for one group's Node references, independent of display mode. */ +import { memo, type ComponentProps } from 'react' +import type { GroupKey } from '@deepseek-ai/dsh-client-ui-conversation/client' +import type { ChatViewSlotProps } from '../contract/slots.ts' +import { ChatNodeSeat } from './ChatNodeSeat.tsx' +import { chatRenderKey } from './render-entry.ts' + +type ChatGroupSeatProps = Omit, 'nodeKey' | 'groupPart'> & { + readonly groupKey: GroupKey + readonly useChatGroup: ChatViewSlotProps['useChatGroup'] +} + +/** Render the group's members without moving them between React parents on mode changes. */ +export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGroup, ...props }: ChatGroupSeatProps) { + const members = useChatGroup(groupKey, group => group?.members) + if (members === undefined) return null + return ( +
+ {members.map(member => ( + + ))} +
+ ) +}) diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 3ed6f7b8d8..081a1d768d 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -3,6 +3,7 @@ import { JsonBlock } from '@deepseek-ai/dsh-client-ui-primitives' import type { ConversationLocationDataStore, ConversationTurnDataMap } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatNodeOwnerProps, ChatViewSlotProps, UsePresentation } from '../contract/slots.ts' import type { ChatNode } from '../contract/chat-nodes.ts' +import type { ChatNodeStore } from '../contract/snapshot.ts' import { TURN_PROCESS_INDEPENDENT_KINDS } from '../contract/turn-process.ts' import { storedTurnProcessEntry } from '../stores.ts' import { useSearchableHidden } from './searchable-hidden.ts' @@ -10,6 +11,8 @@ import css from './ChatView.module.css' interface ChatNodeSeatProps extends ChatNodeOwnerProps { readonly nodeKey: string + /** A replaced Builder must rebind keyed hooks even when references and keys survive. */ + readonly nodeStore: ChatNodeStore readonly useChatNode: ChatViewSlotProps['useChatNode'] readonly useChatNodeProcess: ChatViewSlotProps['useChatNodeProcess'] readonly usePresentation: UsePresentation @@ -39,7 +42,7 @@ function turnOf(node: ChatNode | undefined): number | undefined { * only seats whose visibility actually changes. */ export const ChatNodeSeat = memo(function ChatNodeSeat({ - nodeKey, useChatNode, useChatNodeProcess, usePresentation, + nodeKey, groupPart, useChatNode, useChatNodeProcess, usePresentation, cwd, openFile, openSkill, inspectCall, forkAt, loadImage, renderMessageImages, fileMentions, useStore, actions, renderSlot, t, }: ChatNodeSeatProps) { @@ -108,6 +111,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ const owner = useMemo(() => node === undefined ? null : { + ...groupPart === undefined ? {} : { groupPart }, cwd, openFile, openSkill, @@ -118,7 +122,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ fileMentions, turnProcess, }, [ - node, cwd, openFile, openSkill, inspectCall, forkAt, + node, groupPart, cwd, openFile, openSkill, inspectCall, forkAt, loadImage, renderMessageImages, fileMentions, turnProcess, ]) if (routedNode === undefined || owner === null) return null @@ -127,12 +131,14 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ // keyed-slot entry passed alongside that same Node. TypeScript does not // distribute an object containing a union into a union of objects itself. const routedOwner = { ...owner, node: routedNode } as RoutedChatNodeOwner + const flowKey = groupPart === undefined ? routedNode.key : JSON.stringify([routedNode.key, groupPart]) return (
, 'nodeKey'> & { - readonly order: readonly string[] + readonly entries: readonly RenderEntry[] + readonly useChatGroup: ChatViewSlotProps['useChatGroup'] } -const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNodeListProps) { - return order.map(nodeKey => ( - - )) +const ChatNodeList = memo(function ChatNodeList({ entries, useChatGroup, ...seatProps }: ChatNodeListProps) { + return entries.map((entry) => { + switch (entry.kind) { + case 'node': + return + case 'group': + return + default: + return assertNever(entry) + } + }) }) /** @@ -111,11 +123,14 @@ const ChatNodeList = memo(function ChatNodeList({ order, ...seatProps }: ChatNod * ordered business Node crosses the keyed renderer seat. */ export function ChatView({ - useSession, useChat, useChatNode, useChatNodeProcess, useSessions, useStore, actions, renderSlot, + useSession, useChat, useChatNode, useChatNodeProcess, useChatGroup, useConversation, useSessions, useStore, actions, renderSlot, sessionId, openFile, openSkill, openExternalLink, loadOlder, loadThrough, loadImage, inspectCall, chatScroll, forkAt, fileMentions, usePresentation, useProjection, t, }: ChatViewSlotProps) { const order = useChat(s => s.order) + const groupedEntries = useConversation(snapshot => snapshot.views.grouped('chat')?.entries) + const entries = useMemo(() => groupedEntries + ?? order.map(key => ({ kind: 'node', key: key as NodeKey })), [groupedEntries, order]) const nodeStore = useChat(s => s.nodes) // The rail's items are accumulated in the Chat snapshot, so this selector is // both the data and its change signal: the array identity moves only when a @@ -233,7 +248,9 @@ export function ChatView({ )} void @@ -163,6 +166,8 @@ export interface ChatViewInjected { chatNode: (key: string) => ChatNodeSource /** Resolve the stable Turn-process source for one Chat Node key. */ chatNodeProcess: (key: string) => ChatNodeProcessSource + /** Resolve one optional group without subscribing the root View to its data. */ + chatGroup: (key: string) => ObservableSnapshot> | undefined> | undefined } /** Open the current source file of a skill referenced by a sent message. */ openSkill: (name: string) => void diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 8c805e6107..7f5844ce2a 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type { ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder, - ConversationViewDefinition, PartialAssistant, RunningToolCall, + ConversationViewDefinition, ConversationGroupInput, NodeChange, NodeKey, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' @@ -966,12 +966,20 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder + private readonly readGroupNode = (key: NodeKey): ChatConversationViewNode | undefined => this.store.get(key) /** Last published timeline: a Turn boundary can land without a new node. */ private timeline: ConversationTimelineSnapshot | null = null readonly empty: ChatSnapshot constructor() { this.empty = this.snapshot({ turnOrder: EMPTY_TURNS, turns: new Map() }) + this.latestGroupInput = { + kind: 'replace', + order: this.order as readonly NodeKey[], + readNode: this.readGroupNode, + timeline: this.empty.timeline, + } } replace(input: { @@ -985,21 +993,29 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder() let structural = false const contentOnly: ChatConversationViewNode[] = [] + const changes: NodeChange[] = [] for (const node of upserts) { const previous = this.store.get(node.key) + if (previous !== node) changes.push({ previous, current: node }) const nodeStructural = previous === undefined || previous.kind !== node.kind || previous.anchorSeq !== node.anchorSeq @@ -1028,11 +1044,26 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder { + return this.latestGroupInput + } + + publish(): void { + this.store.publish() + } + private snapshot( timeline: ConversationTimelineSnapshot, legacy = this.legacy.replace(EMPTY_LIST, timeline), diff --git a/packages/client/ui-chat/tests/chat-node-source.client.spec.ts b/packages/client/ui-chat/tests/chat-node-source.client.spec.ts index 1209380799..1b5030e537 100644 --- a/packages/client/ui-chat/tests/chat-node-source.client.spec.ts +++ b/packages/client/ui-chat/tests/chat-node-source.client.spec.ts @@ -28,6 +28,37 @@ function userNode(index: number, text = `message ${String(index)}`): ChatConvers } describe('Chat Node keyed sources', () => { + it('provides ordered projected Nodes and previous values to grouping', () => { + const builder = new ChatSnapshotBuilder() + const user = userNode(1) + const snapshot = builder.replace({ nodes: [user], timeline }) + const initial = builder.groupInput() + expect(initial.kind).toBe('replace') + expect(initial.order).toBe(snapshot.order) + expect(initial.readNode(initial.order[0]!)).toBe(user) + const context: ChatConversationViewNode = { + key: 'context:2', kind: 'context', id: '2', target: 'chat', anchorSeq: 2, + location: { kind: 'session' }, visibility: 'visible', + data: { + kind: 'context', seq: 2, time: 2, content: [], + source: { kind: 'skill-invocation', name: 'demo', form: 'instructions' }, + }, + } + const next = builder.apply({ upserts: [context], timeline, changedTurns: [7] }) + const input = builder.groupInput() + if (input.kind !== 'apply') throw new Error('expected apply') + expect(input.order).toBe(next.order) + expect(input.changedTurns).toEqual([7]) + expect(input.changes.find(change => change.current.key === user.key)).toEqual({ + previous: user, current: next.nodes.get(user.key), + }) + expect(next.nodes.get(user.key)?.data).toMatchObject({ skillNames: ['demo'] }) + expect(input.changes.find(change => change.current.key === context.key)).toEqual({ previous: undefined, current: context }) + builder.apply({ upserts: [], timeline }) + expect(builder.groupInput()).toMatchObject({ kind: 'apply', changes: [], changedTurns: [] }) + expect(builder.groupInput().order).toBe(next.order) + }) + it('notifies only the updated key among 4,000 mounted sources', () => { const builder = new ChatSnapshotBuilder() const nodes = Array.from({ length: 4_000 }, (_, index) => userNode(index + 1)) @@ -43,6 +74,9 @@ describe('Chat Node keyed sources', () => { const changed = userNode(target + 1, 'streamed update') const next = builder.apply({ upserts: [changed], timeline }) + expect(listeners[target]).not.toHaveBeenCalled() + builder.publish() + expect(listeners[target]).toHaveBeenCalledOnce() expect(listeners.reduce((count, listener) => count + listener.mock.calls.length, 0)).toBe(1) expect(next.nodes.source(changed.key)).toBe(sources[target]) diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index db60f029a9..a4151bf9ba 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -16,7 +16,8 @@ import type { SessionListState, SessionSnapshot, } from '@deepseek-ai/dsh-api-session-controller/client' import type { - ConversationLocationDataStore, ConversationTurnDataMap, + ConversationLocationDataStore, ConversationTurnDataMap, ConversationGroupedView, + ConversationSnapshot, ConversationViewSnapshotStore, GroupKey, GroupSnapshot, NodeKey, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' @@ -47,6 +48,13 @@ import { ChatSnapshotBuilder } from '../src/client/conversation-nodes/chat-snaps import type { TurnProcessSpec } from '../src/client/contract/turn-process.ts' import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts' import { installTurnNavigatorObserver } from './turn-navigator-fixture.ts' +import { ConversationGroupStore } from '../../ui-conversation/src/client/conversation/group-store.ts' + +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationGroupDataMap { + chat: number + } +} // Every session-scope fixture carries the resource hook the resources plugin merges into GlobalStandardProps. const useResource = (() => ({ status: 'none' as const, value: undefined, failure: undefined })) as GlobalStandardProps['useResource'] @@ -259,6 +267,18 @@ function makeHarness( const useChatNodeProcess = bindKeyedSnapshotSelector( key => chatSource.source.getSnapshot().nodes.processSource(key), ) + let grouped: ConversationGroupedView | undefined + const absentGroup = createSnapshotStore | undefined>(undefined) + const conversation = createSnapshotStore({ + ...EMPTY_CONVERSATION_SNAPSHOT, + views: { + ...EMPTY_CONVERSATION_SNAPSHOT.views, + grouped: (() => grouped) as ConversationViewSnapshotStore['grouped'], + }, + }) + const useChatGroup = bindKeyedSnapshotSelector( + key => grouped?.groupSource(key as GroupKey) ?? absentGroup, + ) const openFile = vi.fn<(path: string) => Promise>().mockResolvedValue(undefined) const openSkill = vi.fn<(name: string) => void>() const loadOlder = vi.fn() @@ -380,7 +400,8 @@ function makeHarness( useChat: bindSnapshotSelector(chatSource.source), useChatNode, useChatNodeProcess, - useConversation: bindSnapshotSelector(createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT)), + useChatGroup, + useConversation: bindSnapshotSelector(conversation), useTrajectory: (() => { throw new Error('unused') }), useSessions: emptySessions(), useSessionRetainInfo: () => undefined, @@ -448,6 +469,10 @@ function makeHarness( setOutline: (value: unknown) => { outlineValue = value }, chatScroll, forkAt, toolOwners, setPerformanceUsage: (mode: 'compact' | 'detailed') => { performanceUsage.set(mode) }, + setGrouped: (value: ConversationGroupedView | undefined) => { + grouped = value + conversation.set({ ...conversation.getSnapshot() }) + }, setTranscriptView: (mode: TranscriptViewMode) => { transcriptView.set(mode) }, setNodeRenderer: (renderer: React.ComponentProps['renderSlot']) => { nodeSlotOverride = renderer @@ -484,10 +509,12 @@ function withSystemPrompt( visibility: 'visible', data: { text }, } - return builder.replace({ + const next = builder.replace({ nodes: [prompt, ...snapshot.nodes.values()], timeline: snapshot.timeline, }) + builder.publish() + return next } function renderedFlowKinds(container: HTMLElement): Array { @@ -582,6 +609,104 @@ describe('Chat node rendering', () => { }) describe('ChatView', () => { + it('keeps grouped Node instances mounted across presentation modes and group data updates', () => { + const snapshot = chatSnapshotFixture({ nodes: [user(1, 'outside'), user(2, 'inside')] }) + const h = makeHarness({}, {}, snapshot) + const [outside, inside] = snapshot.order.map(key => ({ kind: 'node' as const, key: key as NodeKey })) + if (outside === undefined || inside === undefined) throw new Error('expected two Nodes') + const key = 'process' as GroupKey + const groupStore = new ConversationGroupStore() + const record = { key, data: 0, members: [inside] } + groupStore.prepareAndInstall({ + entries: [outside, { kind: 'group', key }], + groups: { kind: 'replace', snapshots: [record] }, + }, id => snapshot.nodes.get(id)) + groupStore.publish() + h.setGrouped(groupStore) + h.setNodeRenderer(((slot: string, owner: object) => { + if (slot !== 'conversation.chat.node' || !('node' in owner)) return null + const node = (owner as RoutedChatNodeOwner).node + return + }) as ChatViewSlotProps['renderSlot']) + const view = render() + const input = view.getByRole('textbox', { name: inside.key }) as HTMLInputElement + const parent = input.closest('[data-chat-group-key]') + expect(parent).not.toBeNull() + fireEvent.change(input, { target: { value: 'retained local input' } }) + for (const mode of ['detailed', 'expanded', 'compact'] as const) { + act(() => { h.setTranscriptView(mode) }) + expect(view.getByRole('textbox', { name: inside.key })).toBe(input) + expect(input.value).toBe('retained local input') + expect(input.closest('[data-chat-group-key]')).toBe(parent) + } + act(() => { + groupStore.prepareAndInstall({ groups: { kind: 'apply', upserts: [{ ...record, data: 1 }], removes: [] } }, id => snapshot.nodes.get(id)) + groupStore.publish() + }) + expect(view.getByRole('textbox', { name: inside.key })).toBe(input) + expect(view.getByRole('textbox', { name: outside.key }).closest('[data-chat-group-key]')).toBeNull() + act(() => { + groupStore.clear() + groupStore.publish() + }) + expect(view.queryByRole('textbox', { name: inside.key })).toBeNull() + act(() => { h.setGrouped(groupStore) }) + expect(view.queryByRole('textbox', { name: outside.key })).toBeNull() + }) + + it('passes independently keyed group parts to business Node renderers', () => { + const snapshot = chatSnapshotFixture({ nodes: [assistant(1, 'answer')] }) + const h = makeHarness({}, {}, snapshot) + const nodeKey = snapshot.order.find(key => snapshot.nodes.get(key)?.kind === 'assistant-step') as NodeKey + const key = 'parts' as GroupKey + const groupStore = new ConversationGroupStore() + groupStore.prepareAndInstall({ + entries: [{ kind: 'group', key }, { kind: 'node', key: nodeKey, groupPart: 'response' }], + groups: { kind: 'replace', snapshots: [{ key, data: 0, members: [{ kind: 'node', key: nodeKey, groupPart: 'reasoning' }] }] }, + }, id => snapshot.nodes.get(id)) + h.setGrouped(groupStore) + h.setNodeRenderer(((slot: string, owner: object) => { + if (slot !== 'conversation.chat.node' || !('node' in owner)) return null + return {(owner as RoutedChatNodeOwner).groupPart} + }) as ChatViewSlotProps['renderSlot']) + const view = render() + expect(view.getByText('reasoning').closest('[data-chat-group-key]')).not.toBeNull() + expect(view.getByText('response').closest('[data-chat-group-key]')).toBeNull() + const anchors = [...view.container.querySelectorAll('[data-chat-group-part]')] + .map(element => element.getAttribute('data-chat-flow-key')) + expect(new Set(anchors).size).toBe(2) + }) + + it('rebinds grouped members when registry rebuilding replaces the Node store', () => { + const snapshot = chatSnapshotFixture({ nodes: [user(1, 'before rebuild')] }) + const h = makeHarness({}, {}, snapshot) + const key = 'retained-group' as GroupKey + const members = snapshot.order.map(id => ({ kind: 'node' as const, key: id as NodeKey })) + const groups = new ConversationGroupStore() + groups.prepareAndInstall({ + entries: [{ kind: 'group', key }], + groups: { kind: 'replace', snapshots: [{ key, data: 0, members }] }, + }, id => snapshot.nodes.get(id)) + groups.publish() + h.setGrouped(groups) + const view = render() + const original = view.getByText('before rebuild').closest('[data-chat-anchor-key]') + const builder = new ChatSnapshotBuilder() + const replacement = builder.replace({ nodes: snapshot.nodes.values(), timeline: snapshot.timeline }) + act(() => { h.set({ chat: replacement }) }) + const current = replacement.nodes.get(members[0]!.key) as ChatNode<'user'> + act(() => { + builder.apply({ + upserts: [{ ...current, data: { ...current.data, content: [{ type: 'text', text: 'after rebuild' }] } }], + timeline: snapshot.timeline, + }) + builder.publish() + }) + expect(view.queryByText('before rebuild')).toBeNull() + expect(view.getByText('after rebuild').closest('[data-chat-anchor-key]')).toBe(original) + expect(groups.groupSource(key).getSnapshot()?.members).toBe(members) + }) + it('leaves the turn rail unrendered when an unrelated Chat update commits', () => { const snapshot = chatSnapshotFixture({ nodes: [ @@ -1875,6 +2000,7 @@ describe('ChatView', () => { act(() => { turnData.publish() + builder.publish() h.set({ chat: complete, hasMore: false }) }) expect(turnProcessControl(view.container)?.getAttribute('aria-expanded')).toBe('false') diff --git a/packages/client/ui-chat/tsconfig.json b/packages/client/ui-chat/tsconfig.json index 92dffe85b0..ab6915a8a6 100644 --- a/packages/client/ui-chat/tsconfig.json +++ b/packages/client/ui-chat/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../util/values" + }, { "path": "../../../vendor/cordis" }, diff --git a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts index af3043fd78..a982323613 100644 --- a/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts +++ b/packages/extensions/cordis-client-runner/src/client/slot-catalog.ts @@ -176,7 +176,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.assistant-actions\', () => ctx.slots.register(\n { name: \'conversation.chat.assistant-actions\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:243', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:248', }, { key: 'conversation.chat.commandview', @@ -224,7 +224,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ occupants: [], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.commandview\', () => ctx.slots.register(\n { name: \'conversation.chat.commandview\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:231', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:236', }, { key: 'conversation.chat.node', @@ -241,7 +241,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ }, ], ownerProps: [ - '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', + '/** Stable owner currency delivered to a keyed Chat renderer. */\nexport interface ChatNodeOwnerProps {\n /** Renderer-owned Node portion selected by the grouping Definition. */\n groupPart?: string\n cwd?: string | undefined\n /** Open the current source file of a skill referenced by a sent message. */\n openSkill: (name: string) => void\n openFile: (path: string, options?: OpenFileOptions) => void\n inspectCall: ((callId: ToolCallId) => void) | undefined\n forkAt: (seq: number) => void\n /**\n * Session-authorized image loader, down-threaded from the Chat view so a\n * chat-node renderer can render the attachment presentation slot directly\n * with only the durable references plus this loader, instead of receiving a\n * rendering closure.\n */\n loadImage: MessageImageLoader\n renderMessageImages: RenderMessageImages\n fileMentions: (owner: TurnTailOwnerProps) => MarkdownFileMentions | undefined\n /** Turn-process state when this Node belongs to a projected Turn. */\n turnProcess?: TurnProcessOwnerProps | undefined\n}', ], ownerPropsReferences: [ 'MarkdownFileMentions', @@ -293,7 +293,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.node\', () => ctx.slots.register(\n { name: \'conversation.chat.node\', key: \'\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:212', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:217', }, { key: 'conversation.chat.turnTail', @@ -354,7 +354,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'none', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.chat.turnTail\', () => ctx.slots.register(\n { name: \'conversation.chat.turnTail\', id: \'my-entry\', order: 100, label: \'My entry\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:237', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:242', }, { key: 'conversation.composer', @@ -1165,7 +1165,7 @@ export const CLIENT_SLOT_API: readonly ClientSlotEntry[] = [ ], replaceRisk: 'shadows-shipped-ui', example: 'return {\n inject: [\'slots\'],\n apply(ctx) {\n ctx.slots.inject(\'conversation.message.images\', () => ctx.slots.register(\n { name: \'conversation.message.images\' },\n () => React.createElement(\'div\', null, \'hello\'),\n ))\n },\n}', - source: 'packages/client/ui-chat/src/client/contract/slots.ts:225', + source: 'packages/client/ui-chat/src/client/contract/slots.ts:230', }, { key: 'conversation.plan-review.actions', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a78e83473..8498acad86 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2912,6 +2912,9 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../../util/values '@deepseek-ai/dsh-util-workspace-path': specifier: workspace:^ version: link:../../util/workspace-path From a7d457349f5034ee66b47adf491cae9a7f295291 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 04:18:21 +0800 Subject: [PATCH 13/20] docs(client): record the conversation grouping foundation --- ...sentation-policy-and-step-groups.i18n.yaml | 4 +- ...hat-presentation-policy-and-step-groups.md | 2 + ...-presentation-policy-and-step-groups.zh.md | 2 + ...-09-21-conversation-build-groups.i18n.yaml | 6 + .../2026-09-21-conversation-build-groups.md | 185 ++++++++++++++++++ ...2026-09-21-conversation-build-groups.zh.md | 185 ++++++++++++++++++ 6 files changed, 382 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml create mode 100644 .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md create mode 100644 .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml index bb3a7b1cbe..e6ea7582a7 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md -2026-09-20-chat-presentation-policy-and-step-groups.md: df0397a0568967c920994192b370e9fe1d063229 -2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 1bf9cf95174a1de0c7d0576bdd9fbb842d980ae7 +2026-09-20-chat-presentation-policy-and-step-groups.md: 3057403c8c9055f4fbc631ba0b4ca0bae2fcd2de +2026-09-20-chat-presentation-policy-and-step-groups.zh.md: f5b30bf96e9ca78b1f9eaacee1a62dd7cb3ec3da diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md index df0397a056..3057403c8c 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md @@ -25,6 +25,8 @@ The three settings, presentation policy channel, and per-Turn folding eligibilit ## Goals and constraints +The [Definition-owned Build Group proposal](2026-09-21-conversation-build-groups.md) supersedes this note's event fold, Builder-materialized group headers, Step-range membership, and changedLocationData proposal. This note retains the presentation-policy and per-Turn eligibility rationale; the grouping implementation below records the superseded route, not the current implementation plan. + The following decisions were confirmed during design review and are inputs to this proposal. | Number | Decision | Meaning | diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md index 1bf9cf9517..f5b30bf96e 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md @@ -25,6 +25,8 @@ Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不 ## 目标与约束 +[Definition 拥有分组的 Build Group 提案](2026-09-21-conversation-build-groups.zh.md)取代本记录中的事件 fold、Builder 物化组头、Step 范围成员推断及 changedLocationData 提案。本记录保留展示策略与按轮折叠资格的理由;下文分组实现记录已被取代的路线,不是当前实施方案。 + 下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。 | 编号 | 裁决 | 含义 | diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml new file mode 100644 index 0000000000..e15c551a3b --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md +2026-09-21-conversation-build-groups.md: 1b012ebdaae7de59fe92ab80ccc42576c1d05f07 +2026-09-21-conversation-build-groups.zh.md: e26a073d55fb01ad9307171e20c7db00b04a1bd8 diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md new file mode 100644 index 0000000000..1b012ebdaa --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md @@ -0,0 +1,185 @@ +# Agent Note: Definition-owned grouping with generic Node/Group assembly + +Status: proposed + +English | [中文](2026-09-21-conversation-build-groups.zh.md) + +## Problem + +Chat needs expandable groups for continuous process content while keeping replies, user input, and Turn controls independent. Compact, Detailed, and Expanded must share grouping results; changing modes must not rebuild React instances by removing group containers, moving member parents, or changing keys. + +A Chat Builder aggregates one target's Nodes and indexes. Hardcoding a Chat process projector there makes the Builder own business segmentation, even if some caching moves into generic infrastructure. Business Definitions are the registration point for these rules. + +Process groups are orthogonal to Steps: one Assistant Node can contribute reasoning inside a group and a reply outside it; tools after that reply enter a later group. Step-number ranges cannot describe membership. Existing event Definitions interpret one event at a time, whereas grouping needs the Nodes those Definitions have already interpreted. + +## Proposal + +The foundation implements a separate, Node-input `ConversationGroupDefinition`, its registry and Session-local context, generic Builder input/publication, keyed Group storage, and the two React branches. Production Chat segmentation and PR #4565 presentation remain a separate business phase; this note stays proposed until that phase is complete. The [subsystem reference](../../../../docs/subsystems/conversation.md#group-definitions) and [source types](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) own the current API details. + +### Agreed responsibilities + +| Requirement | Consequence | +|---|---| +| Business grouping belongs to a registered Definition | Membership, segmentation, replies, steering, retries, summaries, and cache invalidation stay together. | +| Builder and assembler do generic work | Supply inputs, dispatch Definitions, validate references, reuse identities, and publish; never construct a concrete grouping class. | +| PR #4565 defines product behavior | Preserve its behavior without copying its invasive implementation. | +| React has two root kinds | The ordered root contains NodeReference and GroupReference, not another layout object model. | +| All modes share grouping | Mode is absent from Definition input, keys, and parent selection. | +| No buildLayout | ConversationViewDefinition keeps its existing responsibilities. | +| Foundation precedes business | No production Chat grouping, summaries, notifications, or footer changes in the foundation PR. | + +### Related decisions + +| Record | Relationship | +|---|---| +| [Business Node assembly](../../implemented/architecture/2026-08-09-client-conversation-node-assembly.md) | Retains event matching, Contexts, Locations, one business Node per Context, and target Builders; grouping adds another input category. | +| [Presentation policy and grouping](2026-09-20-chat-presentation-policy-and-step-groups.md) | Retains Definition ownership, local subscriptions, and mode independence; replaces event folding and Step-range membership as the grouping mechanism. | +| [Work-details migration](../feature/2026-09-20-chat-work-details-migration.md) | Retains product scope and migration tracking; infrastructure alone does not complete those features. | + +These records retain independent rationale and remain active. Navigation and `toolCallFocus` are outside this change; no View handles, Label Slots, or resource-navigation architecture is introduced. + +## Definition input, state, and registration + +`ConversationNodeDefinition` keeps `match/start/update/publication/buildLocationData/buildViewNode`. `ConversationViewDefinition` keeps `target/create/isActive/toolCallFocus`. Group business is registered through `ctx.uiConversation.groups.register(processGroupDefinition)`, not a callback on either existing Definition. + +- One Group Definition per target owns the complete grouping semantics. Duplicate target registration fails rather than selecting a winner or stacking strategies. +- Registration requires an existing View target, but never constructs a Builder. First activation checks the constructed Builder for `groupInput()`; missing input fails. Ungrouped targets do not require it or allocate Group state. +- Each Session owns one derived context per registered Definition. `create()` initializes its State; it is not a `turn/start` event or an event Definition's unique start Match. Definition objects do not hold mutable cross-Session state. +- `update(context, input)` returns the adopted State. `buildGroups(context)` materializes pending output without interpreting events again or advancing counts on repeated reads. Replacement input requires a complete root and group replacement so removed Nodes cannot leave dangling references; `null` on apply retains output. +- `replace` supplies target order, synchronous `readNode`, and timeline; `apply` also supplies projected previous/current Nodes and `changedTurns`. The reader is valid during the synchronous call only and must not be retained in State. +- Node data stays in the target Node Store. Content-only updates preserve the order array. Previous values are captured before the Builder installs projected updates, including additional Nodes changed by target projections. +- `ConversationLocationIndex` accumulates changed Turns from boundaries and Location data writes. Assembler passes one drained batch to every updated target, including a Turn ending without Node upserts. Direct ungrouped Builder callers may omit this field. +- The generic GroupDataMap associates target and Data across registration, storage, and reading. Undeclared targets have no group payload type; business consumers do not reconstruct summary types with unknown assertions. + +## Node/Group references and updates + +`NodeKey` names an existing Context Node; `GroupKey` identifies a group within a Session, target, and Definition. `NodeReference` and `GroupReference` have equal standing in `RenderEntry`. `GroupSnapshot` stores a group's immutable data and ordered NodeReference members separately from root order. Group creation adds neither Session events nor an execution lifecycle. + +`groupPart` is a renderer-owned part such as reasoning or response; omission selects the whole Node. A part may also appear at the root. Part completeness and content ownership belong to the business renderer, not the framework. Group data describes status, counts, or summaries, without copying Node bodies, tool results, or mutable business State. A group has no renderer field, CSS, coordinates, React elements, navigation, or nested groups. + +| Update | Meaning | +|---|---| +| Omit `entries` | Keep root order. | +| Supply `entries`, including `[]` | Replace the complete root sequence; an empty array clears it. | +| `groups: { kind: 'replace', snapshots }` | Replace complete group records, removing omitted groups. | +| `groups: { kind: 'apply', upserts, removes }` | Upsert complete snapshots and remove named GroupKeys only. | +| Data-only change | Upsert the group with new data, reuse members, omit entries. | +| Member-only change | Upsert that group with its complete new member array; no all-group replacement. | +| Root-only change | Supply entries with empty apply upserts/removes. | +| Dissolve a group | Replace root entries with its members and remove the GroupKey atomically. Source Nodes remain. | + +`entries` and `groups` are one installation batch, not independent publication channels. Removing a member reference does not delete a Node. Upserts need snapshot contents; removals need only identity. React may remount a member after a real membership change, but mode switches must not dissolve groups. + +The store validates the submitted final result before mutation: every root Group has exactly one record and root position; Node references exist; duplicate upserts/removals and simultaneous upsert/removal fail. Across all roots and members, each `(NodeKey, groupPart)` appears at most once. A whole Node cannot coexist with any of its parts, but different parts may occupy different positions. Atomic moves between positions are allowed. + +GroupStore indexes records and sources by GroupKey, not repeated array searches. Equivalent root/member arrays and unchanged group snapshots retain identity. Data-only upserts inspect only submitted snapshots, without scanning roots, unchanged members, or Nodes. Full replacement revalidates references and still aligns results by key. Definition caches own business calculation; GroupStore owns installed results and notifications. + +## Assembly order and lifecycle + +1. BoundConversation receives append, prepend, replace, or Assistant settlement from the existing feed. +2. The assembler runs existing Node Definition matching, state updates, and required Context replay. +3. Existing immediate/animation-frame/none cadence schedules publication; grouping adds no timer or event subscription. +4. Flush materializes Step then Turn Location data and target Nodes. +5. Builder.replace/apply installs projected Nodes, target order, and indexes, and retains GroupInput without notifying independent sources. +6. The assembler obtains the target's registered Group Definition context, calls update with builder.groupInput(), and adopts the returned State. +7. It calls buildGroups, validates and installs the result, and installs the target snapshot. +8. After every affected target is installed, it calls Builder.publish, publishes Group sources, and publishes Location data. +9. BoundConversation publishes the existing root Conversation source. + +First activation's replaceView uses the same target update function as ordinary flush. Inactive targets create neither Builder nor Group context. Registration rebuild takes the View and Group Definitions together, discards replaced/removed Group contexts, and clears old observed output. Surviving Definitions reuse keyed stores. React receives the Node Store identity so replacement rebinds keyed hooks without replacing keys or DOM instances. + +Grouping never replays raw events. Node Definitions handle replay first; grouping consumes the resulting target replacement or changes. Prepend may repair boundaries and legitimately move members. Definition handles old/new Locations and changed Turns instead of treating pagination or every content chunk as a complete-history regroup. + +## Concrete implementation locations + +| Layer/file | Responsibility | +|---|---| +| [groups.ts](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) | Group Definition, input, references, updates, typed data map, and readers. | +| [conversation.ts](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) | Optional Builder groupInput/publish and snapshot-store grouped reader; existing Node/View Definitions stay unchanged. | +| [group-registry.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-registry.ts) | Existing registry base, target uniqueness, typed registration, effect/disposer lifecycle. | +| [assembly.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembly.ts) | UiConversation.groups and existing binding rebuild notifications. | +| [assembler.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembler.ts) | Common first-activation/flush dispatch, context ownership, installation, and publication. | +| [location-index.ts](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) | Accumulate changed Turns, including lifecycle-only and Location-data changes. | +| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | Atomic reference validation, keyed sources, array reuse, and local publication. | +| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | Record projected Node deltas and stable order; defer source notification. No process grouping class. | +| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | Read optional root entries and switch between node/group; fallback to existing Node order. | +| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, member-only subscription, nested Node seats. | +| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | Existing Node sources/renderers, groupPart forwarding, distinct part anchors, Store-replacement rebind. | +| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) and [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | Existing keyed-hook injection for Group sources; no Slot-engine change. | +| Business phase: process-groups.ts and register.ts | Definition-owned segmentation, membership, caches, summaries, and registration. | +| Business phase: AssistantNodeView, Group seat, stores, navigation | Interpret parts; group header/body presentation; local open state; Turn/group reveal, selection/copy and interruption ownership. | + +## Business phase: segmentation and incremental work + +Business State accepts each input, clears pending changes, and returns repeatable output. Replace builds the loaded window; apply separates structural changes from content updates and maintains affected segments or Turn-local indexes. It owns group opening/closing, stable keys, summaries, and invalidation; generic infrastructure never corrects those decisions by Node kind or Step number. + +| Ordered visible input | Business action | +|---|---| +| Owning Turn changes | Close the pending group. | +| No Turn ownership | Close the group and emit an independent Node; never merge across it. | +| turn-process control | Emit independently without closing the pending process. | +| User, steering, trigger, retry, error, max-tokens, or tail | Close the group and emit independently. | +| Assistant reasoning | Append the reasoning part to the pending group. | +| Assistant visible reply | Close the group and emit the response part independently. Later tools enter a later group. | +| Other process Nodes | Append the Node to the pending group; an ordinary Step boundary does not close it. | +| Turn end | Close that Turn's pending groups even without Node upserts. | + +Content growth with unchanged classification does not resegment history; it may update only the current summary. A first reply, new tool, steering, or retry can change structure. Visibility/location repair handles both old and new ownership. Real structural changes may linearly merge root references; the interface does not promise all operations are constant-time. + +## React reading and presentation + +The root selects `views.grouped('chat')?.entries` through the existing useConversation source. Keyed Group hooks use existing injection; each Group seat selects members, while its future header independently selects data. Members use existing keyed Node sources. Removed Group sources can read undefined before the root removes their components, so selectors tolerate absence and keep Hook order stable. + +React keys derive from reference kind, NodeKey/groupPart or GroupKey, using unambiguous tuples. No separate RenderKey type or renderer dispatch field is needed. Compact, Detailed, and Expanded keep the same group container and member parents; modes change header visibility, body visibility, and styles only. + +| Mode | Group header | Body | +|---|---|---| +| Compact | Short activity summary | Group open/closed state controls visibility. | +| Detailed | Summary plus live command/path/query details | Same group open/closed state. | +| Expanded | Hidden | Visible without group-level collapse or height cap; wrapper remains. | + +This assumes the outer Turn process is open. Expanded retains whole-Turn folding and individual tool/reasoning disclosure; it must not eagerly mount all full Markdown. Turn-close resets of internal state are explicit business actions, not key changes. Group headers, scrolling/fades, and Turn interaction belong to the business phase. + +## Alternatives considered + +**Builder-owned buildGroups or process projector.** Rejected: the target aggregator would still own business rules. Moving caches into infrastructure does not change that ownership. + +**Add buildGroups to the existing event Definition without new input.** Rejected: the callback name does not provide cross-Node input, lifecycle changes, or reconstruction semantics. + +**Replay raw events in the Group Definition.** Rejected: it duplicates Assistant/Tool/Message interpretation and can diverge from the Builder's final visible order. + +**ConversationViewDefinition.buildLayout.** Rejected: it broadens a target factory into a business layout callback rather than registering a distinct business Definition. + +**One event Context per Group.** Rejected: boundaries depend on preceding visible content, not a standalone event carrying the group's start identity. A target-local derived context also admits independent Nodes outside Turns. + +**Dependency graphs, ViewItem classes, factories, nested groups, or strategy stacking.** Rejected for this phase: one target input and one non-nested grouping output cover the stated need. + +**Separate data-only mutation operation.** Rejected: an immutable snapshot upsert with reused members already updates summaries without replacing other groups. + +**Let Group Definition add/remove both Node and Group entities.** Rejected: Node data already has an owner. Symmetrical references do not require a second Node owner. + +**Remove the group wrapper in Expanded.** Rejected: changing the parent remounts members even when their keys survive. + +## Acceptance criteria + +1. Builder, assembler, and GroupStore contain no Chat segmentation/category rules or concrete grouping class construction. +2. Group State owns membership, segmentation, summaries, and business invalidation. +3. Ungrouped targets retain their path; duplicate registration and missing input fail without eager Builder construction. +4. Group input carries final projected Nodes, previous/current values, stable order, and lifecycle-only Turn changes. +5. Data-only upserts preserve entries and members, avoid unrelated scans, and notify only changed group sources. +6. All related Nodes, Groups, and Location data are installed before publication; invalid or overlapping references fail before Group installation. +7. First activation, append, settlement, replacement, prepend, and registry reconstruction use the common grouping path. +8. Registration replacement/removal leaves no old Group context or observed output; replacement Node stores rebind mounted members. +9. React renders only node/group root kinds and retains keys and parents across modes; local state survives mode changes. +10. Data types remain correlated through Definition, store, keyed reading, and business rendering. +11. Empty versus omitted entries, Group removal versus Node retention, and undefined removed-group sources retain their documented distinctions. +12. The separate business phase covers replies, steering, retries, no-Turn rows, Turn closure, summaries, and all three modes before claiming PR #4565 behavior complete. + +## Risks + +- This is an explicit framework extension: a new input protocol, registry, context, publication phase, and reader. It is not merely another callback. +- Target-local State does not automatically make business updates local. The Definition must distinguish content growth from structural changes and index the affected ranges. +- Node order and visibility have one owner: the Builder. Grouping must not independently sort raw events or infer membership again in infrastructure. +- Stable mounting does not eliminate layout, paint, or retained-memory costs. No measured latency or optimal-performance claim is made. +- Real group splits, merges, first-member changes, and pagination repairs can change identity. The mode-switch guarantee does not prohibit those legitimate changes. +- Part-specific navigation anchors, selection/copy, interruption notices, group disclosure, and outer Turn interaction require business adaptation. Foundation tests exercise mixed references without enabling production groups. diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md new file mode 100644 index 0000000000..e26a073d55 --- /dev/null +++ b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md @@ -0,0 +1,185 @@ +# Agent Note: Definition 拥有分组业务,框架统一调度 Node/Group 输出 + +Status: proposed + +[English](2026-09-21-conversation-build-groups.md) | 中文 + +## Problem + +Chat 需要把连续过程内容收进可展开的组,同时保留独立回复、用户输入和轮次控件。Compact、Detailed、Expanded 必须共用分组结果;切换模式不能通过删除组容器、移动成员父级或更换 key 重建 React 实例。 + +Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat 过程投影器,即使把部分缓存移到通用层,仍然让 Builder 拥有业务分段。业务 Definition 才是这些规则的注册点。 + +过程组与 Step 正交:同一个 Assistant Node 可以把推理放在组内、回复放在组外,回复后的工具进入后面的组。Step 编号范围无法描述这种成员归属。现有事件 Definition 逐个解释事件,分组则需要这些 Definition 已经解释完成的节点。 + +## Proposal + +基础阶段实现独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。真实 Chat 分段及 PR #4565 展示仍属于独立业务阶段;该阶段完成前,本记录保留 proposed 状态。[子系统参考](../../../../docs/subsystems/conversation.zh.md#group-definitions)与[源码类型](../../../../packages/client/ui-conversation/src/client/contract/groups.ts)拥有当前 API 细节。 + +### 已确认的职责 + +| 要求 | 含义 | +|---|---| +| 分组业务属于已注册 Definition | 成员、分段、回复、steering、重试、摘要和缓存失效放在一起。 | +| Builder 与 assembler 做统一工作 | 提供输入、调度 Definition、校验引用、复用身份并发布,不创建具体分组类。 | +| 产品行为以 PR #4565 为准 | 保留行为,不复制其侵入性实现。 | +| React 根循环只有两种 kind | 根顺序包含 NodeReference 和 GroupReference,不引入另一套布局对象模型。 | +| 所有模式共用分组 | 模式不进入 Definition 输入、key 或父级选择。 | +| 不采用 buildLayout | ConversationViewDefinition 保留现有职责。 | +| 先基础、后业务 | 基础 PR 不包含真实 Chat 分组、摘要、通知或页脚变化。 | + +### 与已有记录的关系 + +| 记录 | 关系 | +|---|---| +| [业务节点组装](../../implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 | +| [展示策略与过程分组](2026-09-20-chat-presentation-policy-and-step-groups.zh.md) | 保留 Definition 归属、局部订阅和模式无关要求,以新分组机制替代事件 fold 及 Step 范围成员推断。 | +| [工作过程展示迁移](../feature/2026-09-20-chat-work-details-migration.zh.md) | 保留产品范围及迁移跟踪,基础机制本身不代表这些功能完成。 | + +这些记录仍有独立理由,保留有效状态。导航与 `toolCallFocus` 不属于本次改动;不引入 View 句柄、Label Slot 或资源导航架构。 + +## Definition 输入、状态与注册 + +`ConversationNodeDefinition` 保留 `match/start/update/publication/buildLocationData/buildViewNode`,`ConversationViewDefinition` 保留 `target/create/isActive/toolCallFocus`。分组业务通过 `ctx.uiConversation.groups.register(processGroupDefinition)` 注册,不加在上述已有 Definition 的回调中。 + +- 每个目标只有一个 Group Definition,拥有完整分组语义。重复目标注册报错,不选择胜者,也不叠加策略。 +- 注册要求已有 View 目标,但不构造 Builder。首次激活取得 Builder 后检查 `groupInput()`,缺少输入方法时报错。未分组目标不要求该方法,也不分配 Group 状态。 +- 每个 Session 为已注册 Definition 拥有一个派生上下文。`create()` 初始化 State,不是 `turn/start` 事件,也不是事件 Definition 的唯一 start Match。Definition 对象不保存跨 Session 的可变状态。 +- `update(context, input)` 返回框架采纳的 State。`buildGroups(context)` 物化待输出结果,不重新解释事件,不因重复读取增加计数。替换输入要求输出完整根序列和组替换,避免已移除节点留下悬挂引用;apply 的 `null` 保留结果。 +- `replace` 提供目标顺序、同步 `readNode` 和时间线;`apply` 还提供投影后的节点前后值及 `changedTurns`。读取函数仅在当前同步调用中有效,不保留到 State。 +- 节点数据仍在目标 Node Store。仅正文更新保留顺序数组;旧值在 Builder 安装投影结果前记录,也覆盖目标投影额外改变的节点。 +- `ConversationLocationIndex` 从边界事件和 Location 数据写入累计变化轮次。assembler 将同一批变化交给全部被更新目标,覆盖没有节点 upsert 的 Turn 结束。直接调用未分组 Builder 的调用方可省略该字段。 +- 通用 GroupDataMap 在注册、存储及读取之间关联目标和 Data。未声明的目标没有组载荷类型,业务消费方不通过 unknown 断言恢复摘要类型。 + +## Node/Group 引用与更新 + +`NodeKey` 表示既有 Context Node,`GroupKey` 在 Session、目标及 Definition 内标识组。`NodeReference` 与 `GroupReference` 在 `RenderEntry` 中地位相同。`GroupSnapshot` 单独保存组的不可变数据与有序 NodeReference 成员,不混入根顺序。创建 Group 不增加 Session 事件或执行生命周期。 + +`groupPart` 是渲染器拥有的节点部分,例如 reasoning 或 response,省略表示整个 Node。部分引用也可以独立位于根层。部分内容的完备性和归属由业务渲染器负责,不由框架判断。组数据描述状态、计数或摘要,不复制 Node 正文、工具结果或可变业务 State。组不携带 renderer 字段、CSS、坐标、React 元素、导航或嵌套组。 + +| 更新 | 含义 | +|---|---| +| 省略 `entries` | 保留根顺序。 | +| 提供 `entries`,包括 `[]` | 替换完整根序列,空数组清空根序列。 | +| `groups: { kind: 'replace', snapshots }` | 替换完整组记录,移除未包含的旧组。 | +| `groups: { kind: 'apply', upserts, removes }` | upsert 完整快照,只按具名 GroupKey 删除组。 | +| 仅数据变化 | upsert 新 data,复用 members,省略 entries。 | +| 仅成员变化 | upsert 该组完整的新成员数组,不替换全部组。 | +| 仅根顺序变化 | 提供 entries,apply 的 upserts/removes 为空。 | +| 解散一个组 | 原子地用成员替换根引用并删除 GroupKey,保留原 Node。 | + +`entries` 与 `groups` 属于同一安装批次,不是独立发布通道。删除成员引用不删除 Node。upsert 需要快照内容,删除只需要身份。真实成员归属变化可以让 React 重挂载成员,但模式切换不能解散组。 + +存储在修改前校验最终提交结果:每个根 Group 恰有一份记录和一个根位置;Node 引用存在;重复 upsert、重复删除及同时 upsert/删除均报错。全部根和成员中,每个 `(NodeKey, groupPart)` 最多出现一次。整 Node 不能与自身任一部分共存,不同部分可以占据不同位置。允许引用在一次原子更新中移动位置。 + +GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价根数组、成员数组及未变化组快照保留身份。仅数据 upsert 只检查提交的快照,不扫描根、未变化成员或 Node。完整替换重新校验引用,仍按 key 对齐结果。Definition 缓存拥有业务计算,GroupStore 拥有已安装结果及通知。 + +## 组装时序与生命周期 + +1. BoundConversation 从现有 feed 接收 append、prepend、replace 或 Assistant settlement。 +2. assembler 运行既有 Node Definition 匹配、状态更新及必要的 Context 重放。 +3. 既有 immediate/animation-frame/none 节奏调度发布,分组不增加计时器或事件订阅。 +4. flush 依次物化 Step、Turn Location 数据及目标 Node。 +5. Builder.replace/apply 安装投影后 Node、目标顺序及索引,保留 GroupInput,不通知独立来源。 +6. assembler 取得目标已注册的 Group Definition 上下文,以 builder.groupInput() 调用 update,采纳返回 State。 +7. 调用 buildGroups,校验并安装结果,安装目标快照。 +8. 全部受影响目标安装完成后,调用 Builder.publish,发布 Group 来源和 Location 数据。 +9. BoundConversation 发布既有根 Conversation 来源。 + +首次激活的 replaceView 与普通 flush 使用同一目标更新函数。未激活目标不创建 Builder 或 Group 上下文。注册重建一并采纳 View 和 Group Definition,丢弃被替换或删除的 Group 上下文并清空旧观察结果。未替换的 Definition 复用按键存储。React 接收 Node Store 身份,让存储替换时重新绑定按键钩子,不替换 key 或 DOM 实例。 + +分组不重放原始事件。Node Definition 先处理重放,分组消费其最终目标替换或变化。prepend 可以修正边界并真实移动成员。Definition 处理新旧 Location 和变化轮次,不把分页或每个正文分片都当作完整历史重新分组。 + +## 具体实现落点 + +| 层/文件 | 职责 | +|---|---| +| [groups.ts](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) | Group Definition、输入、引用、更新、类型数据映射及读取器。 | +| [conversation.ts](../../../../packages/client/ui-conversation/src/client/contract/conversation.ts) | Builder 可选 groupInput/publish 和快照存储 grouped 读取,已有 Node/View Definition 不变。 | +| [group-registry.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-registry.ts) | 复用注册表基类,目标唯一性、类型化注册及 effect/disposer 生命周期。 | +| [assembly.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembly.ts) | UiConversation.groups 与现有 binding 重建通知。 | +| [assembler.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembler.ts) | 首次激活和 flush 共用调度,上下文归属、安装及发布。 | +| [location-index.ts](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) | 累积变化轮次,覆盖仅生命周期和 Location 数据变化。 | +| [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | 原子引用校验、按键来源、数组复用及局部发布。 | +| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | 记录投影后节点增量及稳定顺序,推迟来源通知,不持有过程分组类。 | +| [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | 读取可选根引用并切换 node/group,无分组时使用既有 Node 顺序。 | +| [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、仅成员订阅及嵌套 Node 容器。 | +| [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | 既有 Node 来源及渲染器、groupPart 传递、独立部分锚点及 Store 替换时重绑定。 | +| [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) 与 [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | 使用现有按键钩子注入 Group 来源,不改 Slot 引擎。 | +| 业务阶段:process-groups.ts 与 register.ts | Definition 拥有分段、成员、缓存、摘要及注册。 | +| 业务阶段:AssistantNodeView、Group 容器、stores、navigation | 解释部分内容,组头和正文展示,本地开合状态,Turn/Group 揭示、选择复制及中断提示归属。 | + +## 业务阶段:分段与增量 + +业务 State 接受输入、清空待输出变化并返回可重复读取的结果。replace 构建已加载窗口;apply 区分结构变化和正文更新,维护受影响段或轮次内索引。它拥有开组、关组、稳定 key、摘要和失效规则;通用层不按 Node kind 或 Step 编号再次修正业务结论。 + +| 有序可见输入 | 业务动作 | +|---|---| +| 所属 Turn 改变 | 关闭待完成组。 | +| 没有 Turn 归属 | 关闭组并输出独立 Node,不跨越它合并。 | +| turn-process 控件 | 独立输出,不关闭待完成过程。 | +| 用户、steering、触发、重试、错误、最大 token 或尾部 | 关闭组并独立输出。 | +| Assistant 推理 | 把 reasoning 部分加入待完成组。 | +| Assistant 可见回复 | 关闭组,独立输出 response 部分;后续工具进入后面的组。 | +| 其他过程 Node | 加入待完成组,普通 Step 边界不关闭组。 | +| Turn 结束 | 即使没有节点 upsert,也关闭该轮次待完成的组。 | + +分类未变的正文增长不重新分段历史,必要时只更新当前摘要。首次回复、新工具、steering 或重试可以改变结构。可见性或位置修正同时处理旧、新归属。真实结构变化允许线性合并根引用,接口不承诺全部操作常数时间。 + +## React 读取与展示 + +根层通过既有 useConversation 来源选择 `views.grouped('chat')?.entries`。按键 Group 钩子沿用现有注入,Group 容器选择 members,后续组头独立选择 data。成员使用既有按键 Node 来源。根列表移除组件前,已删除 Group 来源可能先返回 undefined,因此选择器允许不存在,并保持钩子顺序稳定。 + +React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,模式只改变组头、正文显隐及样式。 + +| 模式 | 组头 | 正文 | +|---|---|---| +| Compact | 简短活动摘要 | 按组开合状态显隐。 | +| Detailed | 摘要及实时命令、路径、查询详情 | 使用同一组开合状态。 | +| Expanded | 隐藏 | 显示,取消组级折叠及限高,容器保留。 | + +以上以外层 Turn 过程已展开为前提。Expanded 保留整轮折叠和单个工具、推理行开合,不能提前挂载全部完整 Markdown。Turn 收起时的内部状态重置是明确业务动作,不通过更换 key 实现。组头、滚动渐隐及整轮联动属于业务阶段。 + +## Alternatives considered + +**Builder 持有 buildGroups 或过程投影器。** 不采用,目标汇总器仍然拥有业务规则,把缓存移到通用层不能改变归属。 + +**在原事件 Definition 加 buildGroups,不补输入。** 不采用,回调名称不能提供跨节点输入、生命周期变化或重建语义。 + +**Group Definition 重放原始事件。** 不采用,它重复 Assistant、Tool、Message 解释,并可能偏离 Builder 最终可见顺序。 + +**ConversationViewDefinition.buildLayout。** 不采用,它把目标工厂扩展成业务布局回调,没有独立注册业务 Definition。 + +**每个 Group 一个事件 Context。** 不采用,边界取决于前方可见内容,不由一个携带组起点身份的事件决定。目标内派生上下文也能处理 Turn 外独立节点。 + +**依赖图、ViewItem 类、工厂、嵌套组或策略叠加。** 本阶段不采用,一个目标输入和一份非嵌套分组输出覆盖当前诉求。 + +**独立的数据更新操作。** 不采用,复用 members 的不可变快照 upsert 已经能更新摘要,不替换其他组。 + +**Group Definition 同时增删 Node 和 Group 实体。** 不采用,Node 数据已有所有者,引用对称不需要第二个 Node 所有者。 + +**Expanded 移除组容器。** 不采用,即使 key 不变,父级改变仍会重挂载成员。 + +## Acceptance criteria + +1. Builder、assembler 和 GroupStore 不含 Chat 分段或类别规则,不固定创建具体分组类。 +2. Group State 拥有成员、分段、摘要和业务失效规则。 +3. 未分组目标保持原路径;重复注册及缺少输入报错,不提前构造 Builder。 +4. 分组输入包含投影后的最终节点、前后值、稳定顺序及仅生命周期的轮次变化。 +5. 仅数据 upsert 保留 entries 和 members,不扫描无关内容,只通知变化组来源。 +6. 相关 Node、Group 和 Location 数据先全部安装再发布;无效及重叠引用在 Group 安装前报错。 +7. 首次激活、append、settlement、replace、prepend 和注册重建使用统一分组路径。 +8. 注册替换或删除不遗留旧 Group 上下文及观察结果;Node 存储替换时重绑定已挂载成员。 +9. React 根层只有 node/group,模式切换保留 key、父级及局部状态。 +10. Definition、存储、按键读取及业务渲染贯穿同一 Data 类型。 +11. 空与省略 entries、删除 Group 与保留 Node、已删除组返回 undefined 均保持协议规定的区别。 +12. 独立业务阶段覆盖回复、steering、重试、无 Turn 行、Turn 结束、摘要和三档展示后,才能宣称 PR #4565 产品行为完成。 + +## Risks + +- 这是明确的框架扩展,包含新输入协议、注册表、上下文、发布阶段及读取器,不只是增加一个回调。 +- 目标内 State 不会自动让业务更新局部化,Definition 必须区分正文增长与结构变化,并索引受影响范围。 +- Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。 +- 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。 +- 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。 +- 部分导航锚点、选择复制、中断提示、组开合与外层 Turn 联动需要业务适配。基础测试使用混合引用,不启用真实分组。 From 0076de86fc423776a1017823f6e77fa7b5645432 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 04:28:16 +0800 Subject: [PATCH 14/20] test(client): cover group injection and conversation fixtures --- .../tests/apply-inject.client.spec.tsx | 32 +++++++++++++++++++ .../tests/chat-ptc-subcalls.client.spec.tsx | 9 ++++-- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index dff3bbe376..cb16fa6a82 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -3,6 +3,7 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' +import { brandString } from '@deepseek-ai/dsh-brand' import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { @@ -12,6 +13,7 @@ import type { SessionBehaviorOverrides } from '@deepseek-ai/dsh-client-test-runt import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { apply as applyConversation, inject as injectConversation, + type GroupKey, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { apply as applyChat, inject as injectChat, type ChatViewInjected, @@ -127,6 +129,36 @@ async function bench(initialSettings?: ChatSettings, withBrowserRegistry = true) } describe('Chat inject API', () => { + it('resolves keyed Group sources across registration, activation, and removal', async () => { + const b = await bench() + try { + const { injected } = b.chatViewApi(b.rootReference) + const key = brandString('injected-group') + expect(injected.keyedHooks.chatGroup(key)).toBeUndefined() + const conversation = b.runtime.ctx.uiConversation + const remove = conversation.groups.register({ + kind: 'test-group', target: 'chat', + create: () => null, + update: () => null, + buildGroups: () => ({ + entries: [{ kind: 'group', key }], + groups: { kind: 'replace', snapshots: [{ key, data: 1, members: [] }] }, + }), + }) + await Promise.resolve() + conversation.binding(b.rootReference.binding).activate('chat') + const source = injected.keyedHooks.chatGroup(key) + expect(source?.getSnapshot()?.data).toBe(1) + expect(injected.keyedHooks.chatGroup(key)).toBe(source) + remove() + await Promise.resolve() + expect(source?.getSnapshot()).toBeUndefined() + expect(injected.keyedHooks.chatGroup(key)).toBeUndefined() + } finally { + await b.runtime.dispose() + } + }) + it('loads older history and forks through the Session Controller', async () => { const b = await bench() const { injected } = b.chatViewApi(b.rootReference) diff --git a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx index 54212fa981..973aceae7f 100644 --- a/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-ptc-subcalls.client.spec.tsx @@ -11,7 +11,7 @@ import { SlotTestRuntime, stubSettingsScope } from '@deepseek-ai/dsh-client-test import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { - ConversationEventRegistry, ConversationViewRegistry, type ConvViewOwnerProps, + ConversationEventRegistry, ConversationViewRegistry, EMPTY_CONVERSATION_SNAPSHOT, type ConvViewOwnerProps, } from '@deepseek-ai/dsh-client-ui-conversation/client' import { en as conversationEn, NS as CONVERSATION_NS, zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' import { apply as applyChat, inject as injectChat } from '@deepseek-ai/dsh-client-ui-chat/client' @@ -102,14 +102,19 @@ async function bench(snapshot: ChatSnapshot) { runtimes.push(runtime) const ctx = runtime.ctx const chat = createSnapshotStore(snapshot) + const conversation = createSnapshotStore(EMPTY_CONVERSATION_SNAPSHOT) const events = new ConversationEventRegistry(ctx) const views = new ConversationViewRegistry(ctx) ctx.provide('settingsScope', { developerTools: { enabled: createSnapshotStore(true) }, bind: () => stubSettingsScope().scope } as never) ctx.provide('uiConversation', { events, views, - binding: () => ({ target: () => chat }), + binding: () => ({ target: () => chat, snapshot: conversation }), } as never) + ctx.uiSession.provide({ + hooks: ['conversation'], + resolve: () => ({ hooks: { conversation } }), + }) await runtime.sessions.add({ id: SID, From c1c3410cf09c550b9df412061852f1400c0fb992 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 04:32:19 +0800 Subject: [PATCH 15/20] fix(benchmarks): declare grouping runtime dependency --- benchmarks/package.json | 3 ++- pnpm-lock.yaml | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/benchmarks/package.json b/benchmarks/package.json index fe74d6f65b..ad685bcb27 100644 --- a/benchmarks/package.json +++ b/benchmarks/package.json @@ -33,7 +33,8 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-util-values": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8498acad86..1047eb63b7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -825,6 +825,9 @@ importers: '@deepseek-ai/dsh-typert-protocol': specifier: workspace:^ version: link:../packages/typert/protocol + '@deepseek-ai/dsh-util-values': + specifier: workspace:^ + version: link:../packages/util/values '@xterm/headless': specifier: ^6.0.0 version: 6.0.0 From b3549ee45590de5dd1536c78bbd3ca2896791b2c Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 12:11:47 +0800 Subject: [PATCH 16/20] fix(client): preserve grouped chat navigation and view lifecycle --- ...-09-21-conversation-build-groups.i18n.yaml | 4 +- .../2026-09-21-conversation-build-groups.md | 8 +- ...2026-09-21-conversation-build-groups.zh.md | 8 +- docs/subsystems/conversation.i18n.yaml | 4 +- docs/subsystems/conversation.md | 4 +- docs/subsystems/conversation.zh.md | 4 +- packages/client/ui-chat/README.i18n.yaml | 4 +- packages/client/ui-chat/README.md | 4 +- packages/client/ui-chat/README.zh.md | 4 +- .../src/client/chat/ChatGroupSeat.module.css | 3 + .../ui-chat/src/client/chat/ChatGroupSeat.tsx | 5 +- .../ui-chat/src/client/chat/ChatNodeSeat.tsx | 1 + .../ui-chat/src/client/chat/ChatView.tsx | 2 +- .../src/client/chat/use-chat-viewport.ts | 13 +- .../tests/apply-inject.client.spec.tsx | 9 +- .../ui-chat/tests/chat-view.client.spec.tsx | 21 ++++ .../tests/chat-viewport.client.spec.ts | 114 ++++++++++++++++++ .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 + packages/client/ui-conversation/README.zh.md | 2 + .../src/client/contract/groups.ts | 6 +- .../src/client/conversation/assembler.ts | 5 - .../src/client/conversation/group-store.ts | 7 +- .../tests/conversation-groups.client.spec.ts | 47 +++++++- 24 files changed, 244 insertions(+), 41 deletions(-) create mode 100644 packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css create mode 100644 packages/client/ui-chat/tests/chat-viewport.client.spec.ts diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml index e15c551a3b..a11e052d9a 100644 --- a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md -2026-09-21-conversation-build-groups.md: 1b012ebdaae7de59fe92ab80ccc42576c1d05f07 -2026-09-21-conversation-build-groups.zh.md: e26a073d55fb01ad9307171e20c7db00b04a1bd8 +2026-09-21-conversation-build-groups.md: 9d7c233346e04218821719b43cfa8abc21651854 +2026-09-21-conversation-build-groups.zh.md: 6736a65e24b99cb2b3bd3d3bd20cd93a92bb63aa diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md index 1b012ebdaa..9d7c233346 100644 --- a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md +++ b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md @@ -36,7 +36,7 @@ The foundation implements a separate, Node-input `ConversationGroupDefinition`, | [Presentation policy and grouping](2026-09-20-chat-presentation-policy-and-step-groups.md) | Retains Definition ownership, local subscriptions, and mode independence; replaces event folding and Step-range membership as the grouping mechanism. | | [Work-details migration](../feature/2026-09-20-chat-work-details-migration.md) | Retains product scope and migration tracking; infrastructure alone does not complete those features. | -These records retain independent rationale and remain active. Navigation and `toolCallFocus` are outside this change; no View handles, Label Slots, or resource-navigation architecture is introduced. +These records retain independent rationale and remain active. Cross-View navigation and `toolCallFocus` are outside this change; no View handles, Label Slots, or resource-navigation architecture is introduced. ## Definition input, state, and registration @@ -88,6 +88,8 @@ GroupStore indexes records and sources by GroupKey, not repeated array searches. First activation's replaceView uses the same target update function as ordinary flush. Inactive targets create neither Builder nor Group context. Registration rebuild takes the View and Group Definitions together, discards replaced/removed Group contexts, and clears old observed output. Surviving Definitions reuse keyed stores. React receives the Node Store identity so replacement rebinds keyed hooks without replacing keys or DOM instances. +Removing a registered View Definition pauses its grouping and clears derived output, while retaining the Group Definition registration. Restoring the View uses the existing replacement flow to rebuild from the current loaded timeline. Initial Group registration still rejects a missing View target; switching tabs does not unregister a View. + Grouping never replays raw events. Node Definitions handle replay first; grouping consumes the resulting target replacement or changes. Prepend may repair boundaries and legitimately move members. Definition handles old/new Locations and changed Turns instead of treating pagination or every content chunk as a complete-history regroup. ## Concrete implementation locations @@ -132,6 +134,8 @@ The root selects `views.grouped('chat')?.entries` through the existing useConver React keys derive from reference kind, NodeKey/groupPart or GroupKey, using unambiguous tuples. No separate RenderKey type or renderer dispatch field is needed. Compact, Detailed, and Expanded keep the same group container and member parents; modes change header visibility, body visibility, and styles only. +The stable Group parent is a `div` with `display: contents`, without its own layout box. CSS inheritance remains available; business styles adapt child/sibling selectors and own measurable body containers. Neither CSS variables nor geometry enter the Definition. Reading-position capture measures member Nodes and retains part-specific anchors; existing Turn navigation resolves the original NodeKey to its first visible part. + | Mode | Group header | Body | |---|---|---| | Compact | Short activity summary | Group open/closed state controls visibility. | @@ -182,4 +186,4 @@ This assumes the outer Turn process is open. Expanded retains whole-Turn folding - Node order and visibility have one owner: the Builder. Grouping must not independently sort raw events or infer membership again in infrastructure. - Stable mounting does not eliminate layout, paint, or retained-memory costs. No measured latency or optimal-performance claim is made. - Real group splits, merges, first-member changes, and pagination repairs can change identity. The mode-switch guarantee does not prohibit those legitimate changes. -- Part-specific navigation anchors, selection/copy, interruption notices, group disclosure, and outer Turn interaction require business adaptation. Foundation tests exercise mixed references without enabling production groups. +- Revealing hidden parts, selection/copy, interruption notices, group disclosure, and outer Turn interaction require business adaptation. Foundation tests exercise mixed references without enabling production groups. diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md index e26a073d55..6736a65e24 100644 --- a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md @@ -36,7 +36,7 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat | [展示策略与过程分组](2026-09-20-chat-presentation-policy-and-step-groups.zh.md) | 保留 Definition 归属、局部订阅和模式无关要求,以新分组机制替代事件 fold 及 Step 范围成员推断。 | | [工作过程展示迁移](../feature/2026-09-20-chat-work-details-migration.zh.md) | 保留产品范围及迁移跟踪,基础机制本身不代表这些功能完成。 | -这些记录仍有独立理由,保留有效状态。导航与 `toolCallFocus` 不属于本次改动;不引入 View 句柄、Label Slot 或资源导航架构。 +这些记录仍有独立理由,保留有效状态。跨 View 导航与 `toolCallFocus` 不属于本次改动;不引入 View 句柄、Label Slot 或资源导航架构。 ## Definition 输入、状态与注册 @@ -88,6 +88,8 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价 首次激活的 replaceView 与普通 flush 使用同一目标更新函数。未激活目标不创建 Builder 或 Group 上下文。注册重建一并采纳 View 和 Group Definition,丢弃被替换或删除的 Group 上下文并清空旧观察结果。未替换的 Definition 复用按键存储。React 接收 Node Store 身份,让存储替换时重新绑定按键钩子,不替换 key 或 DOM 实例。 +已注册的 View Definition 被移除后,分组计算暂停并清空派生结果,Group Definition 注册仍保留。View 恢复时沿现有替换流程,从当前已加载时间线重建。首次注册 Group 时仍拒绝缺失的 View 目标;切换页签不会注销 View。 + 分组不重放原始事件。Node Definition 先处理重放,分组消费其最终目标替换或变化。prepend 可以修正边界并真实移动成员。Definition 处理新旧 Location 和变化轮次,不把分页或每个正文分片都当作完整历史重新分组。 ## 具体实现落点 @@ -132,6 +134,8 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价 React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,模式只改变组头、正文显隐及样式。 +稳定 Group 父级使用 `div` 与 `display: contents`,自身不产生布局盒子。CSS 继承仍然可用,业务样式适配子级/兄弟选择器并拥有可测量的正文容器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。 + | 模式 | 组头 | 正文 | |---|---|---| | Compact | 简短活动摘要 | 按组开合状态显隐。 | @@ -182,4 +186,4 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派 - Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。 - 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。 - 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。 -- 部分导航锚点、选择复制、中断提示、组开合与外层 Turn 联动需要业务适配。基础测试使用混合引用,不启用真实分组。 +- 隐藏部分的揭示、选择复制、中断提示、组开合与外层 Turn 联动需要业务适配。基础测试使用混合引用,不启用真实分组。 diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index efb7d6d43a..e6c640b8fa 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/conversation.md -conversation.md: 0478b546154bddffaa7003df77a995fdcbfa9c39 -conversation.zh.md: 386e25dcfbaa2f9137f723d7d6a3b03963a1e5a2 +conversation.md: 29ed4aa85ebdcc9061f33d620e26119fe7a9f927 +conversation.zh.md: 2aba5ab97dd93a5561e4790da87aa72939ebe322 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index 0478b54615..29ed4aa85e 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -40,7 +40,7 @@ The [group types](../../packages/client/ui-conversation/src/client/contract/grou | `ConversationGroupInput` | `replace` supplies the ordered target Node keys, synchronous `readNode`, and timeline. `apply` also supplies projected `previous/current` Node changes and `changedTurns`, including lifecycle-only changes. Do not retain the reader in State. | | `NodeReference` / `GroupReference` | Branded `NodeKey` or `GroupKey`, distinguished by `kind`. A Node reference may select a renderer-owned `groupPart`; omission selects the whole Node. | | `GroupSnapshot` | Immutable group key, business data, and ordered Node references. Groups cannot contain other groups or own source Node data. | -| `GroupUpdate` | Optional `entries` replaces the complete root sequence; omission preserves it. `groups.replace.snapshots` replaces all groups; `groups.apply.upserts/removes` updates only named group records. Removal does not delete Nodes. | +| `GroupUpdate` | `entries` replaces the complete root sequence; only apply input may omit it to preserve the sequence. Replacement input requires both `entries` and `groups.replace`. `groups.replace.snapshots` replaces all groups; `groups.apply.upserts/removes` updates only named group records. Removal does not delete Nodes. | | `ConversationGroupDataMap` | Declaration-merged target-to-data association shared by registration and `views.grouped(target)`. Undeclared targets have no group payload type. | | `ConversationGroupedView` | Stable root `entries` and keyed `groupSource(key)` readers; a removed group reads as `undefined`. | @@ -48,7 +48,7 @@ The Builder exposes `groupInput()` when grouping is registered; missing input fa The Group store validates the whole submitted result before installation: root Group references and records correspond one-to-one, all referenced Nodes exist, and each `(NodeKey, groupPart)` occupies at most one root or member position. A whole Node cannot coexist with one of its parts. Duplicate upserts, duplicate removals, and simultaneous upsert/removal of a group fail. Data-only upserts retain root and member arrays, do not read Nodes, and notify only changed group sources; full replacement revalidates every reference. -Renderers switch on the two reference kinds and choose components in the owning View, not through a renderer field in group data. The group body reads members separately from summary data. Presentation modes must retain component types, keys, and member parents; changing data membership may legitimately remount a moved member. The framework does not interpret part completeness, segmentation, or presentation policies. +Renderers switch on the two reference kinds and choose components in the owning View, not through a renderer field in group data. Root Node references and referenced group members form the complete render list; unreferenced target Nodes remain stored but do not render. The group body reads members separately from summary data. Presentation modes must retain component types, keys, and member parents; changing data membership may legitimately remount a moved member. The framework does not interpret part completeness, segmentation, or presentation policies. ## Replayable event families diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 386e25dcfb..2aba5ab97d 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -40,7 +40,7 @@ shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View ro | `ConversationGroupInput` | `replace` 提供目标节点的有序键、同步 `readNode` 及时间线。`apply` 还提供投影后的 `previous/current` 节点变化和 `changedTurns`,覆盖仅生命周期变化。不要把读取函数留在 State 中。 | | `NodeReference` / `GroupReference` | 以 `kind` 区分的品牌类型 `NodeKey` 或 `GroupKey`。Node 引用可选择渲染器拥有的 `groupPart`,省略表示整个 Node。 | | `GroupSnapshot` | 不可变的组键、业务数据和有序 Node 引用。Group 不包含其他 Group,也不拥有原 Node 数据。 | -| `GroupUpdate` | 可选的 `entries` 替换完整根序列,省略则保留。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 | +| `GroupUpdate` | `entries` 替换完整根序列,仅 apply 输入允许省略它以保留原序列。替换输入必须同时提供 `entries` 与 `groups.replace`。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 | | `ConversationGroupDataMap` | 通过声明合并关联目标与数据类型,注册及 `views.grouped(target)` 共用。未声明的目标没有组载荷类型。 | | `ConversationGroupedView` | 稳定的根 `entries` 与按键 `groupSource(key)` 读取器;已删除的组返回 `undefined`。 | @@ -48,7 +48,7 @@ shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View ro Group 存储在安装前校验完整提交结果:根 Group 引用与记录一一对应,引用的 Node 全部存在,每个 `(NodeKey, groupPart)` 在根和成员位置中最多占位一次。整 Node 不能与自身任一部分共存。重复 upsert、重复删除以及同时 upsert 和删除一个组都会报错。仅数据 upsert 保留根及成员数组、不读取 Node,并只通知变化组的来源;完整替换重新校验全部引用。 -渲染器按两种引用 kind 切换,由所属 View 选择组件,不通过组数据中的 renderer 字段选择。组 body 将成员与摘要数据分开读取。展示模式必须保留组件类型、key 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。 +渲染器按两种引用 kind 切换,由所属 View 选择组件,不通过组数据中的 renderer 字段选择。根 Node 引用与被引用组的成员构成完整渲染列表;未被引用的目标 Node 仍保留在存储中,但不渲染。组 body 将成员与摘要数据分开读取。展示模式必须保留组件类型、key 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。 ## 可回放 event family diff --git a/packages/client/ui-chat/README.i18n.yaml b/packages/client/ui-chat/README.i18n.yaml index 5589484177..ee8dc73bfc 100644 --- a/packages/client/ui-chat/README.i18n.yaml +++ b/packages/client/ui-chat/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md -README.md: 64ca5bb9d6da1fa6ea34b5022d2f045c0a816d5d -README.zh.md: 17885c3cf954630e1210c377d621b6612ce6735d +README.md: 0fd407f33b1a7cba5a1f2612411d9ec333679c9c +README.zh.md: e9d5005851f57d632ca4ab7835d32ce14105d3a6 diff --git a/packages/client/ui-chat/README.md b/packages/client/ui-chat/README.md index 64ca5bb9d6..0fd407f33b 100644 --- a/packages/client/ui-chat/README.md +++ b/packages/client/ui-chat/README.md @@ -89,7 +89,9 @@ Stable Chat Node Seats keep every renderer mounted, hidden members add no flow s ## Grouped rendering -Chat can render optional Conversation Group Definition output as a mixed `node`/`group` root list. A group seat subscribes only to its member array; each member retains the existing keyed Node source and renderer. `groupPart` reaches that renderer as a business-owned part selector, with distinct DOM anchors for different parts. Presentation modes do not select the root branch or alter member parents. No process Group Definition is registered by this package yet; the default transcript remains ungrouped. +Chat can render optional Conversation Group Definition output as a mixed `node`/`group` root list. A group seat subscribes only to its member array; each member retains the existing keyed Node source and renderer. `groupPart` reaches that renderer as a business-owned part selector, with distinct DOM anchors for reading-position restoration; Turn navigation can still address the original Node key and land on its first visible part. Presentation modes do not select the root branch or alter member parents. No process Group Definition is registered by this package yet; the default transcript remains ungrouped. + +The group seat uses `div` with `display: contents`: it retains the DOM parent without creating a layout box. CSS inheritance remains available, but child and sibling selectors still follow the DOM tree. The existing direct-child spacing selectors do not reach group members. Business styles must adapt spacing within and across groups, including hidden or empty members and the answer-spacing exception, and own any measurable body or scroll container. CSS variables do not belong in the Group Definition. ----- diff --git a/packages/client/ui-chat/README.zh.md b/packages/client/ui-chat/README.zh.md index 17885c3cf9..e9d5005851 100644 --- a/packages/client/ui-chat/README.zh.md +++ b/packages/client/ui-chat/README.zh.md @@ -89,7 +89,9 @@ Chat 在所有工作过程展示模式下都不显示系统提示词行和 `perm ## 分组渲染 -Chat 可以把可选的 Conversation Group Definition 输出渲染为混合 `node`/`group` 根列表。组容器只订阅成员数组,每个成员保留既有的按键 Node 来源与渲染器。`groupPart` 作为业务拥有的部分选择器传给渲染器,不同部分具有不同 DOM 锚点。展示模式不选择根分支,也不改变成员父级。本包尚未注册过程 Group Definition,默认文本记录仍未分组。 +Chat 可以把可选的 Conversation Group Definition 输出渲染为混合 `node`/`group` 根列表。组容器只订阅成员数组,每个成员保留既有的按键 Node 来源与渲染器。`groupPart` 作为业务拥有的部分选择器传给渲染器,不同部分具有独立的 DOM 锚点用于恢复阅读位置;轮次导航仍可使用原 Node key,落到它的第一个可见部分。展示模式不选择根分支,也不改变成员父级。本包尚未注册过程 Group Definition,默认文本记录仍未分组。 + +组容器使用 `div` 与 `display: contents`,保留 DOM 父级但不产生布局盒子。CSS 继承仍然可用,子级和兄弟选择器仍遵循 DOM 树。原有直接子级间距选择器不会匹配组内成员。业务样式必须适配组内及组边界间距,处理隐藏或空成员以及回答前的间距特例,并拥有需要测量的正文或滚动容器。CSS 变量不属于 Group Definition。 ----- diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css new file mode 100644 index 0000000000..94ce0b8455 --- /dev/null +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.module.css @@ -0,0 +1,3 @@ +.root { + display: contents; +} diff --git a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx index a4f4c1bf1d..a5af681ef5 100644 --- a/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx @@ -4,6 +4,7 @@ import type { GroupKey } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatViewSlotProps } from '../contract/slots.ts' import { ChatNodeSeat } from './ChatNodeSeat.tsx' import { chatRenderKey } from './render-entry.ts' +import css from './ChatGroupSeat.module.css' type ChatGroupSeatProps = Omit, 'nodeKey' | 'groupPart'> & { readonly groupKey: GroupKey @@ -15,7 +16,7 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou const members = useChatGroup(groupKey, group => group?.members) if (members === undefined) return null return ( -
+
{members.map(member => ( ))} -
+
) }) diff --git a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx index 081a1d768d..bb62567364 100644 --- a/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx @@ -138,6 +138,7 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({ className={css.flowItem} data-chat-anchor-key={flowKey} data-chat-flow-key={flowKey} + data-chat-node-key={routedNode.key} data-chat-group-part={groupPart} data-chat-flow-kind={routedNode.kind} data-chat-turn={turn} diff --git a/packages/client/ui-chat/src/client/chat/ChatView.tsx b/packages/client/ui-chat/src/client/chat/ChatView.tsx index d948993a3c..a712ae4d8d 100644 --- a/packages/client/ui-chat/src/client/chat/ChatView.tsx +++ b/packages/client/ui-chat/src/client/chat/ChatView.tsx @@ -99,7 +99,7 @@ function TurnStatus({ startTime, t }: { ) } -type ChatNodeListProps = Omit, 'nodeKey'> & { +type ChatNodeListProps = Omit, 'nodeKey' | 'groupPart'> & { readonly entries: readonly RenderEntry[] readonly useChatGroup: ChatViewSlotProps['useChatGroup'] } diff --git a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts index b4c1735707..2be7eb66a1 100644 --- a/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts +++ b/packages/client/ui-chat/src/client/chat/use-chat-viewport.ts @@ -141,10 +141,13 @@ export class ChatViewport { private anchor(key: string): HTMLElement | null { if (this.elements === null) return null - for (const row of this.elements.list.querySelectorAll('[data-chat-anchor-key]:not([hidden])')) { + // Reading anchors name exact parts; Turn navigation names the original Node. + let nodePart: HTMLElement | null = null + for (const row of this.elements.list.querySelectorAll('[data-chat-anchor-key]:not([hidden]):not([hidden] *)')) { if (row.dataset.chatAnchorKey === key) return row + if (nodePart === null && row.dataset.chatNodeKey === key) nodePart = row } - return null + return nodePart } /** @@ -169,7 +172,8 @@ export class ChatViewport { } if (anchor === null) { const rows = list.querySelectorAll( - '[data-chat-flow] > [data-chat-flow-key]:not(:empty):not([hidden])', + ':is([data-chat-flow], [data-chat-flow] > [data-chat-group-key])' + + ' > [data-chat-flow-key]:not([data-chat-group-key]):not(:empty):not([hidden]):not([hidden] *)', ) let low = 0 let high = rows.length @@ -229,6 +233,7 @@ export class ChatViewport { /** * Align a known loaded turn and return its actual clamped position. + * A split Node anchor selects its first visible part. * @param turn - loaded turn to align below the scrollport's top edge. * @returns the actual landing, or null when its anchor is unavailable. */ @@ -246,7 +251,7 @@ export class ChatViewport { */ scrollToTurnAtOrAfter(turn: number): ViewportLanding | null { if (this.elements === null) return null - for (const row of this.elements.list.querySelectorAll('[data-chat-turn]:not([hidden])')) { + for (const row of this.elements.list.querySelectorAll('[data-chat-turn]:not([hidden]):not([hidden] *)')) { const candidate = Number(row.dataset.chatTurn) if (Number.isSafeInteger(candidate) && candidate >= turn) return this.align(row, 24, candidate) } diff --git a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx index cb16fa6a82..9728a0b409 100644 --- a/packages/client/ui-chat/tests/apply-inject.client.spec.tsx +++ b/packages/client/ui-chat/tests/apply-inject.client.spec.tsx @@ -3,7 +3,6 @@ import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' -import { brandString } from '@deepseek-ai/dsh-brand' import type { ISession, SessionReference } from '@deepseek-ai/dsh-api-session-controller/client' import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' import { @@ -24,6 +23,12 @@ import { createChatStore } from '../src/client/stores.ts' import { CHAT_SETTINGS_NAMESPACE, type ChatSettings } from '../src/chat-settings.ts' import type { LinkOpeningRowInjected } from '../src/client/settings/LinkOpeningRow.tsx' +declare module '@deepseek-ai/dsh-client-ui-conversation/client' { + interface ConversationGroupDataMap { + chat: number + } +} + usePinnedBrowserLanguages('zh-CN') const ROOT = 'root-1' as SessionId @@ -133,7 +138,7 @@ describe('Chat inject API', () => { const b = await bench() try { const { injected } = b.chatViewApi(b.rootReference) - const key = brandString('injected-group') + const key = 'injected-group' as GroupKey expect(injected.keyedHooks.chatGroup(key)).toBeUndefined() const conversation = b.runtime.ctx.uiConversation const remove = conversation.groups.register({ diff --git a/packages/client/ui-chat/tests/chat-view.client.spec.tsx b/packages/client/ui-chat/tests/chat-view.client.spec.tsx index a4151bf9ba..9ff18e1ab8 100644 --- a/packages/client/ui-chat/tests/chat-view.client.spec.tsx +++ b/packages/client/ui-chat/tests/chat-view.client.spec.tsx @@ -609,6 +609,24 @@ describe('Chat node rendering', () => { }) describe('ChatView', () => { + it('renders only referenced Nodes without deleting unreferenced Node data', () => { + const snapshot = chatSnapshotFixture({ nodes: [user(1, 'included'), user(2, 'omitted')] }) + const h = makeHarness({}, {}, snapshot) + const [included, omitted] = snapshot.order + if (included === undefined || omitted === undefined) throw new Error('expected two Nodes') + const groupStore = new ConversationGroupStore() + groupStore.prepareAndInstall({ + entries: [{ kind: 'node', key: included as NodeKey }], + groups: { kind: 'replace', snapshots: [] }, + }, id => snapshot.nodes.get(id)) + h.setGrouped(groupStore) + + const view = render() + expect(view.getByText('included')).toBeTruthy() + expect(view.queryByText('omitted')).toBeNull() + expect(snapshot.nodes.get(omitted)).toBeDefined() + }) + it('keeps grouped Node instances mounted across presentation modes and group data updates', () => { const snapshot = chatSnapshotFixture({ nodes: [user(1, 'outside'), user(2, 'inside')] }) const h = makeHarness({}, {}, snapshot) @@ -632,6 +650,7 @@ describe('ChatView', () => { const input = view.getByRole('textbox', { name: inside.key }) as HTMLInputElement const parent = input.closest('[data-chat-group-key]') expect(parent).not.toBeNull() + expect(parent?.tagName).toBe('DIV') fireEvent.change(input, { target: { value: 'retained local input' } }) for (const mode of ['detailed', 'expanded', 'compact'] as const) { act(() => { h.setTranscriptView(mode) }) @@ -675,6 +694,8 @@ describe('ChatView', () => { const anchors = [...view.container.querySelectorAll('[data-chat-group-part]')] .map(element => element.getAttribute('data-chat-flow-key')) expect(new Set(anchors).size).toBe(2) + expect([...view.container.querySelectorAll('[data-chat-group-part]')] + .map(element => element.getAttribute('data-chat-node-key'))).toEqual([nodeKey, nodeKey]) }) it('rebinds grouped members when registry rebuilding replaces the Node store', () => { diff --git a/packages/client/ui-chat/tests/chat-viewport.client.spec.ts b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts new file mode 100644 index 0000000000..09303fdde9 --- /dev/null +++ b/packages/client/ui-chat/tests/chat-viewport.client.spec.ts @@ -0,0 +1,114 @@ +// @vitest-environment jsdom + +import { expect, it, onTestFinished, vi } from 'vitest' +import { ChatViewport } from '../src/client/chat/use-chat-viewport.ts' + +function fixture() { + const column = document.createElement('div') + column.dataset.chatFlow = '' + document.body.append(column) + const viewport = new ChatViewport() + const hitTest = Object.getOwnPropertyDescriptor(document, 'elementsFromPoint') + Object.defineProperty(document, 'elementsFromPoint', { configurable: true, value: undefined }) + onTestFinished(() => { + viewport.detach() + column.remove() + if (hitTest === undefined) Reflect.deleteProperty(document, 'elementsFromPoint') + else Object.defineProperty(document, 'elementsFromPoint', hitTest) + vi.restoreAllMocks() + }) + Object.defineProperties(column, { + clientHeight: { configurable: true, value: 300 }, + scrollHeight: { configurable: true, value: 2_000 }, + }) + vi.spyOn(column, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 500, 300)) + viewport.attach(column, column) + let prependedHeight = 0 + const group = () => { + const element = document.createElement('div') + element.dataset.chatGroupKey = 'group' + element.style.display = 'contents' + column.append(element) + return element + } + const row = (parent: HTMLElement, nodeKey: string, top: number, part?: string) => { + const element = document.createElement('div') + element.textContent = nodeKey + element.dataset.chatNodeKey = nodeKey + element.dataset.chatAnchorKey = part === undefined ? nodeKey : JSON.stringify([nodeKey, part]) + element.dataset.chatFlowKey = element.dataset.chatAnchorKey + element.dataset.chatTurn = '1' + parent.append(element) + vi.spyOn(element, 'getBoundingClientRect').mockImplementation(() => + new DOMRect(0, top + prependedHeight - column.scrollTop, 500, 60)) + return element + } + return { column, viewport, group, row, prepend: (height: number) => { prependedHeight += height } } +} + +it('captures a grouped member and preserves its reading position after history grows above it', () => { + const h = fixture() + const group = h.group() + h.row(group, 'first', 20) + h.row(group, 'second', 130) + h.column.scrollTop = 100 + + const position = h.viewport.capturePosition() + expect(position).toEqual({ anchorKey: 'second', anchorTop: 30, scrollTop: 100 }) + if (position === null) throw new Error('expected a grouped reading anchor') + h.prepend(200) + const landing = h.viewport.preserve(position) + expect(landing?.metrics.top).toBe(300) + expect(landing?.position).toEqual({ anchorKey: 'second', anchorTop: 30, scrollTop: 300 }) +}) + +it('navigates by Node identity while retaining a distinct visible part anchor', () => { + const h = fixture() + const group = h.group() + const reasoning = h.row(group, 'assistant', 100, 'reasoning') + const response = h.row(group, 'assistant', 200, 'response') + h.viewport.updateTurns([{ turn: 1, anchorKey: 'assistant', prompt: '', response: '' }]) + + expect(h.viewport.scrollToTurn(1)?.position?.anchorKey).toBe(reasoning.dataset.chatAnchorKey) + reasoning.setAttribute('hidden', 'until-found') + const landing = h.viewport.scrollToTurn(1) + expect(landing?.metrics.top).toBe(176) + expect(landing?.position?.anchorKey).toBe(response.dataset.chatAnchorKey) + expect(h.viewport.restore({ anchorKey: response.dataset.chatAnchorKey!, anchorTop: 8, scrollTop: 0 })?.metrics.top) + .toBe(192) +}) + +it('excludes empty rows, hidden ancestors, and transparent group shells from fallback sampling', () => { + const h = fixture() + const hidden = h.group() + hidden.setAttribute('hidden', 'until-found') + h.row(hidden, 'hidden', 10) + const group = h.group() + group.dataset.chatFlowKey = 'group' + group.dataset.chatAnchorKey = 'group' + const empty = h.row(group, 'empty', 30) + empty.textContent = '' + h.row(group, 'visible', 70) + + expect(h.viewport.capturePosition()?.anchorKey).toBe('visible') +}) + +it('skips hidden group members when navigating to a fallback Turn row', () => { + const h = fixture() + const hidden = h.group() + hidden.setAttribute('hidden', 'until-found') + h.row(hidden, 'hidden', 10, 'reasoning') + const visible = h.row(h.group(), 'visible', 150, 'response') + + const landing = h.viewport.scrollToTurnAtOrAfter(1) + expect(landing?.metrics.top).toBe(126) + expect(landing?.position?.anchorKey).toBe(visible.dataset.chatAnchorKey) +}) + +it('retains the original capture and Turn navigation path for ungrouped Nodes', () => { + const h = fixture() + h.row(h.column, 'whole', 90) + h.viewport.updateTurns([{ turn: 1, anchorKey: 'whole', prompt: '', response: '' }]) + expect(h.viewport.capturePosition()?.anchorKey).toBe('whole') + expect(h.viewport.scrollToTurn(1)?.metrics.top).toBe(66) +}) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index e4719a752f..252ea7674a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 496c60e29582573abe15253ad0efb46e5bd7125f -README.zh.md: 71d2afefd9b22e0af21d2cd496d53cda9ac6a46c +README.md: 41a9baeb7cc370cdb4a9d6702808646a0df38fb3 +README.zh.md: 1b5604f0c0c074ac64a28da61706be5d663fda85 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 496c60e295..41a9baeb7c 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -33,6 +33,8 @@ A target becomes active when shell selection resolves it or when its source rece `UiConversation.groups` registers one optional business Group Definition per existing target. It consumes the Builder's projected Node changes and changed Turns after Node materialization, including first activation, and owns all segmentation and group data. The assembler validates and installs root references and keyed group snapshots before publishing Node, Group, and Location data sources. Ungrouped targets retain their original path. [Grouping](../../../docs/subsystems/conversation.md#group-definitions) defines input lifetime, typed registration, atomic updates, and renderer responsibilities. +Group registration rejects a missing View target. Removing an already registered View Definition pauses its grouping and clears its published results without removing the Group Definition; registering that View again rebuilds from the current loaded timeline through the existing replacement flow. Switching View tabs does not remove a View Definition. + Target packages declaration-merge their snapshot and Location data maps, then register with `ctx.uiConversation.events.register(...)` and `ctx.uiConversation.views.register(...)`. A target reads its Session-owned source with `ctx.uiConversation.binding(binding).target(targetId)`. Registrations are Cordis effects and their returned disposers remove the contribution from the same registry. The shared request inspection serves every target: `ctx.uiConversation.inspectSystemPrompt(previous, event)` interprets system messages and positional replacements as immutable loaded-surface state. It selects the last nonempty surviving system node in surface order, retains only surviving replacement positions for chained rewrites, and withholds the prompt after an unindexed older endpoint until prepend replay supplies its order. Target-owned Definitions retain historical cards independently. `ctx.uiConversation.inspectRequestPrompt(previous, header, system)` classifies request changes against that effective prompt; ordinary messages and stream chunks require no system-state work. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 71d2afefd9..1b5604f0c0 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -33,6 +33,8 @@ shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 `UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费 Builder 投影后的节点变化及变化轮次,覆盖首次激活,并拥有全部分段规则与组数据。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。 +Group 注册时缺少 View 目标会报错。已注册的 View Definition 被移除后,其分组计算暂停并清空已发布结果,但保留 Group Definition;重新注册该 View 后,沿现有替换流程从当前已加载时间线重建。切换 View 页签不会移除 View Definition。 + target package 通过 declaration merge 扩展 snapshot 与 Location data map,再调用 `ctx.uiConversation.events.register(...)` 和 `ctx.uiConversation.views.register(...)`。target 通过 `ctx.uiConversation.binding(binding).target(targetId)` 读取其 Session-owned source。注册属于 Cordis effect,返回的 disposer 从同一个 registry 移除 contribution。共享的请求检查服务于每个 target:`ctx.uiConversation.inspectSystemPrompt(previous, event)` 将系统消息与位置替换解释为不可变的已加载 surface 状态。它按 surface 顺序选择最后一个非空的存活系统节点,为连续重写只保留存活的替换位置;遇到未建立索引的更早端点后,提示词保持不可用,直到向前补页回放提供其顺序。target 自有的 Definition 独立保留历史卡片。`ctx.uiConversation.inspectRequestPrompt(previous, header, system)` 根据该有效提示词分类请求变更;普通消息与流式分片无需处理系统状态。 diff --git a/packages/client/ui-conversation/src/client/contract/groups.ts b/packages/client/ui-conversation/src/client/contract/groups.ts index 4a7e532c0c..37864617a8 100644 --- a/packages/client/ui-conversation/src/client/contract/groups.ts +++ b/packages/client/ui-conversation/src/client/contract/groups.ts @@ -33,10 +33,12 @@ export interface GroupSnapshot { } /** - * One atomic grouping update. Omitted entries preserve the root list; - * supplied entries replace it. Group removal never deletes source Nodes. + * One atomic grouping update. Replacement input requires entries and a complete + * group replacement; apply input may omit entries to preserve the root list. + * Group removal never deletes source Nodes. */ export interface GroupUpdate { + /** Complete root sequence; only its Node references and referenced group members render. */ readonly entries?: readonly RenderEntry[] readonly groups: | { readonly kind: 'replace'; readonly snapshots: readonly GroupSnapshot[] } diff --git a/packages/client/ui-conversation/src/client/conversation/assembler.ts b/packages/client/ui-conversation/src/client/conversation/assembler.ts index 68b5ad9316..d51216de32 100644 --- a/packages/client/ui-conversation/src/client/conversation/assembler.ts +++ b/packages/client/ui-conversation/src/client/conversation/assembler.ts @@ -994,11 +994,6 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore private resetViewBuilders(): void { const definitions = this.viewDefinitions.entries() const targets = new Set(definitions.map(definition => definition.target)) - for (const definition of this.groupDefinitions.entries()) { - if (!targets.has(definition.target)) { - throw new Error(`conversation group target "${definition.target}" is not registered`) - } - } for (const [target, group] of this.groups) { if (!targets.has(target) || this.groupDefinitions.forTarget(target) !== group.definition) { group.store.clear() diff --git a/packages/client/ui-conversation/src/client/conversation/group-store.ts b/packages/client/ui-conversation/src/client/conversation/group-store.ts index 2a38f2a4df..a56e769bc7 100644 --- a/packages/client/ui-conversation/src/client/conversation/group-store.ts +++ b/packages/client/ui-conversation/src/client/conversation/group-store.ts @@ -174,8 +174,11 @@ export class ConversationGroupStore implements ConversationGroupedView undefined) + this.prepareAndInstall( + { entries: [], groups: { kind: 'replace', snapshots: [] } }, + /* v8 ignore next -- an empty replacement never reads a Node reference. */ + () => undefined, + ) } private collectRootGroups(entries: readonly RenderEntry[]): Set { diff --git a/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts index b10e8a420f..78a95a6185 100644 --- a/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts @@ -6,7 +6,7 @@ import type { ConversationTimelineSnapshot, ConversationViewBuilder, ConversationViewDefinition, ConversationViewNode, } from '../src/client/contract/conversation.ts' import type { - ConversationGroupDefinition, ConversationGroupInput, GroupKey, NodeKey, + ConversationGroupDefinition, ConversationGroupInput, GroupKey, GroupUpdate, NodeKey, } from '../src/client/contract/groups.ts' import { ConversationNodeAssembler } from '../src/client/conversation/assembler.ts' import { ConversationGroupRegistry } from '../src/client/conversation/group-registry.ts' @@ -214,11 +214,8 @@ describe('group Definition dispatch', () => { expect(() => assembler.activateTarget('group-test')).toThrow('requires builder.groupInput()') }) - it('rejects missing targets and incomplete initial output', () => { + it('requires complete grouping for replacement input', () => { const definition = grouping() - expect(() => new ConversationNodeAssembler(noEvents, { entries: () => [] }, { - entries: () => [definition], forTarget: () => definition, - })).toThrow('is not registered') const missing = setup({ ...definition, buildGroups: () => null }) expect(() => missing.assembler.activateTarget('group-test')).toThrow('complete grouping for replacement input') const retained = grouping() @@ -230,6 +227,46 @@ describe('group Definition dispatch', () => { expect(() => assembler.flush()).toThrow('complete grouping for replacement input') }) + it.each>([ + { groups: { kind: 'replace', snapshots: [] } }, + { entries: [], groups: { kind: 'apply', upserts: [], removes: [] } }, + ])('rejects incomplete grouping for replacement input: %j', (update) => { + const { assembler } = setup({ ...grouping(), buildGroups: () => update }) + expect(() => assembler.activateTarget('group-test')).toThrow('complete grouping for replacement input') + }) + + it('pauses grouping when its View disappears and rebuilds when the View returns', () => { + const seen: ConversationGroupInput[] = [] + const definition = grouping(seen) + const createGroup = vi.spyOn(definition, 'create') + const { assembler, views, create } = setup(definition) + assembler.activateTarget('group-test') + const source = assembler.grouped('group-test')!.groupSource(groupKey) + const changed = vi.fn() + source.subscribe(changed) + + const view = views.pop()! + assembler.rebuildRegistry() + expect(assembler.flush()).toBe(true) + expect(assembler.snapshot('group-test')).toBeUndefined() + expect(assembler.grouped('group-test')).toBeUndefined() + expect(source.getSnapshot()).toBeUndefined() + expect(changed).toHaveBeenCalledOnce() + + assembler.append({ type: 'event', event: start(1) }) + assembler.flush() + expect(seen).toHaveLength(1) + expect(createGroup).toHaveBeenCalledOnce() + views.push(view) + assembler.rebuildRegistry() + assembler.flush() + expect(create).toHaveBeenCalledTimes(2) + expect(createGroup).toHaveBeenCalledTimes(2) + expect(seen.map(input => input.kind)).toEqual(['replace', 'replace']) + expect(seen.at(-1)?.timeline.turns.has(1)).toBe(true) + expect(assembler.grouped('group-test')!.groupSource(groupKey).getSnapshot()?.data).toBe(0) + }) + it('registers grouping without creating its Builder and disposes it with its effect', async () => { const ctx = new Context() onTestFinished(async () => { await ctx.fiber.dispose() }) From b9252673e9cecc19463416d3d28a783d64b3b33f Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 13:18:42 +0800 Subject: [PATCH 17/20] fix(client): report only changed turn locations --- .../src/client/conversation/location-index.ts | 2 +- .../conversation-assembler.client.spec.ts | 18 ++++++++++++++++++ .../conversation-group-store.client.spec.ts | 6 +++--- 3 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/client/ui-conversation/src/client/conversation/location-index.ts b/packages/client/ui-conversation/src/client/conversation/location-index.ts index 2f6eb70d9b..839beedeaf 100644 --- a/packages/client/ui-conversation/src/client/conversation/location-index.ts +++ b/packages/client/ui-conversation/src/client/conversation/location-index.ts @@ -494,7 +494,7 @@ export class ConversationLocationIndex { ? [...this.timeline.turnOrder, turnNumber] : this.timeline.turnOrder this.timeline = { turnOrder, turns } - this.changedTurns.add(turnNumber) + if (turn !== previousTurn) this.changedTurns.add(turnNumber) const changed = new Set() for (const seq of this.seqsByTurn.get(turnNumber) ?? []) { diff --git a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts index 7b6441f217..340263e65d 100644 --- a/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts @@ -172,6 +172,24 @@ function fallbackDefinition(start: () => string): ConversationNodeDefinition { + it('reports boundary changes only when the owning Turn location changes', () => { + const index = new ConversationLocationIndex() + const boundaries = [ + at(SessionSeq(1), 'turn/start', { turn: 1 }), + at(SessionSeq(2), 'step/start', { turn: 1, step: 1 }), + at(SessionSeq(3), 'step/end', { turn: 1, step: 1 }), + at(SessionSeq(4), 'turn/end', { turn: 1 }), + ] + for (const boundary of boundaries) { + index.appendBoundary(boundary) + expect(index.takeChangedTurns()).toEqual([1]) + const turn = index.snapshot().turns.get(1) + index.appendBoundary(boundary) + expect(index.snapshot().turns.get(1)).toBe(turn) + expect(index.takeChangedTurns()).toEqual([]) + } + }) + it('publishes Location data through stable per-key sources', () => { const index = new ConversationLocationIndex() const turnStart = at(SessionSeq(1), 'turn/start', { turn: 1 }) diff --git a/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts b/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts index 2a165bfb93..a437f25b27 100644 --- a/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts @@ -124,7 +124,7 @@ describe('ConversationGroupStore', () => { const initial = group('a', [node('a')]) const entries = [reference('a'), node('b')] replace(store, entries, [initial]) - expect(() =>{ store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: [initial] } }, () => undefined) }) + expect(() => { store.prepareAndInstall({ entries, groups: { kind: 'replace', snapshots: [initial] } }, () => undefined) }) .toThrow('missing Node') expect(store.entries).toBe(entries) expect(store.groupSource(key('a')).getSnapshot()).toBe(initial) @@ -139,7 +139,7 @@ describe('ConversationGroupStore', () => { const store = new ConversationGroupStore() replace(store, [node('before')], []) const root = store.entries - expect(() =>{ replace(store, [first, reference('a')], [group('a', [second])]) }).toThrow('overlapping') + expect(() => { replace(store, [first, reference('a')], [group('a', [second])]) }).toThrow('overlapping') expect(store.entries).toBe(root) replace(store, [reference('a')], [group('a', [first])]) expect(store.groupSource(key('a')).getSnapshot()?.members).toEqual([first]) @@ -163,7 +163,7 @@ describe('ConversationGroupStore', () => { store.publish() const root = store.entries const source = store.groupSource(key('a')) - expect(() =>{ store.prepareAndInstall(update, readNode) }).toThrow(error) + expect(() => { store.prepareAndInstall(update, readNode) }).toThrow(error) expect(store.entries).toBe(root) expect(source.getSnapshot()).toBe(initial) }) From 7dd6d1fc31229deb00b6fc2ae97b95dc3a4f97aa Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 13:29:46 +0800 Subject: [PATCH 18/20] docs(client): mark the grouping foundation implemented --- ...-09-21-conversation-build-groups.i18n.yaml | 6 ++ .../2026-09-21-conversation-build-groups.md | 66 +++++-------------- ...2026-09-21-conversation-build-groups.zh.md | 66 +++++-------------- ...sentation-policy-and-step-groups.i18n.yaml | 4 +- ...hat-presentation-policy-and-step-groups.md | 2 +- ...-presentation-policy-and-step-groups.zh.md | 2 +- ...-09-21-conversation-build-groups.i18n.yaml | 6 -- ...9-20-chat-work-details-migration.i18n.yaml | 4 +- .../2026-09-20-chat-work-details-migration.md | 38 ++++++++++- ...26-09-20-chat-work-details-migration.zh.md | 38 ++++++++++- 10 files changed, 112 insertions(+), 120 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml rename .agents/notes/{proposed => implemented}/architecture/2026-09-21-conversation-build-groups.md (72%) rename .agents/notes/{proposed => implemented}/architecture/2026-09-21-conversation-build-groups.zh.md (72%) delete mode 100644 .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml new file mode 100644 index 0000000000..282f61cdcb --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md +2026-09-21-conversation-build-groups.md: ab55de55767cca1da63d4c52a6b1defba1244ad3 +2026-09-21-conversation-build-groups.zh.md: ebef2df31b623125b8aa96ae4a42c5d6a97ec29b diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md similarity index 72% rename from .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md rename to .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md index 9d7c233346..ab55de5576 100644 --- a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md @@ -1,6 +1,6 @@ # Agent Note: Definition-owned grouping with generic Node/Group assembly -Status: proposed +Status: implemented English | [中文](2026-09-21-conversation-build-groups.zh.md) @@ -12,11 +12,11 @@ A Chat Builder aggregates one target's Nodes and indexes. Hardcoding a Chat proc Process groups are orthogonal to Steps: one Assistant Node can contribute reasoning inside a group and a reply outside it; tools after that reply enter a later group. Step-number ranges cannot describe membership. Existing event Definitions interpret one event at a time, whereas grouping needs the Nodes those Definitions have already interpreted. -## Proposal +## Decision -The foundation implements a separate, Node-input `ConversationGroupDefinition`, its registry and Session-local context, generic Builder input/publication, keyed Group storage, and the two React branches. Production Chat segmentation and PR #4565 presentation remain a separate business phase; this note stays proposed until that phase is complete. The [subsystem reference](../../../../docs/subsystems/conversation.md#group-definitions) and [source types](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) own the current API details. +The grouping foundation provides a separate, Node-input `ConversationGroupDefinition`, its registry and Session-local context, generic Builder input/publication, keyed Group storage, and the two React branches. Production Chat segmentation and PR #4565 presentation are not registered by this foundation; the [business migration proposal](../../proposed/feature/2026-09-20-chat-work-details-migration.md) retains their requirements. The [subsystem reference](../../../../docs/subsystems/conversation.md#group-definitions) and [source types](../../../../packages/client/ui-conversation/src/client/contract/groups.ts) own the current API details. -### Agreed responsibilities +### Responsibilities | Requirement | Consequence | |---|---| @@ -32,9 +32,9 @@ The foundation implements a separate, Node-input `ConversationGroupDefinition`, | Record | Relationship | |---|---| -| [Business Node assembly](../../implemented/architecture/2026-08-09-client-conversation-node-assembly.md) | Retains event matching, Contexts, Locations, one business Node per Context, and target Builders; grouping adds another input category. | -| [Presentation policy and grouping](2026-09-20-chat-presentation-policy-and-step-groups.md) | Retains Definition ownership, local subscriptions, and mode independence; replaces event folding and Step-range membership as the grouping mechanism. | -| [Work-details migration](../feature/2026-09-20-chat-work-details-migration.md) | Retains product scope and migration tracking; infrastructure alone does not complete those features. | +| [Business Node assembly](2026-08-09-client-conversation-node-assembly.md) | Retains event matching, Contexts, Locations, one business Node per Context, and target Builders; grouping adds another input category. | +| [Presentation policy and grouping](../../proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md) | Retains Definition ownership, local subscriptions, and mode independence; replaces event folding and Step-range membership as the grouping mechanism. | +| [Work-details migration](../../proposed/feature/2026-09-20-chat-work-details-migration.md) | Retains product scope and migration tracking; infrastructure alone does not complete those features. | These records retain independent rationale and remain active. Cross-View navigation and `toolCallFocus` are outside this change; no View handles, Label Slots, or resource-navigation architecture is introduced. @@ -59,7 +59,7 @@ These records retain independent rationale and remain active. Cross-View navigat | Update | Meaning | |---|---| -| Omit `entries` | Keep root order. | +| Omit `entries` on apply input | Keep root order; replacement input requires the complete sequence. | | Supply `entries`, including `[]` | Replace the complete root sequence; an empty array clears it. | | `groups: { kind: 'replace', snapshots }` | Replace complete group records, removing omitted groups. | | `groups: { kind: 'apply', upserts, removes }` | Upsert complete snapshots and remove named GroupKeys only. | @@ -108,42 +108,15 @@ Grouping never replays raw events. Node Definitions handle replay first; groupin | [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, member-only subscription, nested Node seats. | | [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | Existing Node sources/renderers, groupPart forwarding, distinct part anchors, Store-replacement rebind. | | [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) and [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | Existing keyed-hook injection for Group sources; no Slot-engine change. | -| Business phase: process-groups.ts and register.ts | Definition-owned segmentation, membership, caches, summaries, and registration. | -| Business phase: AssistantNodeView, Group seat, stores, navigation | Interpret parts; group header/body presentation; local open state; Turn/group reveal, selection/copy and interruption ownership. | - -## Business phase: segmentation and incremental work - -Business State accepts each input, clears pending changes, and returns repeatable output. Replace builds the loaded window; apply separates structural changes from content updates and maintains affected segments or Turn-local indexes. It owns group opening/closing, stable keys, summaries, and invalidation; generic infrastructure never corrects those decisions by Node kind or Step number. - -| Ordered visible input | Business action | -|---|---| -| Owning Turn changes | Close the pending group. | -| No Turn ownership | Close the group and emit an independent Node; never merge across it. | -| turn-process control | Emit independently without closing the pending process. | -| User, steering, trigger, retry, error, max-tokens, or tail | Close the group and emit independently. | -| Assistant reasoning | Append the reasoning part to the pending group. | -| Assistant visible reply | Close the group and emit the response part independently. Later tools enter a later group. | -| Other process Nodes | Append the Node to the pending group; an ordinary Step boundary does not close it. | -| Turn end | Close that Turn's pending groups even without Node upserts. | - -Content growth with unchanged classification does not resegment history; it may update only the current summary. A first reply, new tool, steering, or retry can change structure. Visibility/location repair handles both old and new ownership. Real structural changes may linearly merge root references; the interface does not promise all operations are constant-time. ## React reading and presentation -The root selects `views.grouped('chat')?.entries` through the existing useConversation source. Keyed Group hooks use existing injection; each Group seat selects members, while its future header independently selects data. Members use existing keyed Node sources. Removed Group sources can read undefined before the root removes their components, so selectors tolerate absence and keep Hook order stable. +The root selects `views.grouped('chat')?.entries` through the existing useConversation source. Keyed Group hooks use existing injection; each Group seat selects members. Members use existing keyed Node sources. Removed Group sources can read undefined before the root removes their components, so selectors tolerate absence and keep Hook order stable. -React keys derive from reference kind, NodeKey/groupPart or GroupKey, using unambiguous tuples. No separate RenderKey type or renderer dispatch field is needed. Compact, Detailed, and Expanded keep the same group container and member parents; modes change header visibility, body visibility, and styles only. +React keys derive from reference kind, NodeKey/groupPart or GroupKey, using unambiguous tuples. No separate RenderKey type or renderer dispatch field is needed. Compact, Detailed, and Expanded keep the same group container and member parents; presentation changes do not rerun the Group Definition or select a different root branch. The stable Group parent is a `div` with `display: contents`, without its own layout box. CSS inheritance remains available; business styles adapt child/sibling selectors and own measurable body containers. Neither CSS variables nor geometry enter the Definition. Reading-position capture measures member Nodes and retains part-specific anchors; existing Turn navigation resolves the original NodeKey to its first visible part. -| Mode | Group header | Body | -|---|---|---| -| Compact | Short activity summary | Group open/closed state controls visibility. | -| Detailed | Summary plus live command/path/query details | Same group open/closed state. | -| Expanded | Hidden | Visible without group-level collapse or height cap; wrapper remains. | - -This assumes the outer Turn process is open. Expanded retains whole-Turn folding and individual tool/reasoning disclosure; it must not eagerly mount all full Markdown. Turn-close resets of internal state are explicit business actions, not key changes. Group headers, scrolling/fades, and Turn interaction belong to the business phase. - ## Alternatives considered **Builder-owned buildGroups or process projector.** Rejected: the target aggregator would still own business rules. Moving caches into infrastructure does not change that ownership. @@ -164,22 +137,13 @@ This assumes the outer Turn process is open. Expanded retains whole-Turn folding **Remove the group wrapper in Expanded.** Rejected: changing the parent remounts members even when their keys survive. -## Acceptance criteria +## Verification -1. Builder, assembler, and GroupStore contain no Chat segmentation/category rules or concrete grouping class construction. -2. Group State owns membership, segmentation, summaries, and business invalidation. -3. Ungrouped targets retain their path; duplicate registration and missing input fail without eager Builder construction. -4. Group input carries final projected Nodes, previous/current values, stable order, and lifecycle-only Turn changes. -5. Data-only upserts preserve entries and members, avoid unrelated scans, and notify only changed group sources. -6. All related Nodes, Groups, and Location data are installed before publication; invalid or overlapping references fail before Group installation. -7. First activation, append, settlement, replacement, prepend, and registry reconstruction use the common grouping path. -8. Registration replacement/removal leaves no old Group context or observed output; replacement Node stores rebind mounted members. -9. React renders only node/group root kinds and retains keys and parents across modes; local state survives mode changes. -10. Data types remain correlated through Definition, store, keyed reading, and business rendering. -11. Empty versus omitted entries, Group removal versus Node retention, and undefined removed-group sources retain their documented distinctions. -12. The separate business phase covers replies, steering, retries, no-Turn rows, Turn closure, summaries, and all three modes before claiming PR #4565 behavior complete. +- [Group store tests](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts) cover atomic reference validation, root/member identity reuse, local publication, and removal without deleting source Nodes. +- [Grouping dispatch tests](../../../../packages/client/ui-conversation/tests/conversation-groups.client.spec.ts) cover first activation, lifecycle-only input, registry replacement, View removal/recovery, and complete replacement output. [Assembler tests](../../../../packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts) cover changed-Turn reporting and Location data sources. +- [Chat rendering tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) retain component state across modes, rebind replacement Node stores, pass independent parts, and omit unreferenced Nodes. [Viewport tests](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts) cover grouped reading anchors, history prepend, and part-aware Turn navigation. -## Risks +## Consequences - This is an explicit framework extension: a new input protocol, registry, context, publication phase, and reader. It is not merely another callback. - Target-local State does not automatically make business updates local. The Definition must distinguish content growth from structural changes and index the affected ranges. diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md similarity index 72% rename from .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md rename to .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md index 6736a65e24..ebef2df31b 100644 --- a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md @@ -1,6 +1,6 @@ # Agent Note: Definition 拥有分组业务,框架统一调度 Node/Group 输出 -Status: proposed +Status: implemented [English](2026-09-21-conversation-build-groups.md) | 中文 @@ -12,11 +12,11 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat 过程组与 Step 正交:同一个 Assistant Node 可以把推理放在组内、回复放在组外,回复后的工具进入后面的组。Step 编号范围无法描述这种成员归属。现有事件 Definition 逐个解释事件,分组则需要这些 Definition 已经解释完成的节点。 -## Proposal +## Decision -基础阶段实现独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。真实 Chat 分段及 PR #4565 展示仍属于独立业务阶段;该阶段完成前,本记录保留 proposed 状态。[子系统参考](../../../../docs/subsystems/conversation.zh.md#group-definitions)与[源码类型](../../../../packages/client/ui-conversation/src/client/contract/groups.ts)拥有当前 API 细节。 +分组基础机制提供独立的节点输入 `ConversationGroupDefinition`、注册表及 Session 内上下文、Builder 通用输入与发布、按键 Group 存储及两种 React 分支。基础机制不注册真实 Chat 分段及 PR #4565 展示,[业务迁移提案](../../proposed/feature/2026-09-20-chat-work-details-migration.zh.md)保留这些需求。[子系统参考](../../../../docs/subsystems/conversation.zh.md#group-definitions)与[源码类型](../../../../packages/client/ui-conversation/src/client/contract/groups.ts)拥有当前 API 细节。 -### 已确认的职责 +### 职责 | 要求 | 含义 | |---|---| @@ -32,9 +32,9 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat | 记录 | 关系 | |---|---| -| [业务节点组装](../../implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 | -| [展示策略与过程分组](2026-09-20-chat-presentation-policy-and-step-groups.zh.md) | 保留 Definition 归属、局部订阅和模式无关要求,以新分组机制替代事件 fold 及 Step 范围成员推断。 | -| [工作过程展示迁移](../feature/2026-09-20-chat-work-details-migration.zh.md) | 保留产品范围及迁移跟踪,基础机制本身不代表这些功能完成。 | +| [业务节点组装](2026-08-09-client-conversation-node-assembly.zh.md) | 保留事件匹配、Context、Location、每个 Context 一个业务 Node 及目标 Builder,分组增加另一种输入类别。 | +| [展示策略与过程分组](../../proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md) | 保留 Definition 归属、局部订阅和模式无关要求,以新分组机制替代事件 fold 及 Step 范围成员推断。 | +| [工作过程展示迁移](../../proposed/feature/2026-09-20-chat-work-details-migration.zh.md) | 保留产品范围及迁移跟踪,基础机制本身不代表这些功能完成。 | 这些记录仍有独立理由,保留有效状态。跨 View 导航与 `toolCallFocus` 不属于本次改动;不引入 View 句柄、Label Slot 或资源导航架构。 @@ -59,7 +59,7 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat | 更新 | 含义 | |---|---| -| 省略 `entries` | 保留根顺序。 | +| apply 输入省略 `entries` | 保留根顺序;替换输入必须提供完整序列。 | | 提供 `entries`,包括 `[]` | 替换完整根序列,空数组清空根序列。 | | `groups: { kind: 'replace', snapshots }` | 替换完整组记录,移除未包含的旧组。 | | `groups: { kind: 'apply', upserts, removes }` | upsert 完整快照,只按具名 GroupKey 删除组。 | @@ -108,42 +108,15 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价 | [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、仅成员订阅及嵌套 Node 容器。 | | [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | 既有 Node 来源及渲染器、groupPart 传递、独立部分锚点及 Store 替换时重绑定。 | | [slots.ts](../../../../packages/client/ui-chat/src/client/contract/slots.ts) 与 [apply.ts](../../../../packages/client/ui-chat/src/client/apply.ts) | 使用现有按键钩子注入 Group 来源,不改 Slot 引擎。 | -| 业务阶段:process-groups.ts 与 register.ts | Definition 拥有分段、成员、缓存、摘要及注册。 | -| 业务阶段:AssistantNodeView、Group 容器、stores、navigation | 解释部分内容,组头和正文展示,本地开合状态,Turn/Group 揭示、选择复制及中断提示归属。 | - -## 业务阶段:分段与增量 - -业务 State 接受输入、清空待输出变化并返回可重复读取的结果。replace 构建已加载窗口;apply 区分结构变化和正文更新,维护受影响段或轮次内索引。它拥有开组、关组、稳定 key、摘要和失效规则;通用层不按 Node kind 或 Step 编号再次修正业务结论。 - -| 有序可见输入 | 业务动作 | -|---|---| -| 所属 Turn 改变 | 关闭待完成组。 | -| 没有 Turn 归属 | 关闭组并输出独立 Node,不跨越它合并。 | -| turn-process 控件 | 独立输出,不关闭待完成过程。 | -| 用户、steering、触发、重试、错误、最大 token 或尾部 | 关闭组并独立输出。 | -| Assistant 推理 | 把 reasoning 部分加入待完成组。 | -| Assistant 可见回复 | 关闭组,独立输出 response 部分;后续工具进入后面的组。 | -| 其他过程 Node | 加入待完成组,普通 Step 边界不关闭组。 | -| Turn 结束 | 即使没有节点 upsert,也关闭该轮次待完成的组。 | - -分类未变的正文增长不重新分段历史,必要时只更新当前摘要。首次回复、新工具、steering 或重试可以改变结构。可见性或位置修正同时处理旧、新归属。真实结构变化允许线性合并根引用,接口不承诺全部操作常数时间。 ## React 读取与展示 -根层通过既有 useConversation 来源选择 `views.grouped('chat')?.entries`。按键 Group 钩子沿用现有注入,Group 容器选择 members,后续组头独立选择 data。成员使用既有按键 Node 来源。根列表移除组件前,已删除 Group 来源可能先返回 undefined,因此选择器允许不存在,并保持钩子顺序稳定。 +根层通过既有 useConversation 来源选择 `views.grouped('chat')?.entries`。按键 Group 钩子沿用现有注入,Group 容器选择 members。成员使用既有按键 Node 来源。根列表移除组件前,已删除 Group 来源可能先返回 undefined,因此选择器允许不存在,并保持钩子顺序稳定。 -React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,模式只改变组头、正文显隐及样式。 +React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派生,不另设 RenderKey 类型或 renderer 分发字段。Compact、Detailed、Expanded 保留同一组容器及成员父级,展示变化不重新运行 Group Definition,也不选择不同的根分支。 稳定 Group 父级使用 `div` 与 `display: contents`,自身不产生布局盒子。CSS 继承仍然可用,业务样式适配子级/兄弟选择器并拥有可测量的正文容器。CSS 变量和几何信息都不进入 Definition。阅读位置采样测量成员 Node,保留部分专属锚点;既有轮次导航将原 NodeKey 解析到它的第一个可见部分。 -| 模式 | 组头 | 正文 | -|---|---|---| -| Compact | 简短活动摘要 | 按组开合状态显隐。 | -| Detailed | 摘要及实时命令、路径、查询详情 | 使用同一组开合状态。 | -| Expanded | 隐藏 | 显示,取消组级折叠及限高,容器保留。 | - -以上以外层 Turn 过程已展开为前提。Expanded 保留整轮折叠和单个工具、推理行开合,不能提前挂载全部完整 Markdown。Turn 收起时的内部状态重置是明确业务动作,不通过更换 key 实现。组头、滚动渐隐及整轮联动属于业务阶段。 - ## Alternatives considered **Builder 持有 buildGroups 或过程投影器。** 不采用,目标汇总器仍然拥有业务规则,把缓存移到通用层不能改变归属。 @@ -164,22 +137,13 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派 **Expanded 移除组容器。** 不采用,即使 key 不变,父级改变仍会重挂载成员。 -## Acceptance criteria +## Verification -1. Builder、assembler 和 GroupStore 不含 Chat 分段或类别规则,不固定创建具体分组类。 -2. Group State 拥有成员、分段、摘要和业务失效规则。 -3. 未分组目标保持原路径;重复注册及缺少输入报错,不提前构造 Builder。 -4. 分组输入包含投影后的最终节点、前后值、稳定顺序及仅生命周期的轮次变化。 -5. 仅数据 upsert 保留 entries 和 members,不扫描无关内容,只通知变化组来源。 -6. 相关 Node、Group 和 Location 数据先全部安装再发布;无效及重叠引用在 Group 安装前报错。 -7. 首次激活、append、settlement、replace、prepend 和注册重建使用统一分组路径。 -8. 注册替换或删除不遗留旧 Group 上下文及观察结果;Node 存储替换时重绑定已挂载成员。 -9. React 根层只有 node/group,模式切换保留 key、父级及局部状态。 -10. Definition、存储、按键读取及业务渲染贯穿同一 Data 类型。 -11. 空与省略 entries、删除 Group 与保留 Node、已删除组返回 undefined 均保持协议规定的区别。 -12. 独立业务阶段覆盖回复、steering、重试、无 Turn 行、Turn 结束、摘要和三档展示后,才能宣称 PR #4565 产品行为完成。 +- [Group 存储测试](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts)覆盖原子引用校验、根与成员身份复用、局部发布,以及删除组但保留原 Node。 +- [分组调度测试](../../../../packages/client/ui-conversation/tests/conversation-groups.client.spec.ts)覆盖首次激活、仅生命周期输入、注册替换、View 移除与恢复,以及完整替换输出。[Assembler 测试](../../../../packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts)覆盖变化轮次报告和 Location 数据来源。 +- [Chat 渲染测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)保留模式切换时的组件状态,重绑定替换后的 Node 存储,传递独立部分,并省略未引用 Node。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)覆盖组内阅读锚点、历史前插及部分感知的轮次导航。 -## Risks +## Consequences - 这是明确的框架扩展,包含新输入协议、注册表、上下文、发布阶段及读取器,不只是增加一个回调。 - 目标内 State 不会自动让业务更新局部化,Definition 必须区分正文增长与结构变化,并索引受影响范围。 diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml index e6ea7582a7..2c52ae61da 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md -2026-09-20-chat-presentation-policy-and-step-groups.md: 3057403c8c9055f4fbc631ba0b4ca0bae2fcd2de -2026-09-20-chat-presentation-policy-and-step-groups.zh.md: f5b30bf96e9ca78b1f9eaacee1a62dd7cb3ec3da +2026-09-20-chat-presentation-policy-and-step-groups.md: 65fbb313d57a34a792ed2ec08d0adfb2fdd5f03d +2026-09-20-chat-presentation-policy-and-step-groups.zh.md: 7ac3442476ea34d5127376cfc40d42892403ce47 diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md index 3057403c8c..65fbb313d5 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.md @@ -25,7 +25,7 @@ The three settings, presentation policy channel, and per-Turn folding eligibilit ## Goals and constraints -The [Definition-owned Build Group proposal](2026-09-21-conversation-build-groups.md) supersedes this note's event fold, Builder-materialized group headers, Step-range membership, and changedLocationData proposal. This note retains the presentation-policy and per-Turn eligibility rationale; the grouping implementation below records the superseded route, not the current implementation plan. +The [implemented Definition-owned Build Group foundation](../../implemented/architecture/2026-09-21-conversation-build-groups.md) supersedes this note's event fold, Builder-materialized group headers, Step-range membership, and changedLocationData proposal. This note retains the presentation-policy and per-Turn eligibility rationale; the grouping implementation below records the superseded route, not the current implementation plan. The following decisions were confirmed during design review and are inputs to this proposal. diff --git a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md index f5b30bf96e..7ac3442476 100644 --- a/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md +++ b/.agents/notes/proposed/architecture/2026-09-20-chat-presentation-policy-and-step-groups.zh.md @@ -25,7 +25,7 @@ Chat 的「对话显示」设置在本提案开始时只有两档:`normal` 不 ## 目标与约束 -[Definition 拥有分组的 Build Group 提案](2026-09-21-conversation-build-groups.zh.md)取代本记录中的事件 fold、Builder 物化组头、Step 范围成员推断及 changedLocationData 提案。本记录保留展示策略与按轮折叠资格的理由;下文分组实现记录已被取代的路线,不是当前实施方案。 +[已实现的 Definition 分组基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)取代本记录中的事件 fold、Builder 物化组头、Step 范围成员推断及 changedLocationData 提案。本记录保留展示策略与按轮折叠资格的理由;下文分组实现记录已被取代的路线,不是当前实施方案。 下表是评审阶段已经确认的裁决,本方案不再重新讨论它们。 diff --git a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml deleted file mode 100644 index a11e052d9a..0000000000 --- a/.agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/proposed/architecture/2026-09-21-conversation-build-groups.md -2026-09-21-conversation-build-groups.md: 9d7c233346e04218821719b43cfa8abc21651854 -2026-09-21-conversation-build-groups.zh.md: 6736a65e24b99cb2b3bd3d3bd20cd93a92bb63aa diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml index 1f41262d74..25c2c8df38 100644 --- a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md -2026-09-20-chat-work-details-migration.md: cd5dbe723425d5bed719bbb579305e3f917d0cc5 -2026-09-20-chat-work-details-migration.zh.md: 4055b027fc7e5c710358886361badb88a4e9b13e +2026-09-20-chat-work-details-migration.md: 126b61d32d4a4dbf27cb5997149abc9820d7f881 +2026-09-20-chat-work-details-migration.zh.md: 20727c299307881b67d0662092d269afa9af176c diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md index cd5dbe7234..126b61d32d 100644 --- a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.md @@ -45,7 +45,7 @@ Verify staging against the feature's diff hunks, not whole files. Inspect the wo | Local reasoning-preview subscription | `ReasoningRow` selects its own visibility | Same | Same | Complete | Existing capability | Committed | Pending | | Stable disclosure-row references | Callbacks, icons, and unchanged summaries retain references; full Markdown mounts only on expansion | Same | Same | Complete | Existing capability | Committed | Pending | -All three modes retain whole-Turn folding. `expanded` means removing secondary process groups, not disabling whole-Turn folding or automatically expanding reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically. +All three modes retain whole-Turn folding. `expanded` removes group-level folding, headers, and height limits while retaining group identity and member parents; it does not disable whole-Turn folding or automatically expand reasoning and tool-card bodies. Until grouping is connected, Chat presents `detailed` and `expanded` identically. ### Migration inventory without grouping dependencies @@ -82,11 +82,11 @@ Review batches are independent of implementation and staging status. Review rema ### Requirements retained for the grouping phase -All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. Neither this channel nor the transferred drafts introduce step-group. Experimental code in `feat/chat-step-groups` does not establish completion of these product behaviors or performance requirements. +All items below remain incomplete and retain their full requirements for the turn/step and grouping phase. The [Definition-owned grouping foundation](../../implemented/architecture/2026-09-21-conversation-build-groups.md) is implemented; that does not establish completion of these product behaviors or performance requirements. | Feature | compact | detailed | expanded | Current state | |---|---|---|---|---| -| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows directly without secondary wrappers | Incomplete (grouping phase) | +| Consecutive process groups between replies | Group tools and reasoning, initially collapsed | Same | Show process rows without group-level folding; retain member parents | Incomplete (grouping phase) | | Independent reasoning/reply visibility within one Assistant | Reasoning may join the preceding group; reply stays visible | Same | No secondary group hiding | Incomplete; do not directly copy the original split into two render nodes | | Header category summary | Current category while running; aggregate main categories after completion | Same | No header | Incomplete (grouping phase) | | Running header details | Hidden | Show command, path, query, etc.; reasoning summary when no tool is running | No header | Incomplete (grouping phase) | @@ -97,6 +97,38 @@ All items below remain incomplete and retain their full requirements for the tur Grouping details to verify: completed summaries aggregate at most three categories and use “etc.” beyond three; call counts determine category ordering rather than appearing as header numbers. Detailed-mode live details collapse whitespace and are capped at 160 characters. Titles remain for at least 150ms; retain only the latest update without replaying queued updates. Expanded height is at most the smaller of 400px and half the viewport. Scrollable edges use a 24px fade and preserve interaction with outer scrolling. Retry rows are outside secondary groups. These values describe the original PR's reference behavior, not completed grouping experiments. +### Definition-owned segmentation and presentation + +Business State accepts each input, clears pending changes, and returns repeatable output. Replace builds the loaded window; apply separates structural changes from content updates and maintains affected segments or Turn-local indexes. It owns group opening/closing, stable keys, summaries, and invalidation; generic infrastructure never corrects those decisions by Node kind or Step number. + +| Ordered visible input | Business action | +|---|---| +| Owning Turn changes | Close the pending group. | +| No Turn ownership | Close the group and emit an independent Node; never merge across it. | +| turn-process control | Emit independently without closing the pending process. | +| User, steering, trigger, retry, error, max-tokens, or tail | Close the group and emit independently. | +| Assistant reasoning | Append the reasoning part to the pending group. | +| Assistant visible reply | Close the group and emit the response part independently. Later tools enter a later group. | +| Other process Nodes | Append the Node to the pending group; an ordinary Step boundary does not close it. | +| Turn end | Close that Turn's pending groups even without Node upserts. | + +Content growth with unchanged classification does not resegment history; it may update only the current summary. A first reply, new tool, steering, or retry can change structure. Visibility/location repair handles both old and new ownership. Real structural changes may linearly merge root references; the interface does not promise all operations are constant-time. + +Group headers select data independently from member arrays. All modes retain the same Group container and member parents: + +| Mode | Group header | Body | +|---|---|---| +| Compact | Short activity summary | Group open/closed state controls visibility. | +| Detailed | Summary plus live command/path/query details | Same group open/closed state. | +| Expanded | Hidden | Visible without group-level collapse or height cap; wrapper remains. | + +This assumes the outer Turn process is open. Expanded retains whole-Turn folding and individual tool/reasoning disclosure; it must not eagerly mount all full Markdown. Turn-close resets of internal state are explicit business actions, not key changes. Group headers, scrolling/fades, and Turn interaction belong to this business integration. + +| Business implementation | Responsibility | +|---|---| +| process-groups.ts and register.ts | Definition-owned segmentation, membership, caches, summaries, and registration. | +| AssistantNodeView, Group seat, stores, navigation | Interpret parts; group header/body presentation; local open state; Turn/group reveal, selection/copy and interruption ownership. | + ## Performance constraints | Scenario | Property to preserve | Required evidence | diff --git a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md index 4055b027fc..20727c2993 100644 --- a/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md +++ b/.agents/notes/proposed/feature/2026-09-20-chat-work-details-migration.zh.md @@ -45,7 +45,7 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发 | 推理预览的局部订阅 | `ReasoningRow` 选择自己的可见性 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 | | 折叠行引用稳定 | 回调、图标及未变化的摘要保持引用;完整 Markdown 仅在展开时挂载 | 相同 | 相同 | 已完成 | 当前代码已有 | 已提交 | 待验收 | -三档都保留整轮折叠。`expanded` 的产品含义是取消二级过程分组,并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。 +三档都保留整轮折叠。`expanded` 取消组级折叠、组头和限高,保留组身份与成员父级;并非禁止整轮折叠,也不是自动展开每个思考或工具卡的正文。分组尚未接入时,`detailed` 与 `expanded` 的 Chat 展示相同。 ### 不依赖分组的迁移清单 @@ -82,11 +82,11 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发 ### 分组阶段保留的需求 -这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。当前通道和本次搬迁草稿均未引入 step-group;`feat/chat-step-groups` 的实验代码不代表这些产品行为或性能要求已经完成。 +这些项目均未完成,继续保留全部需求,后续在 turn/step 与分组阶段处理。[Definition 拥有分组的基础机制](../../implemented/architecture/2026-09-21-conversation-build-groups.zh.md)已经实现,但不代表这些产品行为或性能要求已经完成。 | 功能 | compact | detailed | expanded | 当前状态 | |---|---|---|---|---| -| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行直接展示,不使用二级分组包装 | 未完成(分组阶段) | +| 回复之间的连续过程分组 | 工具与推理归组,默认收起 | 相同 | 过程行不做组级折叠,保留成员父级 | 未完成(分组阶段) | | 同一 Assistant 中推理与回复的分别显隐 | 推理可归入前组,回复外露 | 相同 | 不做二级分组隐藏 | 未完成;不直接复制原版拆成两个渲染节点的实现 | | 组头类别摘要 | 运行时显示当前类别,完成后聚合主要类别 | 相同 | 无组头 | 未完成(分组阶段) | | 运行中组头详情 | 不显示 | 显示命令、路径、查询等详情,无运行工具时显示推理摘要 | 无组头 | 未完成(分组阶段) | @@ -97,6 +97,38 @@ PR #4565 同时包含三档展示设置、过程分组、整轮状态、触发 分组产品细节的核对项:完成摘要最多聚合前三类,超过三类使用「等」;调用计数用于分类排序,不直接显示为组头数字。详细模式的实时详情合并空白并限制为 160 个字符;标题最短展示时间为 150ms,只保留最新更新,不补播积压项。展开区高度不超过 400px 与半屏中的较小值,可滚动方向显示 24px 渐隐,并保留与外层滚动的衔接。重试行不属于二级过程组。这些数值是原 PR 的行为参考,不代表当前分组实验已经实现。 +### Definition 拥有的分段与展示 + +业务 State 接受输入、清空待输出变化并返回可重复读取的结果。replace 构建已加载窗口;apply 区分结构变化和正文更新,维护受影响段或轮次内索引。它拥有开组、关组、稳定 key、摘要和失效规则;通用层不按 Node kind 或 Step 编号再次修正业务结论。 + +| 有序可见输入 | 业务动作 | +|---|---| +| 所属 Turn 改变 | 关闭待完成组。 | +| 没有 Turn 归属 | 关闭组并输出独立 Node,不跨越它合并。 | +| turn-process 控件 | 独立输出,不关闭待完成过程。 | +| 用户、steering、触发、重试、错误、最大 token 或尾部 | 关闭组并独立输出。 | +| Assistant 推理 | 把 reasoning 部分加入待完成组。 | +| Assistant 可见回复 | 关闭组,独立输出 response 部分;后续工具进入后面的组。 | +| 其他过程 Node | 加入待完成组,普通 Step 边界不关闭组。 | +| Turn 结束 | 即使没有节点 upsert,也关闭该轮次待完成的组。 | + +分类未变的正文增长不重新分段历史,必要时只更新当前摘要。首次回复、新工具、steering 或重试可以改变结构。可见性或位置修正同时处理旧、新归属。真实结构变化允许线性合并根引用,接口不承诺全部操作常数时间。 + +组头独立于成员数组选择 data,所有模式保留同一 Group 容器及成员父级: + +| 模式 | 组头 | 正文 | +|---|---|---| +| Compact | 简短活动摘要 | 按组开合状态显隐。 | +| Detailed | 摘要及实时命令、路径、查询详情 | 使用同一组开合状态。 | +| Expanded | 隐藏 | 显示,取消组级折叠及限高,容器保留。 | + +以上以外层 Turn 过程已展开为前提。Expanded 保留整轮折叠和单个工具、推理行开合,不能提前挂载全部完整 Markdown。Turn 收起时的内部状态重置是明确业务动作,不通过更换 key 实现。组头、滚动渐隐及整轮联动属于本业务接入。 + +| 业务实现 | 职责 | +|---|---| +| process-groups.ts 与 register.ts | Definition 拥有分段、成员、缓存、摘要及注册。 | +| AssistantNodeView、Group 容器、stores、navigation | 解释部分内容,组头和正文展示,本地开合状态,Turn/Group 揭示、选择复制及中断提示归属。 | + ## 性能约束 | 场景 | 要保留的性质 | 所需证据 | From 440070e4f8dfb1917c1a0a83385c6a4a52e266f6 Mon Sep 17 00:00:00 2001 From: yixiangihsiang Date: Mon, 21 Sep 2026 13:51:53 +0800 Subject: [PATCH 19/20] fix(web): address diff preview review findings --- apps/web/tests/changed-files-turn.e2e.ts | 16 ++++++ .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 +- packages/client/ui-deliverables/README.zh.md | 2 +- .../src/client/ChangedFiles.module.css | 5 +- .../src/client/ChangedFiles.tsx | 11 ++-- .../src/client/Deliverables.tsx | 2 +- .../src/client/FileDiff.module.css | 28 ++++++++++ .../ui-deliverables/src/client/FileDiff.tsx | 4 +- .../src/client/ReviewTab.module.css | 30 +---------- .../ui-deliverables/src/client/ReviewTab.tsx | 9 ++-- .../tests/changed-files-hover.client.spec.tsx | 14 ++++- .../tests/deliverables.client.spec.tsx | 23 +++++++- .../client/ui-primitives/README.i18n.yaml | 4 +- packages/client/ui-primitives/README.md | 2 +- packages/client/ui-primitives/README.zh.md | 2 +- .../ui-primitives/src/HoverCard.module.css | 1 - .../client/ui-primitives/src/HoverCard.tsx | 53 ++++++++++--------- .../tests/hover-card.client.spec.tsx | 39 ++++++++++++++ 19 files changed, 173 insertions(+), 78 deletions(-) create mode 100644 packages/client/ui-deliverables/src/client/FileDiff.module.css diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 6a5d394cc1..a3691df3c2 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -221,6 +221,8 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { expect(geometry.width).toBe(geometry.cardWidth - 48) expect(geometry.height).toBeLessThanOrEqual(420) const path = preview.locator('[data-changes-preview-path]') + expect(await row.evaluate(element => document.getElementById(element.getAttribute('aria-describedby')!)?.textContent)).toBe(await path.textContent()) + expect(await path.getAttribute('tabindex')).toBeNull() const pathStyle = await path.evaluate((element) => { const style = getComputedStyle(element) return { family: style.fontFamily, overflow: style.overflowX, whiteSpace: style.whiteSpace, ellipsis: style.textOverflow } @@ -249,11 +251,25 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { await preview.locator('[data-diff-code]').first().waitFor({ state: 'visible' }) expect(await preview.locator('[data-review-view="unified"]').count()).toBe(1) expect(await preview.locator('[data-diff-note]').isVisible()).toBe(false) + // Empty comparisons keep their status; only metadata accompanying code is hidden. + await preview.locator('[data-diff-note]').evaluate((element) => { element.setAttribute('data-diff-note', 'empty') }) + expect(await preview.locator('[data-diff-note]').isVisible()).toBe(true) + await preview.locator('[data-diff-note]').evaluate((element) => { element.setAttribute('data-diff-note', 'metadata') }) expect(await preview.locator('[data-diff-hunk-header]').isVisible()).toBe(false) await expect.poll(() => preview.evaluate(element => Number(getComputedStyle(element.parentElement!).opacity))).toBe(1) expect(await hoverOpacityTransition(page, true)).toEqual({ duration: 100, opacity: 0.5 }) await preview.waitFor({ state: 'detached' }) + for (const key of ['Enter', 'Space']) { + await page.mouse.move(0, 0) + await row.focus() + await row.hover() + await preview.locator('[data-review-view="unified"]').waitFor({ state: 'visible' }) + await row.press(key) + await preview.waitFor({ state: 'detached' }) + await review.locator('[data-review-file="notes.txt"]').waitFor({ state: 'visible' }) + } expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) }) it('reviews a shell-appended file from the snapshots and an ignored file from its captured copies in one tab', async () => { diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index e10d0408ba..84e8bd43ae 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 17b0d77b365a7198ff81dac4c51adddf94957859 -README.zh.md: 0399a355cb9ecee78ee992df3b6a614b9e9ceb86 +README.md: 7bf55c474250b8e389a1767092be0f82b4073222 +README.zh.md: d8b1fc368487d96091b400c6034d159989352192 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 17b0d77b36..7bf55c4742 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -38,7 +38,7 @@ The `present` tool row retains its document icon and recorded result across prog Developer tools must be enabled to show this card or request its summary. Turning the preference off removes the card immediately while explicit delivery cards, inline file links, and recorded workspace changes remain available. -The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count and summed added and deleted lines; hovering or focusing it replaces the counts with “Preview in sidebar”. Each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Four rows show before a fold; the control reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. Hovering a file row for 500ms opens a scrollable, single-column preview of the comparison, independent of the Sidebar’s view. Its full path uses muted monospace text and scrolls horizontally when needed. File-status notes and hunk headers are hidden in the preview. The preview is 48px narrower than the card, inset 24px on each side, and at most 420px high, further constrained by the viewport. The whole preview fades in and out over 100ms. Moving into the preview keeps it open; leaving it or pressing Escape dismisses it. Comparisons load only when the preview opens and share the review tab’s cache and retry states. +The card renders the summary the Host serves for the turn's latest `workspace/changes` announcement, read once per announcement through the authenticated summary route; while the read is pending, once the Host answers that the summary is gone, or when it lists no file, there is no card. Its header names the complete changed-file count and summed added and deleted lines; hovering or focusing it replaces the counts with “Preview in sidebar”. Each row shows one file's display path with its own counts, “binary” for a binary file, or “too large” for a file the Host did not capture. Rows appear in the recorded display order, so repository files above the working directory and files outside it sort first. Four rows show before a fold; the control reveals every recorded file and, once expanded, collapses the list again from the bottom. Each row opens the turn's review in the right Sidebar on that file, and the header opens it on the first file. Hovering a file row for 500ms opens a scrollable, single-column preview of the comparison, independent of the Sidebar’s view. Its full path uses muted monospace text and scrolls horizontally when needed. File-status notes accompanying code and hunk headers are hidden in the preview; comparisons without hunks retain their status text. Each row exposes the full path as its accessible description. The preview is 48px narrower than the card, inset 24px on each side, and at most 420px high, further constrained by the viewport. The whole preview fades in and out over 100ms. Moving into the preview keeps it open; leaving it, pressing Escape, or activating the row by pointer or keyboard dismisses it. Comparisons load only when the preview opens and share the review tab’s cache and retry states. ### The review tab diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 0399a355cb..d8b1fc3684 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -38,7 +38,7 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present`,用于声明 只有开启开发者工具才展示此卡片并请求其摘要。关闭该偏好会立即隐藏卡片,同时保留显式交付卡片、行内文件链接和已记录的工作区改动。 -卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计;鼠标悬停或键盘聚焦时,统计信息替换为“在侧边栏预览”。每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示四行;控件展开全部记录文件,展开后从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。 在文件行上悬停 500ms 后,会出现可滚动的单列改动浮层,不受 Sidebar 视图设置影响。顶部完整路径使用较浅的等宽字体,超出宽度时可横向滚动。浮层不显示文件状态说明和 hunk 头。浮层比卡片窄 48px,左右各内缩 24px,高度最多 420px,并受视口可用空间限制。整个浮层的淡入和淡出各持续 100ms。鼠标移入浮层后保持打开,移出或按 Escape 关闭。对比只在浮层打开时加载,与 review tab 共用缓存及重试状态。 +卡片渲染 Host 为本轮最新一条 `workspace/changes` 宣告提供的摘要,每条宣告通过经过认证的摘要路由读取一次;读取尚未完成、Host 答复摘要已不存在,或摘要没有列出任何文件时,没有卡片。标题给出改动文件总数与增删行数合计;鼠标悬停或键盘聚焦时,统计信息替换为“在侧边栏预览”。每一行显示一个文件的展示路径及其增删行数,二进制文件显示“二进制”,Host 没有捕获的文件显示“过大”。行按记录的展示顺序排列,因此仓库内位于工作目录之上的文件与工作区外的文件排在最前。折叠前显示四行;控件展开全部记录文件,展开后从底部收起列表。每一行在右侧 Sidebar 中打开本轮的 review 并选中该文件,标题则在第一个文件上打开它。 在文件行上悬停 500ms 后,会出现可滚动的单列改动浮层,不受 Sidebar 视图设置影响。顶部完整路径使用较浅的等宽字体,超出宽度时可横向滚动。浮层不显示代码旁的文件状态说明和 hunk 头;没有 hunk 的对比仍显示状态文字。每行通过无障碍描述提供完整路径。浮层比卡片窄 48px,左右各内缩 24px,高度最多 420px,并受视口可用空间限制。整个浮层的淡入和淡出各持续 100ms。鼠标移入浮层后保持打开;移出、按 Escape 或通过鼠标和键盘激活文件行会关闭浮层。对比只在浮层打开时加载,与 review tab 共用缓存及重试状态。 ### review tab diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css index 7ec998b044..0aa5f6558f 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.module.css +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.module.css @@ -30,8 +30,7 @@ button.header:disabled { cursor: progress; } .toggle svg { flex: none; width: 14px; height: 14px; } @media (pointer: coarse) { .row, .toggle { min-height: 44px; } } -.preview { height: auto; max-height: 100%; overflow: hidden; } +.preview { max-height: 100%; overflow: hidden; } .previewPath { flex: 1 1 auto; min-width: 0; overflow-x: auto; overflow-y: hidden; color: var(--dsw-alias-label-tertiary); font-family: var(--ds-font-family-code); font-size: 12px; line-height: 20px; white-space: nowrap; } -.previewPath:focus-visible { outline: 1px solid var(--dsw-alias-brand-primary); outline-offset: -1px; } -.preview [data-diff-note], .preview [data-diff-hunk-header] { display: none; } +.preview [data-diff-note='metadata'], .preview [data-diff-hunk-header] { display: none; } diff --git a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx index 79b8bb6e48..5e06f5111c 100644 --- a/packages/client/ui-deliverables/src/client/ChangedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ChangedFiles.tsx @@ -1,12 +1,12 @@ /** The changed-files card: a header and per-file rows that open the turn's review, and a four-row fold. */ -import { useEffect, useRef, useState } from 'react' +import { useEffect, useId, useRef, useState } from 'react' import { resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path' import { HoverCard, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsLocale, SessionStandardProps } from '@deepseek-ai/dsh-client-ui-slots' import { changesDiffUrl, type ChangesSummary } from '../changes.ts' import type { DeliverablesInjected } from './Deliverables.tsx' import { FileDiff } from './FileDiff.tsx' -import reviewCss from './ReviewTab.module.css' +import diffCss from './FileDiff.module.css' import { IconCodeBracketsOutline16 } from './icons.tsx' import type { NS } from './locales.ts' import css from './ChangedFiles.module.css' @@ -42,6 +42,7 @@ export function ChangedFiles({ changes, cwd, openReview, t, sessionId, useChange openReview: (index: number) => void } & PropsLocale & PreviewProps) { const cardRef = useRef(null) + const pathDescriptionId = useId() const [expanded, setExpanded] = useState(false) const foldable = changes.files.length > COLLAPSED_ROWS const rows = foldable && !expanded ? changes.files.slice(0, COLLAPSED_ROWS) : changes.files @@ -66,6 +67,7 @@ export function ChangedFiles({ changes, cwd, openReview, t, sessionId, useChange display={resolveWorkspacePath(cwd, file.path)} useChangesDiff={useChangesDiff} loadChangesDiff={loadChangesDiff} t={t} />} anchor={} /> + ))} @@ -97,8 +100,8 @@ function ChangedFilePreview({ sessionId, seq, index, display, useChangesDiff, lo useEffect(() => { if (state === undefined) void loadChangesDiff(sessionId, seq, index) }, [state, sessionId, seq, index, loadChangesDiff]) - return
-
{display}
+ return
+
{display}
{ void loadChangesDiff(sessionId, seq, index) }} />
} diff --git a/packages/client/ui-deliverables/src/client/Deliverables.tsx b/packages/client/ui-deliverables/src/client/Deliverables.tsx index 5bcdb34946..544e7a82b2 100644 --- a/packages/client/ui-deliverables/src/client/Deliverables.tsx +++ b/packages/client/ui-deliverables/src/client/Deliverables.tsx @@ -92,7 +92,7 @@ export function Deliverables({ if (host === null) void reloadPresentedHost() }, [host, reloadPresentedHost]) return <> - {changes !== null && { openChangesReview({ sessionId, seq: changes.seq, turn: changes.turn }, index) }} />} {matched.presented.length > 0 &&
hunks.map(hunk => hunkHighlights(hunk, highlighter)), [hunks, highlighter]) return (
- {note !== undefined &&

{t(note)}

} + {note !== undefined &&

{t(note)}

} {diff.coarse &&

{t('diff.coarse')}

} {truncated &&

{t('diff.truncated', { count: String(MAX_RENDERED_LINES) })}

} {split && !wrap ? : hunks.map((hunk, position) => { diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index 8d9fd9ede4..28fb9b62f4 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -1,7 +1,5 @@ -/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns clip their lines and scroll together on both axes; wrapped ones share rows so the sides stay aligned. */ -.root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 8%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 8%, transparent); --diff-empty-fill: color-mix(in srgb, var(--dsw-alias-interactive-bg-hover) 50%, transparent); display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } -:global(body[data-ds-dark-theme]) .root { --diff-add-fill: color-mix(in srgb, var(--dsw-alias-state-success-primary) 12%, transparent); --diff-del-fill: color-mix(in srgb, var(--dsw-alias-state-error-primary) 12%, transparent); } -.header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } +/** Review tab sizing, file selector, and file action controls. */ +.root { height: 100%; } .selector { flex: 0 1 auto; min-width: 0; } .selectorLabel { flex: 1 1 auto; min-width: 0; overflow: hidden; font-size: 12px; text-overflow: ellipsis; white-space: nowrap; } .selectorButton { display: inline-flex; gap: 4px; align-items: center; box-sizing: border-box; max-width: 100%; height: 28px; padding: 0 6px 0 8px; border: 0; border-radius: 8px; background: transparent; color: var(--dsw-alias-label-primary); cursor: pointer; font: inherit; font-size: 12px; line-height: 20px; } @@ -22,27 +20,3 @@ .tool[aria-pressed='true'] .compareIcon { transform: rotate(90deg); } .tool:disabled { cursor: progress; } .tool[data-error] { color: var(--dsw-alias-state-error-primary); } -.status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); } -.body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); } -.columns { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 0; } -.column { min-width: 0; overflow-x: scroll; } -.column + .column { border-left: 0.5px solid var(--dsw-alias-border-l3); } -.sideLine { display: grid; grid-template-columns: 3.5em max-content; box-sizing: border-box; min-width: 100%; width: max-content; min-height: 22px; line-height: 22px; white-space: pre; } -.note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); } -.hunk { margin-bottom: 8px; } -.hunkHeader { padding: 4px 16px; color: var(--dsw-alias-label-tertiary); white-space: pre; } -.line { display: grid; grid-template-columns: 3.5em 3.5em 1.2em minmax(0, 1fr); box-sizing: border-box; min-height: 22px; line-height: 22px; white-space: pre; } -.splitLine { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 22px; line-height: 22px; white-space: pre; } -.cell { display: grid; grid-template-columns: 3.5em minmax(0, 1fr); box-sizing: border-box; min-width: 0; } -.cell + .cell { border-left: 0.5px solid var(--dsw-alias-border-l3); } -.number { padding-right: 8px; color: var(--dsw-alias-label-tertiary); text-align: right; user-select: none; } -.sign { text-align: center; user-select: none; } -.text { padding-right: 16px; } -.body[data-review-wrap] .line, .body[data-review-wrap] .splitLine { white-space: pre-wrap; } -.body[data-review-wrap] .text { overflow-wrap: anywhere; } -.add { background: var(--diff-add-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-success-primary); } -.add .sign, .add .text { color: var(--dsw-alias-state-success-primary); } -.del { background: var(--diff-del-fill); box-shadow: inset 3px 0 0 var(--dsw-alias-state-error-primary); } -.del .sign, .del .text { color: var(--dsw-alias-state-error-primary); } -.context .text { color: var(--dsw-alias-label-secondary); } -.empty { background: var(--diff-empty-fill); } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 158b2495f5..9574dd7bdf 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -21,6 +21,7 @@ import type { ChangesReviewParams } from './review-definition.ts' import type { createReviewStore } from './review-store.ts' import type { NS } from './locales.ts' import { FileDiff } from './FileDiff.tsx' +import diffCss from './FileDiff.module.css' import css from './ReviewTab.module.css' const GROUPED = new Intl.NumberFormat('en-US') @@ -108,8 +109,8 @@ export function ReviewTab({ const native = host !== null && host !== 'error' && host.available && phase !== 'nativeUnavailable' const summaryState = summary === undefined || summary === 'loading' ? 'loading' : summary === 'missing' ? 'missing' : 'ready' return ( -
-
+
+
{file === undefined ? {t('review.title', { turn: String(coordinates.turn) })} : { setMenuOpen(false) }} @@ -150,8 +151,8 @@ export function ReviewTab({ })}
- {summaryState === 'loading' &&

{t('diff.loading')}

} - {summaryState === 'missing' &&

{t('diff.missing')}

} + {summaryState === 'loading' &&

{t('diff.loading')}

} + {summaryState === 'missing' &&

{t('diff.missing')}

} {file !== undefined && { void loadChangesDiff(sessionId, seq, index) }} />}
diff --git a/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx b/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx index a78c38cbcb..d0fbfe80d9 100644 --- a/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/changed-files-hover.client.spec.tsx @@ -48,6 +48,8 @@ it('reads only after 500ms and renders the selected comparison in a single colum expect(preview.querySelector('[data-diff-line="del"]')?.textContent).toContain('const value = 1') expect(preview.querySelector('[data-diff-line="add"]')?.textContent).toContain('const value = 2') expect(preview.textContent).toContain('/workspace/main.ts') + expect(screen.getByRole('button', { name: 'View changes to main.ts', description: '/workspace/main.ts' })).toBe(row) + expect(preview.querySelector('[data-changes-preview-path]')?.getAttribute('tabindex')).toBeNull() fireEvent.pointerLeave(row) fireEvent.pointerEnter(preview) act(() => { vi.advanceTimersByTime(500) }) @@ -62,6 +64,16 @@ it('reads only after 500ms and renders the selected comparison in a single colum expect(openReview).toHaveBeenCalledExactlyOnceWith(0) }) +it('keeps a status note for a comparison with no hunks', () => { + const { row, diffs } = mount() + diffs.state.set({ [changesDiffUrl(sessionId, 5, 0)]: { + kind: 'text', path: 'main.ts', display: 'main.ts', before: true, after: true, coarse: false, hunks: [], + } }) + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(500) }) + expect(document.querySelector('[data-changes-hover-preview] [data-diff-note="empty"]')?.textContent).toBe('Both sides hold the same lines') +}) + it('cancels a short hover and the pending dwell when unmounted', () => { const { row, view, loadChangesDiff } = mount() fireEvent.pointerEnter(row) @@ -87,7 +99,7 @@ it('previews each row independently, retries failed reads, and closes on Escape' act(() => { vi.advanceTimersByTime(200) }) act(() => { vi.advanceTimersByTime(300) }) expect(document.querySelectorAll('[data-changes-hover-preview]')).toHaveLength(1) - expect(screen.getByText('/workspace/second.ts')).toBeTruthy() + expect(document.querySelector('[data-changes-preview-path]')?.textContent).toBe('/workspace/second.ts') fireEvent.keyDown(screen.getByRole('button', { name: 'View changes to second.ts' }), { key: 'Escape' }) act(() => { vi.advanceTimersByTime(100) }) expect(document.querySelector('[data-changes-hover-preview]')).toBeNull() diff --git a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx index 4756af65c6..2231effb3d 100644 --- a/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/deliverables.client.spec.tsx @@ -602,9 +602,9 @@ describe('ChangedFiles card', () => { expect(within(card).getByText('-326')).toBeTruthy() expect(within(card).getByText('Preview in sidebar')).toBeTruthy() expect(within(card).getAllByRole('listitem')).toHaveLength(4) - expect(within(card).getByText('config/design-token')).toBeTruthy() + expect(within(within(card).getByRole('button', { name: 'View changes to config/design-token' })).getByText('config/design-token')).toBeTruthy() expect(within(card).getByText('+42')).toBeTruthy() - expect(within(card).getByText('src/index.ts')).toBeTruthy() + expect(within(within(card).getByRole('button', { name: 'View changes to src/index.ts' })).getByText('src/index.ts')).toBeTruthy() expect(within(card).queryByText('~/.zshrc')).toBeNull() expect(within(card).getByRole('button', { name: 'Review this turn’s changes in the sidebar' }) .querySelector('svg')?.getAttribute('width')).toBe('10') @@ -621,6 +621,7 @@ describe('ChangedFiles card', () => { expect(within(card).getAllByRole('listitem')).toHaveLength(5) expect(within(card).getByText('binary')).toBeTruthy() expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' }).getAttribute('title')).toBeNull() + expect(within(card).getByRole('button', { name: 'View changes to ~/.zshrc', description: '/home/u/.zshrc' })).toBeTruthy() fireEvent.click(within(card).getByRole('button', { name: 'View changes to ~/.zshrc' })) expect(props.openChangesReview).toHaveBeenLastCalledWith({ sessionId: 'child-session', seq: 5, turn: 1 }, 4) const collapse = within(card).getByRole('button', { name: 'Collapse changed files' }) @@ -648,6 +649,19 @@ describe('ChangedFiles card', () => { expect(view.getByRole('button', { name: '展开全部 5 个改动文件' }).textContent).toContain('全部 5 个文件') }) + it('preserves expanded rows when the served announcement changes', () => { + const summaries = servedStore() + summaries.state.set({ ...summaries.state.getSnapshot(), [changesSummaryUrl(SessionId('child-session'), 6)]: served }) + const { props, openFile, view } = renderCard(new PresentedOpenController(), en, { changes, presented: [] }, summaries) + const card = view.container.querySelector('[data-changed-files]') + fireEvent.click(view.getByRole('button', { name: 'Show all 5 changed files' })) + view.rerender() + expect(view.container.querySelector('[data-changed-files]')).toBe(card) + expect(view.getAllByRole('listitem')).toHaveLength(5) + expect(view.getByRole('button', { name: 'Collapse changed files' }).getAttribute('aria-expanded')).toBe('true') + }) + it('keeps every count in place whatever the native-open gestures of the review tab are doing', () => { const controller = new PresentedOpenController() controller.state.set({ @@ -794,7 +808,12 @@ describe('plugin registration', () => { await tabFace.loadChangesSummary(SessionId('child-session'), 6) expect(tabFace.hooks.changesSummary.getSnapshot()['api/changes.summary?sessionId=child-session&seq=6']).toEqual({ turn: 3, files: [], total: 0, added: 0, deleted: 0 }) fetcher.mockResolvedValueOnce(Response.json({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' })) + await face.loadChangesDiff(SessionId('child-session'), 5, 1) + expect(face.hooks.changesDiff).toBe(tabFace.hooks.changesDiff) + expect(face.hooks.changesDiff.getSnapshot()['api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) + const readsAfterHover = fetcher.mock.calls.length await tabFace.loadChangesDiff(SessionId('child-session'), 5, 1) + expect(fetcher).toHaveBeenCalledTimes(readsAfterHover) expect(tabFace.hooks.changesDiff.getSnapshot()['api/changes.diff?sessionId=child-session&seq=5&index=1']).toEqual({ kind: 'binary', path: 'src/a.ts', display: 'src/a.ts' }) expect(tabFace.hooks.presentedHost).toBe(face.hooks.presentedHost) fetcher.mockResolvedValueOnce(Response.json({ name: 'desktop', available: true, fileManager: 'finder' })) diff --git a/packages/client/ui-primitives/README.i18n.yaml b/packages/client/ui-primitives/README.i18n.yaml index 499ec1d975..cfdfbb6a39 100644 --- a/packages/client/ui-primitives/README.i18n.yaml +++ b/packages/client/ui-primitives/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md -README.md: f639551b1ff008e226a9e9fc42784160c87b41cd -README.zh.md: 8054de52d2bd1dac6a2550e94906f4a15c269a72 +README.md: df0a8984a187473fdc2f76947a83c282dc8744ce +README.zh.md: 468719ee165fc39fae2f6d1d1524c1099b1e8a13 diff --git a/packages/client/ui-primitives/README.md b/packages/client/ui-primitives/README.md index f639551b1f..df0a8984a1 100644 --- a/packages/client/ui-primitives/README.md +++ b/packages/client/ui-primitives/README.md @@ -72,7 +72,7 @@ Writing your own component in your own package is fine when the need is genuinel ### Controls and icons -The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel caps its height at 420px and follows content and anchor resizing; Escape dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. +The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a case-insensitive ordered subsequence of the name; prefix hits rank first, then alignment score, then source order. `Menu.autoFocus` focuses its first enabled item, supports Arrow Up/Down and Home/End navigation, and focuses the first button in the anchor on Escape; it is opt-in for action menus. ### Rendering agent output diff --git a/packages/client/ui-primitives/README.zh.md b/packages/client/ui-primitives/README.zh.md index 8054de52d2..468719ee16 100644 --- a/packages/client/ui-primitives/README.zh.md +++ b/packages/client/ui-primitives/README.zh.md @@ -72,7 +72,7 @@ kind: "package-library" ### 控件与图标 -上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 +上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 `Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一个按钮;操作菜单可显式启用。 ### 渲染 agent 输出 diff --git a/packages/client/ui-primitives/src/HoverCard.module.css b/packages/client/ui-primitives/src/HoverCard.module.css index 551b25c89a..e23a27e11e 100644 --- a/packages/client/ui-primitives/src/HoverCard.module.css +++ b/packages/client/ui-primitives/src/HoverCard.module.css @@ -56,7 +56,6 @@ .preview { display: flex; flex-direction: column; - max-height: 420px; padding: 0; overflow: hidden; border: 0; diff --git a/packages/client/ui-primitives/src/HoverCard.tsx b/packages/client/ui-primitives/src/HoverCard.tsx index b08e5dd5ec..b0c8ae86f8 100644 --- a/packages/client/ui-primitives/src/HoverCard.tsx +++ b/packages/client/ui-primitives/src/HoverCard.tsx @@ -1,13 +1,18 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' -import type { CSSProperties, ReactNode, RefObject } from 'react' +import type { CSSProperties, ReactNode, RefObject, SyntheticEvent } from 'react' import clsx from 'clsx' import { createPortal } from 'react-dom' import { writeClipboard } from './clipboard.ts' import { usePointerGrace } from './pointer-grace.ts' +import { overlayTopMargin } from './overlay-top-margin.ts' import css from './HoverCard.module.css' /** Preview opacity transition and retained lifetime during dismissal. */ const PREVIEW_FADE_MS = 100 +const PREVIEW_MAX_HEIGHT = 420 +const PREVIEW_INSET = 24 +const ANCHOR_GAP = 8 +const VIEWPORT_MARGIN = 8 /** * Render an anchor with a hover-triggered preview card. @@ -16,7 +21,7 @@ const PREVIEW_FADE_MS = 100 * readable and selectable, but it carries no dismissal affordance of its own. * @param props.openDelayMs - hover dwell before the card shows (default 500). * @param props.variant - compact card beside the anchor, or a preview above/below it - * with 24px side insets, a 420px height cap, and 100ms opacity transitions. + * with 24px side insets, a 420px height cap, frame-top clearance, and 100ms opacity transitions. * @param props.widthAnchorRef - optional element whose width and horizontal position size the preview. * @param props.disabled - suppress opening; turning true dismisses an open card. * @param props.copyText - optional primary value copied by activation and @@ -129,20 +134,22 @@ export function HoverCard({ const h = cardRef.current?.offsetHeight ?? 0 if (variant === 'preview') { const bounds = widthAnchorRef?.current?.getBoundingClientRect() ?? r - const width = Math.max(0, Math.min(bounds.width - 48, window.innerWidth - 16)) - const above = Math.max(0, r.top - 16) - const below = Math.max(0, window.innerHeight - r.bottom - 16) - const onTop = above >= Math.min(420, below) - const maxHeight = Math.min(420, onTop ? above : below) + const width = Math.max(0, Math.min(bounds.width - PREVIEW_INSET * 2, window.innerWidth - VIEWPORT_MARGIN * 2)) + const topMargin = overlayTopMargin(VIEWPORT_MARGIN) + const belowTop = Math.max(topMargin, r.bottom + ANCHOR_GAP) + const above = Math.max(0, r.top - ANCHOR_GAP - topMargin) + const below = Math.max(0, window.innerHeight - belowTop - VIEWPORT_MARGIN) + const onTop = above >= Math.min(PREVIEW_MAX_HEIGHT, below) + const maxHeight = Math.min(PREVIEW_MAX_HEIGHT, onTop ? above : below) setPos({ - left: Math.max(8, Math.min(bounds.left + 24, window.innerWidth - width - 8)), - top: onTop ? Math.max(8, r.top - Math.min(h, maxHeight) - 8) : r.bottom + 8, + left: Math.max(VIEWPORT_MARGIN, Math.min(bounds.left + PREVIEW_INSET, window.innerWidth - width - VIEWPORT_MARGIN)), + top: onTop ? Math.max(topMargin, r.top - Math.min(h, maxHeight) - ANCHOR_GAP) : belowTop, width, maxHeight, }) return } - const top = r.top + h > window.innerHeight - 8 ? window.innerHeight - h - 8 : r.top - setPos({ left: r.right + 8, top }) + const top = r.top + h > window.innerHeight - VIEWPORT_MARGIN ? window.innerHeight - h - VIEWPORT_MARGIN : r.top + setPos({ left: r.right + ANCHOR_GAP, top }) } place() const observer = variant === 'preview' && typeof ResizeObserver !== 'undefined' ? new ResizeObserver(place) : null @@ -165,8 +172,8 @@ export function HoverCard({ if (!open || pos === null || variant === 'preview') return /* v8 ignore next -- the card is mounted whenever pos is set, so the ref is attached here. */ const h = cardRef.current?.offsetHeight ?? 0 - if (pos.top + h > window.innerHeight - 8) { - setPos({ left: pos.left, top: window.innerHeight - h - 8 }) + if (pos.top + h > window.innerHeight - VIEWPORT_MARGIN) { + setPos({ left: pos.left, top: window.innerHeight - h - VIEWPORT_MARGIN }) } }, [open, pos, variant]) @@ -185,6 +192,13 @@ export function HoverCard({ } const copyable = copyText !== undefined + const dismissFromAnchor = (event: SyntheticEvent): void => { + // Portal content is a React child, but its clicks and text selection do not activate the anchor. + if (cardRef.current?.contains(event.target as Node)) return + clearTimer() + cancelClose() + close() + } const card = open && pos !== null && (
{ - if (cardRef.current?.contains(e.target as Node)) return - clearTimer() - cancelClose() - close() - }} + onPointerDownCapture={dismissFromAnchor} + onClickCapture={dismissFromAnchor} > {anchor} {open && copyable && {copied ? copiedLabel : ''}} diff --git a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx index 7a2195949f..a71922c828 100644 --- a/packages/client/ui-primitives/tests/hover-card.client.spec.tsx +++ b/packages/client/ui-primitives/tests/hover-card.client.spec.tsx @@ -158,6 +158,45 @@ describe('HoverCard', () => { expect(screen.getByText('card body')).toBeTruthy() }) + it('dismisses on keyboard clicks and cancels a pending hover', () => { + const { anchor, wrapper } = mount({ variant: 'preview' }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(499) }) + fireEvent.click(anchor, { detail: 0 }) + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.queryByText('card body')).toBeNull() + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + fireEvent.click(screen.getByText('card body')) + expect(screen.getByText('card body').parentElement?.hasAttribute('data-closing')).toBe(false) + fireEvent.click(anchor, { detail: 0 }) + expect(screen.getByText('card body').parentElement?.hasAttribute('data-closing')).toBe(true) + act(() => { vi.advanceTimersByTime(100) }) + expect(screen.queryByText('card body')).toBeNull() + expect(vi.getTimerCount()).toBe(0) + }) + + it('keeps previews below the desktop frame clearance', () => { + const prior = document.documentElement.style.getPropertyValue('--dsh-frame-top-clearance') + const priority = document.documentElement.style.getPropertyPriority('--dsh-frame-top-clearance') + document.documentElement.style.setProperty('--dsh-frame-top-clearance', '120px') + try { + const { wrapper } = mount({ variant: 'preview' }) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 600, width: 600, height: 32 }) + fireEvent.pointerEnter(wrapper) + act(() => { vi.advanceTimersByTime(500) }) + const card = screen.getByText('card body').parentElement as HTMLElement + Object.defineProperty(card, 'offsetHeight', { configurable: true, value: 420 }) + wrapper.getBoundingClientRect = () => DOMRect.fromRect({ x: 100, y: 450, width: 600, height: 32 }) + fireEvent(window, new Event('resize')) + expect(card.style.top).toBe('120px') + expect(card.style.maxHeight).toBe('322px') + } finally { + if (prior === '') document.documentElement.style.removeProperty('--dsh-frame-top-clearance') + else document.documentElement.style.setProperty('--dsh-frame-top-clearance', prior, priority) + } + }) + it('pointerleave before the delay cancels the pending open', () => { const { wrapper } = mount() fireEvent.pointerEnter(wrapper) From a67ad912378a9600660e106d0046b42263cbb4f9 Mon Sep 17 00:00:00 2001 From: imccyu Date: Mon, 21 Sep 2026 14:56:14 +0800 Subject: [PATCH 20/20] feat(client): expose indexed positions to group definitions --- ...-09-21-conversation-build-groups.i18n.yaml | 4 +- .../2026-09-21-conversation-build-groups.md | 10 +++-- ...2026-09-21-conversation-build-groups.zh.md | 10 +++-- docs/subsystems/conversation.i18n.yaml | 4 +- docs/subsystems/conversation.md | 5 ++- docs/subsystems/conversation.zh.md | 5 ++- .../chat-snapshot-builder.ts | 43 +++++++++++++++++-- .../tests/chat-node-source.client.spec.ts | 9 +++- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../src/client/contract/groups.ts | 15 ++++++- .../ui-conversation/src/client/index.ts | 2 +- .../tests/conversation-groups.client.spec.ts | 22 ++++++++-- 14 files changed, 109 insertions(+), 28 deletions(-) diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml index 282f61cdcb..d8c8fb934a 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md -2026-09-21-conversation-build-groups.md: ab55de55767cca1da63d4c52a6b1defba1244ad3 -2026-09-21-conversation-build-groups.zh.md: ebef2df31b623125b8aa96ae4a42c5d6a97ec29b +2026-09-21-conversation-build-groups.md: 6591ebea61d8edd06edbaf5f54f2bebd1bd00602 +2026-09-21-conversation-build-groups.zh.md: 70657ded8e04f0badb987ada965aef1184cfc7b0 diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md index ab55de5576..6591ebea61 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.md @@ -46,9 +46,10 @@ These records retain independent rationale and remain active. Cross-View navigat - Registration requires an existing View target, but never constructs a Builder. First activation checks the constructed Builder for `groupInput()`; missing input fails. Ungrouped targets do not require it or allocate Group state. - Each Session owns one derived context per registered Definition. `create()` initializes its State; it is not a `turn/start` event or an event Definition's unique start Match. Definition objects do not hold mutable cross-Session state. - `update(context, input)` returns the adopted State. `buildGroups(context)` materializes pending output without interpreting events again or advancing counts on repeated reads. Replacement input requires a complete root and group replacement so removed Nodes cannot leave dangling references; `null` on apply retains output. -- `replace` supplies target order, synchronous `readNode`, and timeline; `apply` also supplies projected previous/current Nodes and `changedTurns`. The reader is valid during the synchronous call only and must not be retained in State. +- `replace` supplies target order, timeline, and synchronous `readNode`, `readTurn`, and `readPosition` readers; `apply` also supplies projected previous/current Nodes, `changedTurns`, and `changedTurnOrders`. Readers are valid during the synchronous call only and must not be retained in State. - Node data stays in the target Node Store. Content-only updates preserve the order array. Previous values are captured before the Builder installs projected updates, including additional Nodes changed by target projections. - `ConversationLocationIndex` accumulates changed Turns from boundaries and Location data writes. Assembler passes one drained batch to every updated target, including a Turn ending without Node upserts. Direct ungrouped Builder callers may omit this field. +- The target position index supplies `changedTurnOrders` for visible-key, owning-Turn, or immediate-neighbour changes, including both owners of a moved Node and Turns affected by adjacent unscoped Nodes. `readTurn` returns one Turn's visible keys; `readPosition` returns `GroupNodePosition` with the owning Turn and immediate previous/next keys. Turn-only reads omit interruptions by unscoped Nodes; neighbour facts let business segmentation retain them. The index provides positions, not grouping decisions. - The generic GroupDataMap associates target and Data across registration, storage, and reading. Undeclared targets have no group payload type; business consumers do not reconstruct summary types with unknown assertions. ## Node/Group references and updates @@ -80,7 +81,7 @@ GroupStore indexes records and sources by GroupKey, not repeated array searches. 2. The assembler runs existing Node Definition matching, state updates, and required Context replay. 3. Existing immediate/animation-frame/none cadence schedules publication; grouping adds no timer or event subscription. 4. Flush materializes Step then Turn Location data and target Nodes. -5. Builder.replace/apply installs projected Nodes, target order, and indexes, and retains GroupInput without notifying independent sources. +5. Builder.replace/apply installs projected Nodes, target order, and indexes, records position changes, and retains GroupInput with its indexed readers without notifying independent sources. 6. The assembler obtains the target's registered Group Definition context, calls update with builder.groupInput(), and adopts the returned State. 7. It calls buildGroups, validates and installs the result, and installs the target snapshot. 8. After every affected target is installed, it calls Builder.publish, publishes Group sources, and publishes Location data. @@ -103,7 +104,7 @@ Grouping never replays raw events. Node Definitions handle replay first; groupin | [assembler.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembler.ts) | Common first-activation/flush dispatch, context ownership, installation, and publication. | | [location-index.ts](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) | Accumulate changed Turns, including lifecycle-only and Location-data changes. | | [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | Atomic reference validation, keyed sources, array reuse, and local publication. | -| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | Record projected Node deltas and stable order; defer source notification. No process grouping class. | +| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | Record projected Node deltas, target positions, and changed Turn orders; provide indexed readers and defer source notification. No process grouping class. | | [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | Read optional root entries and switch between node/group; fallback to existing Node order. | | [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | Stable group parent, member-only subscription, nested Node seats. | | [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | Existing Node sources/renderers, groupPart forwarding, distinct part anchors, Store-replacement rebind. | @@ -141,12 +142,13 @@ The stable Group parent is a `div` with `display: contents`, without its own lay - [Group store tests](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts) cover atomic reference validation, root/member identity reuse, local publication, and removal without deleting source Nodes. - [Grouping dispatch tests](../../../../packages/client/ui-conversation/tests/conversation-groups.client.spec.ts) cover first activation, lifecycle-only input, registry replacement, View removal/recovery, and complete replacement output. [Assembler tests](../../../../packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts) cover changed-Turn reporting and Location data sources. +- [Node source tests](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts) cover projected grouping inputs, indexed readers, and empty change batches. - [Chat rendering tests](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx) retain component state across modes, rebind replacement Node stores, pass independent parts, and omit unreferenced Nodes. [Viewport tests](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts) cover grouped reading anchors, history prepend, and part-aware Turn navigation. ## Consequences - This is an explicit framework extension: a new input protocol, registry, context, publication phase, and reader. It is not merely another callback. -- Target-local State does not automatically make business updates local. The Definition must distinguish content growth from structural changes and index the affected ranges. +- Target-local State does not automatically make business updates local. Node changes and changed Turn orders let a Definition choose affected groups and ranges. Structural changes still rebuild the target position index over visible order; content-only updates do not rebuild it. Structural grouping output still replaces the complete root-reference array. - Node order and visibility have one owner: the Builder. Grouping must not independently sort raw events or infer membership again in infrastructure. - Stable mounting does not eliminate layout, paint, or retained-memory costs. No measured latency or optimal-performance claim is made. - Real group splits, merges, first-member changes, and pagination repairs can change identity. The mode-switch guarantee does not prohibit those legitimate changes. diff --git a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md index ebef2df31b..70657ded8e 100644 --- a/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md +++ b/.agents/notes/implemented/architecture/2026-09-21-conversation-build-groups.zh.md @@ -46,9 +46,10 @@ Chat Builder 汇总整个目标的节点及索引。在其中固定创建 Chat - 注册要求已有 View 目标,但不构造 Builder。首次激活取得 Builder 后检查 `groupInput()`,缺少输入方法时报错。未分组目标不要求该方法,也不分配 Group 状态。 - 每个 Session 为已注册 Definition 拥有一个派生上下文。`create()` 初始化 State,不是 `turn/start` 事件,也不是事件 Definition 的唯一 start Match。Definition 对象不保存跨 Session 的可变状态。 - `update(context, input)` 返回框架采纳的 State。`buildGroups(context)` 物化待输出结果,不重新解释事件,不因重复读取增加计数。替换输入要求输出完整根序列和组替换,避免已移除节点留下悬挂引用;apply 的 `null` 保留结果。 -- `replace` 提供目标顺序、同步 `readNode` 和时间线;`apply` 还提供投影后的节点前后值及 `changedTurns`。读取函数仅在当前同步调用中有效,不保留到 State。 +- `replace` 提供目标顺序、时间线及同步的 `readNode`、`readTurn`、`readPosition` 读取器;`apply` 还提供投影后的节点前后值、`changedTurns` 和 `changedTurnOrders`。读取器仅在当前同步调用中有效,不保留到 State。 - 节点数据仍在目标 Node Store。仅正文更新保留顺序数组;旧值在 Builder 安装投影结果前记录,也覆盖目标投影额外改变的节点。 - `ConversationLocationIndex` 从边界事件和 Location 数据写入累计变化轮次。assembler 将同一批变化交给全部被更新目标,覆盖没有节点 upsert 的 Turn 结束。直接调用未分组 Builder 的调用方可省略该字段。 +- 目标位置索引通过 `changedTurnOrders` 提供可见键、所属 Turn 或紧邻关系的变化,包含移动 Node 的新旧所属轮次,以及受相邻轮次外 Node 影响的轮次。`readTurn` 返回某个 Turn 的可见键,`readPosition` 返回包含所属 Turn 和前后紧邻键的 `GroupNodePosition`。仅按 Turn 读取会遗漏轮次外 Node 造成的分隔,业务分段可以通过相邻事实保留这些分隔。索引提供位置,不决定分组。 - 通用 GroupDataMap 在注册、存储及读取之间关联目标和 Data。未声明的目标没有组载荷类型,业务消费方不通过 unknown 断言恢复摘要类型。 ## Node/Group 引用与更新 @@ -80,7 +81,7 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价 2. assembler 运行既有 Node Definition 匹配、状态更新及必要的 Context 重放。 3. 既有 immediate/animation-frame/none 节奏调度发布,分组不增加计时器或事件订阅。 4. flush 依次物化 Step、Turn Location 数据及目标 Node。 -5. Builder.replace/apply 安装投影后 Node、目标顺序及索引,保留 GroupInput,不通知独立来源。 +5. Builder.replace/apply 安装投影后 Node、目标顺序及索引,记录位置变化,保留包含索引读取器的 GroupInput,不通知独立来源。 6. assembler 取得目标已注册的 Group Definition 上下文,以 builder.groupInput() 调用 update,采纳返回 State。 7. 调用 buildGroups,校验并安装结果,安装目标快照。 8. 全部受影响目标安装完成后,调用 Builder.publish,发布 Group 来源和 Location 数据。 @@ -103,7 +104,7 @@ GroupStore 按 GroupKey 索引记录和来源,不反复查找数组。等价 | [assembler.ts](../../../../packages/client/ui-conversation/src/client/conversation/assembler.ts) | 首次激活和 flush 共用调度,上下文归属、安装及发布。 | | [location-index.ts](../../../../packages/client/ui-conversation/src/client/conversation/location-index.ts) | 累积变化轮次,覆盖仅生命周期和 Location 数据变化。 | | [group-store.ts](../../../../packages/client/ui-conversation/src/client/conversation/group-store.ts) | 原子引用校验、按键来源、数组复用及局部发布。 | -| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | 记录投影后节点增量及稳定顺序,推迟来源通知,不持有过程分组类。 | +| [chat-snapshot-builder.ts](../../../../packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts) | 记录投影后节点增量、目标位置及变化轮次顺序,提供索引读取器并推迟来源通知,不持有过程分组类。 | | [ChatView.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatView.tsx) | 读取可选根引用并切换 node/group,无分组时使用既有 Node 顺序。 | | [ChatGroupSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatGroupSeat.tsx) | 稳定组父级、仅成员订阅及嵌套 Node 容器。 | | [ChatNodeSeat.tsx](../../../../packages/client/ui-chat/src/client/chat/ChatNodeSeat.tsx) | 既有 Node 来源及渲染器、groupPart 传递、独立部分锚点及 Store 替换时重绑定。 | @@ -141,12 +142,13 @@ React key 由引用 kind、NodeKey/groupPart 或 GroupKey 的无歧义元组派 - [Group 存储测试](../../../../packages/client/ui-conversation/tests/conversation-group-store.client.spec.ts)覆盖原子引用校验、根与成员身份复用、局部发布,以及删除组但保留原 Node。 - [分组调度测试](../../../../packages/client/ui-conversation/tests/conversation-groups.client.spec.ts)覆盖首次激活、仅生命周期输入、注册替换、View 移除与恢复,以及完整替换输出。[Assembler 测试](../../../../packages/client/ui-conversation/tests/conversation-assembler.client.spec.ts)覆盖变化轮次报告和 Location 数据来源。 +- [Node 来源测试](../../../../packages/client/ui-chat/tests/chat-node-source.client.spec.ts)覆盖投影后的分组输入、索引读取器和空变化批次。 - [Chat 渲染测试](../../../../packages/client/ui-chat/tests/chat-view.client.spec.tsx)保留模式切换时的组件状态,重绑定替换后的 Node 存储,传递独立部分,并省略未引用 Node。[视口测试](../../../../packages/client/ui-chat/tests/chat-viewport.client.spec.ts)覆盖组内阅读锚点、历史前插及部分感知的轮次导航。 ## Consequences - 这是明确的框架扩展,包含新输入协议、注册表、上下文、发布阶段及读取器,不只是增加一个回调。 -- 目标内 State 不会自动让业务更新局部化,Definition 必须区分正文增长与结构变化,并索引受影响范围。 +- 目标内 State 不会自动让业务更新局部化,Definition 可以根据节点变化和轮次顺序变化选择受影响的组及范围。结构变化仍按可见顺序重建目标位置索引,仅正文更新不重建该索引。结构性分组输出仍替换完整根引用数组。 - Node 顺序及可见性只有 Builder 一个所有者,分组不独立排序原始事件,也不在通用层再次推断成员。 - 稳定挂载不消除布局、绘制或保留内存的成本,不宣称实测延迟或最优性能。 - 真实组拆并、首成员变化及分页修正可以改变身份,模式切换保证不禁止这些正常变化。 diff --git a/docs/subsystems/conversation.i18n.yaml b/docs/subsystems/conversation.i18n.yaml index e6c640b8fa..56327598b7 100644 --- a/docs/subsystems/conversation.i18n.yaml +++ b/docs/subsystems/conversation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/conversation.md -conversation.md: 29ed4aa85ebdcc9061f33d620e26119fe7a9f927 -conversation.zh.md: 2aba5ab97dd93a5561e4790da87aa72939ebe322 +conversation.md: 9455858acac16584b4d300dc8cd2634eb2a20310 +conversation.zh.md: 2e7c48ab5f65935c2fcb4de05c4484c965622746 diff --git a/docs/subsystems/conversation.md b/docs/subsystems/conversation.md index 29ed4aa85e..9455858aca 100644 --- a/docs/subsystems/conversation.md +++ b/docs/subsystems/conversation.md @@ -37,7 +37,8 @@ The [group types](../../packages/client/ui-conversation/src/client/contract/grou | Type | Meaning | |---|---| | `ConversationGroupDefinition` | `create()` initializes Session-local State; `update(context, input)` returns its next State; `buildGroups(context)` returns pending output or `null`. Replacement input requires entries and a complete group replacement. | -| `ConversationGroupInput` | `replace` supplies the ordered target Node keys, synchronous `readNode`, and timeline. `apply` also supplies projected `previous/current` Node changes and `changedTurns`, including lifecycle-only changes. Do not retain the reader in State. | +| `ConversationGroupInput` | `replace` supplies target order, timeline, and synchronous `readNode`, `readTurn`, and `readPosition` readers. `apply` adds projected `previous/current` Node changes, lifecycle `changedTurns`, and `changedTurnOrders`. Do not retain readers in State. | +| `GroupNodePosition` | Owning Turn, when present, and immediate previous/next visible Node keys. Neighbours preserve interruptions that a Turn-only key list omits. | | `NodeReference` / `GroupReference` | Branded `NodeKey` or `GroupKey`, distinguished by `kind`. A Node reference may select a renderer-owned `groupPart`; omission selects the whole Node. | | `GroupSnapshot` | Immutable group key, business data, and ordered Node references. Groups cannot contain other groups or own source Node data. | | `GroupUpdate` | `entries` replaces the complete root sequence; only apply input may omit it to preserve the sequence. Replacement input requires both `entries` and `groups.replace`. `groups.replace.snapshots` replaces all groups; `groups.apply.upserts/removes` updates only named group records. Removal does not delete Nodes. | @@ -46,6 +47,8 @@ The [group types](../../packages/client/ui-conversation/src/client/contract/grou The Builder exposes `groupInput()` when grouping is registered; missing input fails at first activation. It records the Node values after its own projections and preserves content-only order identity. The assembler supplies changed Turns with every update; direct ungrouped Builder callers may omit them. Builders with independent sources defer their notifications to `publish()`. First activation and ordinary flush share the same sequence: materialize Location data and Nodes, update the Builder, call the Group Definition, validate and install grouping, then publish every affected target and Location source. The group phase reuses the existing publication cadence. +Target position indexes supply the readers and identify Turns whose visible keys or neighbours changed. `changedTurnOrders` includes both owners of a moved Node and Turns adjacent to inserted or removed unscoped Nodes; lifecycle-only changes remain in `changedTurns`. A business Definition can resegment those Turns and refresh only groups containing changed content. Structural output still replaces the complete root-reference array, without requiring unchanged Node contents to be reread. + The Group store validates the whole submitted result before installation: root Group references and records correspond one-to-one, all referenced Nodes exist, and each `(NodeKey, groupPart)` occupies at most one root or member position. A whole Node cannot coexist with one of its parts. Duplicate upserts, duplicate removals, and simultaneous upsert/removal of a group fail. Data-only upserts retain root and member arrays, do not read Nodes, and notify only changed group sources; full replacement revalidates every reference. Renderers switch on the two reference kinds and choose components in the owning View, not through a renderer field in group data. Root Node references and referenced group members form the complete render list; unreferenced target Nodes remain stored but do not render. The group body reads members separately from summary data. Presentation modes must retain component types, keys, and member parents; changing data membership may legitimately remount a moved member. The framework does not interpret part completeness, segmentation, or presentation policies. diff --git a/docs/subsystems/conversation.zh.md b/docs/subsystems/conversation.zh.md index 2aba5ab97d..2e7c48ab5f 100644 --- a/docs/subsystems/conversation.zh.md +++ b/docs/subsystems/conversation.zh.md @@ -37,7 +37,8 @@ shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View ro | 类型 | 含义 | |---|---| | `ConversationGroupDefinition` | `create()` 初始化 Session 内的 State;`update(context, input)` 返回下一份 State;`buildGroups(context)` 返回待发布输出或 `null`。替换输入要求输出 entries 与完整组替换。 | -| `ConversationGroupInput` | `replace` 提供目标节点的有序键、同步 `readNode` 及时间线。`apply` 还提供投影后的 `previous/current` 节点变化和 `changedTurns`,覆盖仅生命周期变化。不要把读取函数留在 State 中。 | +| `ConversationGroupInput` | `replace` 提供目标顺序、时间线及同步的 `readNode`、`readTurn`、`readPosition` 读取器。`apply` 增加投影后的 `previous/current` 节点变化、生命周期 `changedTurns` 和 `changedTurnOrders`。不要把读取器留在 State 中。 | +| `GroupNodePosition` | 所属 Turn(如有)及前后紧邻的可见 Node 键。相邻关系保留仅按 Turn 取键时会遗漏的分隔。 | | `NodeReference` / `GroupReference` | 以 `kind` 区分的品牌类型 `NodeKey` 或 `GroupKey`。Node 引用可选择渲染器拥有的 `groupPart`,省略表示整个 Node。 | | `GroupSnapshot` | 不可变的组键、业务数据和有序 Node 引用。Group 不包含其他 Group,也不拥有原 Node 数据。 | | `GroupUpdate` | `entries` 替换完整根序列,仅 apply 输入允许省略它以保留原序列。替换输入必须同时提供 `entries` 与 `groups.replace`。`groups.replace.snapshots` 替换全部组;`groups.apply.upserts/removes` 只更新具名组记录。删除组不删除 Node。 | @@ -46,6 +47,8 @@ shell 拥有 View 选择,并在 binding 创建、被选为 current 或 View ro 注册分组时 Builder 必须提供 `groupInput()`,缺少输入方法在首次激活时报错。它记录自身投影后的节点值,并在仅正文更新时保留顺序数组身份。assembler 每次更新都提供变化轮次,直接调用未分组 Builder 的调用方可以省略。拥有独立来源的 Builder 把通知推迟到 `publish()`。首次激活与普通 flush 共用同一顺序:物化 Location 数据和 Node,更新 Builder,调用 Group Definition,校验并安装分组,再发布全部受影响目标及 Location 来源。分组阶段复用既有发布节奏。 +目标位置索引提供这些读取器,并标识可见键或相邻关系发生变化的 Turn。`changedTurnOrders` 包含移动 Node 的新旧所属轮次,以及相邻的轮次外 Node 被插入或移除所影响的轮次;仅生命周期变化仍由 `changedTurns` 提供。业务 Definition 可只重分这些轮次,并只刷新包含变化内容的组。结构输出仍替换完整根引用数组,但不要求重读未变化的 Node 内容。 + Group 存储在安装前校验完整提交结果:根 Group 引用与记录一一对应,引用的 Node 全部存在,每个 `(NodeKey, groupPart)` 在根和成员位置中最多占位一次。整 Node 不能与自身任一部分共存。重复 upsert、重复删除以及同时 upsert 和删除一个组都会报错。仅数据 upsert 保留根及成员数组、不读取 Node,并只通知变化组的来源;完整替换重新校验全部引用。 渲染器按两种引用 kind 切换,由所属 View 选择组件,不通过组数据中的 renderer 字段选择。根 Node 引用与被引用组的成员构成完整渲染列表;未被引用的目标 Node 仍保留在存储中,但不渲染。组 body 将成员与摘要数据分开读取。展示模式必须保留组件类型、key 和成员父级;业务数据改变成员归属时,移动成员可以正常重挂载。框架不解释部分内容完备性、分段或展示策略。 diff --git a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts index 7f5844ce2a..85b565a178 100644 --- a/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-chat/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -2,7 +2,7 @@ import type { Context } from '@deepseek-ai/cordis' import { notifySubscribers } from '@deepseek-ai/dsh-client-store' import type { ConversationLocation, ConversationNode, ConversationTimelineSnapshot, ConversationViewBuilder, - ConversationViewDefinition, ConversationGroupInput, NodeChange, NodeKey, PartialAssistant, RunningToolCall, + ConversationViewDefinition, ConversationGroupInput, GroupNodePosition, NodeChange, NodeKey, PartialAssistant, RunningToolCall, } from '@deepseek-ai/dsh-client-ui-conversation/client' import type { ChatConversationViewNode, ChatNode } from '../contract/chat-nodes.ts' import { isRunningTool } from '../contract/chat-nodes.ts' @@ -164,6 +164,7 @@ class MutableChatNodeStore implements ChatNodeStore { class MutableChatLocationIndex implements ChatLocationNodeIndex { private turns = new Map() private steps = new Map() + private positions = new Map() getTurn(turn: number): readonly string[] { return this.turns.get(turn) ?? EMPTY_KEYS @@ -173,13 +174,32 @@ class MutableChatLocationIndex implements ChatLocationNodeIndex { return this.steps.get(stepKey(turn, step)) ?? EMPTY_KEYS } - rebuild(order: readonly string[], store: ChatNodeStore): void { + getPosition(key: string): GroupNodePosition | undefined { + return this.positions.get(key) + } + + rebuild(order: readonly string[], store: ChatNodeStore): readonly number[] { const turns = new Map() const steps = new Map() - for (const key of order) { + const positions = new Map() + const changedTurns = new Set() + for (const [index, key] of order.entries()) { const location = store.get(key)?.location if (location === undefined) continue const coordinates = locationCoordinates(location) + const previous = this.positions.get(key) + const position: GroupNodePosition = { + turn: coordinates.turn, + previous: order[index - 1] as NodeKey | undefined, + next: order[index + 1] as NodeKey | undefined, + } + const unchanged = previous !== undefined && previous.turn === position.turn + && previous.previous === position.previous && previous.next === position.next + positions.set(key, unchanged ? previous : position) + if (!unchanged) { + if (previous?.turn !== undefined) changedTurns.add(previous.turn) + if (position.turn !== undefined) changedTurns.add(position.turn) + } if (coordinates.turn === undefined) continue const turnKeys = turns.get(coordinates.turn) ?? [] turnKeys.push(key) @@ -190,8 +210,13 @@ class MutableChatLocationIndex implements ChatLocationNodeIndex { stepKeys.push(key) steps.set(step, stepKeys) } + for (const [key, position] of this.positions) { + if (!positions.has(key) && position.turn !== undefined) changedTurns.add(position.turn) + } + this.positions = positions this.turns = updateIndex(this.turns, turns) this.steps = updateIndex(this.steps, steps) + return [...changedTurns] } /** Invalidate aggregate readers when member data changes without moving. */ @@ -968,6 +993,8 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder private readonly readGroupNode = (key: NodeKey): ChatConversationViewNode | undefined => this.store.get(key) + private readonly readGroupTurn = (turn: number): readonly NodeKey[] => this.locations.getTurn(turn) as readonly NodeKey[] + private readonly readGroupPosition = (key: NodeKey): GroupNodePosition | undefined => this.locations.getPosition(key) /** Last published timeline: a Turn boundary can land without a new node. */ private timeline: ConversationTimelineSnapshot | null = null readonly empty: ChatSnapshot @@ -978,6 +1005,8 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder node.key) this.order = sameReferences(this.order, next) ? this.order : next - this.locations.rebuild(this.order, this.store) + changedTurnOrders = this.locations.rebuild(this.order, this.store) } this.locations.touch(contentOnly) this.store.updateProcesses(processTurns, this.locations) @@ -1049,8 +1081,11 @@ export class ChatSnapshotBuilder implements ConversationViewBuilder { expect(initial.kind).toBe('replace') expect(initial.order).toBe(snapshot.order) expect(initial.readNode(initial.order[0]!)).toBe(user) + expect(initial.readPosition(initial.order[0]!)).toEqual({ turn: undefined, previous: undefined, next: undefined }) + expect(initial.readTurn(7)).toEqual([]) const context: ChatConversationViewNode = { key: 'context:2', kind: 'context', id: '2', target: 'chat', anchorSeq: 2, location: { kind: 'session' }, visibility: 'visible', @@ -49,13 +51,18 @@ describe('Chat Node keyed sources', () => { if (input.kind !== 'apply') throw new Error('expected apply') expect(input.order).toBe(next.order) expect(input.changedTurns).toEqual([7]) + expect(input.changedTurnOrders).toEqual([]) + expect(input.readPosition(context.key as typeof input.order[number])).toEqual({ + turn: undefined, previous: user.key, next: undefined, + }) + expect(input.readPosition('missing' as typeof input.order[number])).toBeUndefined() expect(input.changes.find(change => change.current.key === user.key)).toEqual({ previous: user, current: next.nodes.get(user.key), }) expect(next.nodes.get(user.key)?.data).toMatchObject({ skillNames: ['demo'] }) expect(input.changes.find(change => change.current.key === context.key)).toEqual({ previous: undefined, current: context }) builder.apply({ upserts: [], timeline }) - expect(builder.groupInput()).toMatchObject({ kind: 'apply', changes: [], changedTurns: [] }) + expect(builder.groupInput()).toMatchObject({ kind: 'apply', changes: [], changedTurns: [], changedTurnOrders: [] }) expect(builder.groupInput().order).toBe(next.order) }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 252ea7674a..60fd5ca07b 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: 41a9baeb7cc370cdb4a9d6702808646a0df38fb3 -README.zh.md: 1b5604f0c0c074ac64a28da61706be5d663fda85 +README.md: 6f115521a090ca32686af417a7ceea0634324c7d +README.zh.md: babf6f5cfb0760b471988cf27017e20b46a85868 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 41a9baeb7c..6f115521a0 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -31,7 +31,7 @@ The adapter passes each `SessionEventLikeEntry` directly to the assembler. Its o A target becomes active when shell selection resolves it or when its source receives a first subscriber. The assembler replaces that target from current Contexts once and keeps it active for later incremental flushes; creating a source does not activate it and unsubscription does not deactivate it. -`UiConversation.groups` registers one optional business Group Definition per existing target. It consumes the Builder's projected Node changes and changed Turns after Node materialization, including first activation, and owns all segmentation and group data. The assembler validates and installs root references and keyed group snapshots before publishing Node, Group, and Location data sources. Ungrouped targets retain their original path. [Grouping](../../../docs/subsystems/conversation.md#group-definitions) defines input lifetime, typed registration, atomic updates, and renderer responsibilities. +`UiConversation.groups` registers one optional business Group Definition per existing target. It consumes projected Node changes, changed Turns, and indexed target positions after Node materialization, including first activation, and owns all segmentation and group data. Indexed Turn reads preserve interruptions from neighbouring Nodes, allowing business updates to stay within affected Turns and groups. The assembler validates and installs root references and keyed group snapshots before publishing Node, Group, and Location data sources. Ungrouped targets retain their original path. [Grouping](../../../docs/subsystems/conversation.md#group-definitions) defines input lifetime, typed registration, atomic updates, and renderer responsibilities. Group registration rejects a missing View target. Removing an already registered View Definition pauses its grouping and clears its published results without removing the Group Definition; registering that View again rebuilds from the current loaded timeline through the existing replacement flow. Switching View tabs does not remove a View Definition. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 1b5604f0c0..babf6f5cfb 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -31,7 +31,7 @@ kind: "package-reference" shell 选择解析出 target 或 target source 收到首个 subscriber 时,该 target 进入 active 状态。assembler 从当前 Context 对它执行一次 replace,并使它参与后续增量 flush;创建 source 不会激活 target,取消订阅也不会停用 target。 -`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费 Builder 投影后的节点变化及变化轮次,覆盖首次激活,并拥有全部分段规则与组数据。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。 +`UiConversation.groups` 为每个已注册目标注册一个可选的业务 Group Definition。它在节点物化后消费投影后的节点变化、变化轮次及已索引的目标位置,覆盖首次激活,并拥有全部分段规则与组数据。按索引读取 Turn 时保留相邻 Node 造成的分隔,使业务更新可以限制在受影响轮次和组内。assembler 先校验并安装根引用和按键索引的组快照,再发布 Node、Group 和 Location 数据来源。未分组目标保持原有路径。[分组](../../../docs/subsystems/conversation.zh.md#group-definitions)定义输入有效期、类型化注册、原子更新及渲染器职责。 Group 注册时缺少 View 目标会报错。已注册的 View Definition 被移除后,其分组计算暂停并清空已发布结果,但保留 Group Definition;重新注册该 View 后,沿现有替换流程从当前已加载时间线重建。切换 View 页签不会移除 View Definition。 diff --git a/packages/client/ui-conversation/src/client/contract/groups.ts b/packages/client/ui-conversation/src/client/contract/groups.ts index 37864617a8..e0fa2735e1 100644 --- a/packages/client/ui-conversation/src/client/contract/groups.ts +++ b/packages/client/ui-conversation/src/client/contract/groups.ts @@ -55,10 +55,21 @@ export interface NodeChange { readonly current: Node } +/** Owning Turn and immediate neighbours in the target's visible Node order. */ +export interface GroupNodePosition { + readonly turn: number | undefined + readonly previous: NodeKey | undefined + readonly next: NodeKey | undefined +} + interface GroupInputNodes { readonly order: readonly NodeKey[] /** @param key - target Node identity. @returns its current value during this synchronous update. */ readonly readNode: (key: NodeKey) => Node | undefined + /** @param turn - owning Turn. @returns its visible keys in target order; empty when absent. */ + readonly readTurn: (turn: number) => readonly NodeKey[] + /** @param key - target Node identity. @returns its visible position, or absence outside the target order. */ + readonly readPosition: (key: NodeKey) => GroupNodePosition | undefined readonly timeline: ConversationTimelineSnapshot } @@ -69,6 +80,8 @@ export type ConversationGroupInput = GroupInputNodes & ( readonly kind: 'apply' readonly changes: readonly NodeChange[] readonly changedTurns: readonly number[] + /** Turns whose visible keys or immediate neighbours changed, including removal and cross-Turn moves. */ + readonly changedTurnOrders: readonly number[] } ) @@ -90,7 +103,7 @@ export interface ConversationGroupDefinition< /** * Consume target Node changes, not raw Session events or presentation modes. * @param context - current Session-local State. - * @param input - current target inputs; readNode is valid only during this call. + * @param input - current target inputs; its readers are valid only during this call. * @returns State adopted by the framework. */ update(context: ConversationGroupContext, input: ConversationGroupInput): State diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index 998169610e..e776311040 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -7,7 +7,7 @@ export type { ConversationGroupRegistry } from './conversation/group-registry.ts export type { ConversationGroupContext, ConversationGroupData, ConversationGroupDataMap, ConversationGroupDefinition, ConversationGroupedView, ConversationGroupInput, - GroupKey, GroupReference, GroupSnapshot, GroupUpdate, NodeChange, NodeKey, + GroupKey, GroupNodePosition, GroupReference, GroupSnapshot, GroupUpdate, NodeChange, NodeKey, NodeReference, RenderEntry, } from './contract/groups.ts' export { ConversationController, UnsupportedImageMediaTypeError } from './service.ts' diff --git a/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts index 78a95a6185..03be14383b 100644 --- a/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts +++ b/packages/client/ui-conversation/tests/conversation-groups.client.spec.ts @@ -25,14 +25,27 @@ class GroupTestBuilder implements ConversationViewBuilder() readonly nodes = new Map() readonly publish = vi.fn() + private readonly readers = { + readNode: (key: NodeKey) => this.nodes.get(key), + readTurn: (_turn: number): readonly NodeKey[] => [], + readPosition: (key: NodeKey) => { + const index = this.input.order.indexOf(key) + if (index < 0) return undefined + return { + turn: undefined, + previous: this.input.order[index - 1], + next: this.input.order[index + 1], + } + }, + } private input: ConversationGroupInput = { - kind: 'replace', order: [], readNode: key => this.nodes.get(key), timeline: { turnOrder: [], turns: new Map() }, + kind: 'replace', order: [], ...this.readers, timeline: { turnOrder: [], turns: new Map() }, } replace({ nodes, timeline }: { nodes: readonly ConversationViewNode[]; timeline: ConversationTimelineSnapshot }) { this.nodes.clear() for (const node of nodes) this.nodes.set(node.key, node) - this.input = { kind: 'replace', order: [...this.nodes.keys()] as NodeKey[], readNode: key => this.nodes.get(key), timeline } + this.input = { kind: 'replace', order: [...this.nodes.keys()] as NodeKey[], ...this.readers, timeline } return this.nodes } @@ -43,7 +56,10 @@ class GroupTestBuilder implements ConversationViewBuilder ({ previous: this.nodes.get(current.key), current })) for (const node of upserts) this.nodes.set(node.key, node) - this.input = { kind: 'apply', changes, changedTurns, order: [...this.nodes.keys()] as NodeKey[], readNode: key => this.nodes.get(key), timeline } + this.input = { + kind: 'apply', changes, changedTurns, changedTurnOrders: [], + order: [...this.nodes.keys()] as NodeKey[], ...this.readers, timeline, + } return this.nodes }