From 1d56bd56280fff2b03e7d04ba2bdfb4daa20ffbb Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 25 Sep 2026 13:05:04 +0800 Subject: [PATCH 01/86] feat(sandbox-windows-acl): ship a diagnosis skill for ACL denials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A denial this backend cannot explain — the confined child cannot open an object it was granted, or provisioning itself fails with Win32 5 — reached the user as a bare "Access is denied". The README documented both causes but shipped no way to tell them apart. Add the `diagnose-windows-sandbox-acl` skill. Its script reads unless a repair switch is passed, and classifies the failing path and its ancestors before anything changes; its two repair modes are the only supported ACL edits. `-Fix` removes a foreign package-SID allow ACE, and `-GrantFullControl` supplies the WRITE_DAC/WRITE_OWNER pair the mandatory-label write needs without ever changing an owner. Both require `-AllowRoot`, refuse a target outside it, and refuse to run together. `dsh-sandbox-local` registers the skill on Windows through `ctx.inject(['skills'], ...)`, so the skill registry stays optional and the sandbox never depends on it. The assets ship in this package's `files` list, which the workspace-constraints gate now pins alongside the runner and emitted-type entries. Tests cover the script's verdicts (foreign ACE, healthy object, inert capability SID, inert DENY ACE), its surgical removal, the grant's owner invariant, and both refusals; the catalog spec covers registration and disposal. --- packages/sandbox/sandbox-local/src/index.ts | 9 +- .../sandbox-windows-acl/README.i18n.yaml | 16 +- .../sandbox/sandbox-windows-acl/README.md | 7 +- .../sandbox/sandbox-windows-acl/README.zh.md | 7 +- .../diagnose-windows-sandbox-acl/SKILL.md | 120 ++++++ .../scripts/diagnose-windows-sandbox-acl.ps1 | 392 ++++++++++++++++++ .../sandbox/sandbox-windows-acl/package.json | 6 +- .../sandbox-windows-acl/src/acl-skill.ts | 68 +++ .../sandbox/sandbox-windows-acl/src/index.ts | 2 + .../tests/acl-skill.spec.ts | 48 +++ .../tests/diagnose-script.spec.ts | 199 +++++++++ .../sandbox/sandbox-windows-acl/tsconfig.json | 3 + pnpm-lock.yaml | 6 + scripts/check-workspace-constraints.ts | 2 +- 14 files changed, 870 insertions(+), 15 deletions(-) create mode 100644 packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md create mode 100644 packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 create mode 100644 packages/sandbox/sandbox-windows-acl/src/acl-skill.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/acl-skill.spec.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 5f180668cf..52718771b2 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -37,7 +37,7 @@ import z from '@deepseek-ai/schemastery' import { SandboxProvider, SandboxUnavailableError, canonicalPath } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type { SessionId } from '@deepseek-ai/dsh-session' -import { AclWriteGrant, assertTempRootOutsideWorkspace, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, assertTempRootOutsideWorkspace, registerAclDiagnosisSkill, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { assertNever } from '@deepseek-ai/dsh-util-values' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' @@ -295,6 +295,13 @@ export class LocalSandboxProvider extends SandboxProvider { this.configuredRunnerFailureSignatures = runnerFailureSignatures this.probeTimeoutMs = config.probeTimeoutMs as number assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs) + // The bundled diagnosis skill explains this backend's ACL failures, so it is + // registered wherever the backend composes. `ctx.inject` keeps `skills` + // optional: the sandbox must not require the skill registry. + /* v8 ignore next 3 -- Windows-only registration; the Linux coverage lane cannot take this branch */ + if (process.platform === 'win32') { + ctx.inject(['skills'], skillsCtx => registerAclDiagnosisSkill(skillsCtx)) + } // The temp grants are revoked with the provider: a clean server // shutdown leaves no temp ACEs behind (workspace ACEs stand by design — // the reuse cache; an unclean shutdown leaves them for the next diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 899cb22541..0813beda01 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -27,8 +27,8 @@ en: 289e65bddf20dd79 zh: 44b9fcedf944546a /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery: - en: f93372e784cda63e - zh: f8371114f3481550 + en: bd847a7890748b9d + zh: 25ef02776687d8c3 /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation: en: c2cde86c85c2c619 zh: a29a114e591f9d3f @@ -51,14 +51,14 @@ en: 7850da9522bf984b zh: a9f2273a85bafcf9 /deepseek-ai-dsh-sandbox-windows-acl/model-experience: - en: c94416e26fec28cd - zh: a16f435e8afbdf30 + en: bbb19594a2c2e4d4 + zh: 075a65d9b9182aad /deepseek-ai-dsh-sandbox-windows-acl/model-experience/kv-cache-effect: - en: 796af37d6f6add68 - zh: 1e0dc5a58e5456be + en: 8dc637e4c015ff15 + zh: 10db9e10111108cc /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work: - en: 2b6b967c62766d47 - zh: 0faf9ec390504967 + en: 2311808c0ded8564 + zh: 845ddb2408c861fd /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note: en: cc273320f6a1beaf zh: 5c57a312ddc50651 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index 42b2290028..fa905a51bc 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -75,6 +75,8 @@ Temp isolation is per live session/workspace pair: sessions sharing a workspace `init()` throws on any Win32 failure — the child is never spawned unrestricted. A runner that fails before executing the command prints `windows-acl-run: ` to stderr and exits 127, which the seam's runner-failure rules classify as a broken sandbox rather than a denial. Cleanup is best-effort by design: `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`. +A denial this backend cannot explain — the child cannot open an object it was granted, or the provisioning call itself fails — is diagnosed by the `diagnose-windows-sandbox-acl` skill bundled in `assets/`, whose script reads unless a repair switch is passed and classifies the failing path and its ancestors before anything changes. `registerAclDiagnosisSkill` contributes it to the session catalog wherever `dsh-sandbox-local` composes on Windows, and the script's `-Fix` and `-GrantFullControl` modes are the only supported repairs: both require `-AllowRoot`, refuse any target outside it, never change an owner, and need an unconfined caller because a confined child can neither open an object carrying a package SID nor write a DACL. + ----- @@ -156,11 +158,11 @@ Start with the subsystem reference for the shared vocabulary, then the provider ## Model Experience -Indirectly, through [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../shell/pwsh-sandbox/README.md), and their tools, which render this backend's partial-enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and `sandbox-local` owns runner selection. +Indirectly, through [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../shell/pwsh-sandbox/README.md), and their tools, which render this backend's partial-enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and `sandbox-local` owns runner selection. On Windows this package also contributes one catalog entry, the bundled `diagnose-windows-sandbox-acl` skill, which is where the model learns to diagnose a denial the tool layer can only report. #### KV Cache effect -None directly; the denial surface belongs to the tool layer. +One extra catalog entry on Windows: the skill's `description` (500 characters) rides in the catalog, while its body and script load only when the model invokes it. The denial surface itself stays in the tool layer. ## Known Limitations and Deferred Work @@ -178,6 +180,7 @@ These limits define when the backend is a poor fit or needs special operational - **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** — 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. +- **The bundled diagnosis skill needs an unconfined caller** — both the classification and the repairs read or write security descriptors a confined child cannot reach, so the session must escalate that one call; with no approval channel the escalation fails closed and the model can only hand the user the exact command to run. - **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 6a99eec14a..feda48e15e 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -75,6 +75,8 @@ rmSync(tempDir, { recursive: true, force: true }) `init()` 在任何 Win32 失败时抛出——子进程绝不会不受限制地 spawn。执行命令前失败的 runner 会向 stderr 打印 `windows-acl-run: ` 并以 127 退出,seam 的 runner 失败规则将其归类为损坏的沙箱,而非拒绝。清理按设计尽力而为:`dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`。 +此后端无法解释的拒绝——子进程打不开本已授权的对象,或授权调用本身失败——交给 `assets/` 中随包发布的 `diagnose-windows-sandbox-acl` 技能诊断:该脚本除非传入修复开关,否则只读,并先对待查路径及其祖先做出分类,再动任何东西。只要 `dsh-sandbox-local` 在 Windows 上被组合,`registerAclDiagnosisSkill` 就会把它注册进会话目录;脚本的 `-Fix` 与 `-GrantFullControl` 是唯一受支持的修复,两者都要求 `-AllowRoot`、拒绝其外的任何目标、绝不更改所有者,并且需要不受限的调用者——受限子进程既打不开带包 SID 的对象,也写不了 DACL。 + ----- @@ -156,11 +158,11 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 ## 模型体验 -间接地通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.zh.md)、[`dsh-pwsh-sandbox`](../../shell/pwsh-sandbox/README.zh.md) 及其工具呈现;它们渲染此后端的部分强制执行与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.zh.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本、`sandbox-local` 拥有 runner 选择。 +间接地通过 [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.zh.md)、[`dsh-pwsh-sandbox`](../../shell/pwsh-sandbox/README.zh.md) 及其工具呈现;它们渲染此后端的部分强制执行与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.zh.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本、`sandbox-local` 拥有 runner 选择。在 Windows 上本包还贡献一个目录条目——随包发布的 `diagnose-windows-sandbox-acl` 技能,模型正是从它学会诊断工具层只能上报的拒绝。 #### KV Cache 影响 -无直接影响;拒绝面属于工具层。 +Windows 上多一个目录条目:技能的 `description`(500 字符)随目录进入上下文,其正文与脚本仅在模型调用该技能时加载。拒绝面本身仍属于工具层。 ## 已知限制与延期工作 @@ -178,6 +180,7 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 - **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 - **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 在本机上,当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,Low 完整性的令牌无法访问它——即使同一份 DACL 同时向用户授予完全控制、向 Everyone 授予读取(已观测:只给新文件加这一条 ACE 即可复现拒绝,补授 Everyone 读取无法解除,而同样内容复制到别处仍可读)。其背后的内核规则尚未确证,也不由本包掌控;以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的目录树对本后端的子进程将不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问。 - **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。 +- **随包发布的诊断技能需要不受限的调用者。** 无论分类还是修复,都要读写受限子进程够不到的安全描述符,因此会话必须为那一次调用升权;没有审批通道时升权 fail-closed,模型只能把确切的命令交给用户自行执行。 - **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/assets/diagnose-windows-sandbox-acl/SKILL.md b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md new file mode 100644 index 0000000000..08c19e17d0 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md @@ -0,0 +1,120 @@ +--- +name: diagnose-windows-sandbox-acl +description: 'Use on Windows when the DSH sandbox denies something it promised: cannot write or list in the workspace, or read a path the user should be able to read. Run the bundled script over the failing path and its ancestors; it only reads without a repair switch. It separates an AppContainer package-SID ACE, which it removes under an explicit root, from an unmet ownership or permission precondition, which it repairs with a full-control grant. Never change an owner or edit ACLs outside the script.' +--- + +# Diagnose Windows sandbox ACL failures + +A denial is only worth diagnosing when it contradicts what the active mode promises. Establish that first: repairing a denial the sandbox is supposed to produce means weakening confinement for nothing. + +## Step 0 — Decide whether this denial is unexpected + +Enter the diagnosis only for these: + +| Symptom | Why it contradicts the promise | +|---|---| +| Cannot write inside the workspace (root or any subdirectory) | `workspace-write` promises writes there | +| Cannot enumerate inside the workspace (`dir` prints nothing for a directory that has entries) | same | +| Anything the signed-in user should plainly be able to read cannot be read | reads are never confined in either restricted mode | +| Only the workspace **root** is denied while its subdirectories work normally | points at one object, not at the whole tree | + +Strong but not required corroboration: `Test-Path` returns true while reads fail; `dir` reports an empty directory that is not empty; `cmd` cannot read a file that .NET reads fine. + +Stop and explain instead of repairing for these — they are the confinement working as documented: + +| Symptom | Why it is expected | What to do instead | +|---|---|---| +| Writing or deleting **outside** the workspace under `workspace-write` | the fence is doing its job | escalate that one call, or write inside the workspace | +| Any write under `read-only` | the mode itself | escalate that one call, or switch mode | +| `spawn EPERM` when a confined command captures a grandchild's output through piped stdio | named-pipe opens are denied in both restricted modes (a Win32 default-SD property, not a permission mistake); anonymous pipes and inherited stdio still work | escalate that one call, or restructure the command to avoid piping | +| `Add-Type`, `[System.IO.*]::`, COM, or reflection failing with "only core types" | `read-only` pwsh runs in ConstrainedLanguage | not a permission problem; escalate or use `workspace-write` | + +Then split "cannot write in the workspace" by shape, because the two shapes have different causes: + +- **Uniformly denied** — the root and every subdirectory fail together: the sandbox could not provision its grant. +- **Only the root denied** — subdirectories are fine: one object carries something that blocks it. + +## Step 1 — Diagnose read-only before changing anything + +Run the bundled script over the failing path. It reads ACLs with .NET and native `icacls`, changes nothing, and prints machine-parseable lines: + +```powershell +pwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path +``` + +It reports, for the target and every ancestor: any AppContainer package SID (`S-1-15-2-*`) as explicit or inherited, other `S-1-15-*` SIDs (reported but inert), the owner, whether the current user holds `WRITE_DAC` and `WRITE_OWNER`, whether the ACL could be read at all, and the caller's integrity level plus any sandbox markers in the environment. + +The failing API names the family. DSH's fail-closed errors carry the API and the exact Win32 code, so read the error rather than guessing: + +| Error | Meaning | +|---|---| +| `GetNamedSecurityInfoW` … (Win32 5) | the object could not be **read** — either it carries a package SID and the caller is below Medium, or the caller has no access to it at all | +| `SetNamedSecurityInfoW` … (Win32 5) | the object was read but not written — the caller lacks `WRITE_DAC`, `WRITE_OWNER`, or both | +| `grantWrite` succeeded, yet the confined child still cannot use the directory | the grant landed but the child cannot open the object | + +The script needs a caller that can open the object. `read-only` pwsh cannot run it at all, because ConstrainedLanguage blocks the .NET calls it needs; under `workspace-write` the confined child cannot open an object carrying a package SID, so the diagnosis comes back `READABLE=False` for the very object that caused the problem. In both modes the diagnosis needs an unconfined caller, so escalate once and run the diagnosis together with any repair in that same call (Step 4) — rather than concluding that the path is unreadable or that the failure is something else. + +## Step 2 — Read the verdict + +| Package SID present | Owner is the current user | Verdict | Action | +|---|---|---|---| +| yes | yes | a foreign AppContainer ACE is the blocker | remove the ACE (Step 4) | +| yes | no | both problems at once | grant full control first, then remove the ACE; the grant needs Windows elevation | +| no | yes, and the DACL lacks `WRITE_OWNER` | a precondition DSH documents is unmet | grant full control (Step 4); ownership already carries `WRITE_DAC`, so no elevation is needed | +| no | no | a precondition this caller cannot repair | hand the user the single command to run from an elevated terminal (Step 4) | +| no | yes and the DACL is complete | not this class | say so and stop; report what was actually found | + +## Step 3 — Explain, and apologize + +Say what the finding is and what it will cost before doing anything. + +Apologize on the harness's behalf, in the language the user is writing in, in one or two sentences, then move to the technical explanation. The gap being owned is ours: the harness neither notices the conflict when it provisions the grant nor gives any direction when the denial surfaces, so the user only sees a bare "Access is denied" and has to guess. Do not apologize before the cause is confirmed, do not repeat it, do not blame another tool in the apology (the cause belongs in the explanation), and do not let it replace the explanation. + +Two things must reach the user either way: + +- **Which shape this is** — one object carrying a foreign ACE, or a directory whose ownership/DACL does not satisfy DSH's documented precondition. +- **That removing a foreign ACE may be undone** — if whatever tool placed it runs again with this directory as its root, it will place it again. The repair is not a permanent fix. + +## Step 4 — Repair, or hand over steps + +Two repairs exist, and both run through the same script so that no ACL is edited by hand. + +**Remove a foreign package-SID ACE.** It grants nothing to anyone yet blocks every reader below Medium: + +```powershell +pwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path -AllowRoot -Out -Fix +``` + +**Grant the current user full control when the precondition is unmet:** + +```powershell +pwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path -AllowRoot -Out -GrantFullControl +``` + +Full control carries both rights this needs: `WRITE_DAC` for the DACL and `WRITE_OWNER` for the mandatory label DSH writes in the same call. **Taking ownership alone does not work** — it yields only `WRITE_DAC`, so the label write still fails. Never change an object's owner, and never "replace permissions on all child objects": that erases deny ACEs somebody set deliberately. + +Both modes require `-AllowRoot` and refuse any target outside it, are dry-run unless the repair switch is passed, write a native `icacls` backup plus a rollback command to `-Out`, and re-read the object to confirm both that the intended change landed and that every other ACE — the mandatory label included — survived unchanged. Both also write a DACL, which the confined child cannot do, so run the diagnosis and the repair inside one escalated call rather than spending two approvals on the same directory. + +When the caller owns the directory, the missing piece is only `WRITE_OWNER`: ownership already carries `WRITE_DAC`, so the script repairs it unelevated. Windows elevation is needed only when the owner is somebody else, because an unelevated caller cannot write the DACL of a directory it does not own; the script reports that case as a failed grant together with the one command to run. + +Do not reach for `sandbox_permissions` to solve that token problem. Escalation is the right lever for giving the script an unconfined caller (Step 1) and the wrong one for writing a DACL the caller has no right to write, because it widens the sandbox and not the Windows token. Even at `danger-full-access` the harness still runs unelevated with `Administrators` marked deny-only, so it has no `WRITE_DAC` on a directory owned by Administrators, and asking for the escalation only spends an approval on the wrong lever. + +Do not make the harness trigger UAC either. An agent-initiated `runas` is a privilege-escalation path, the elevated child would run outside both the sandbox and the approval ledger the session log is built from, and a UAC prompt cannot be routed through this harness's approval channel. Hand the user the single exact command to run from an elevated terminal, and say plainly what it changes. + +Never recommend running the whole agent elevated instead: that is a downgrade, and the community habit of "just run it as administrator" trades the isolation away for a working prompt. + +Also stop short when the directory cannot be repaired this way at all, and say which applies: the owner is a system principal such as `TrustedInstaller`, the path is one the user has no business owning, or the volume is not ACL-capable. Moving the workspace is then the honest answer. + +## Step 5 — Verify against the original failure + +Re-run the operation that failed, from the same confined context. The repair is only done when that operation succeeds. If it still fails, report the new error verbatim rather than starting another round of changes. + +## Step 6 — Report recurrence + +Tell the user the condition that brings it back, so they can recognize it instead of re-diagnosing it. + +## Never + +- Never write, create, or delete a file to *test* permissions. Writing to probe is how an existing file gets clobbered, and it answers nothing the read-only diagnosis does not. (This has already happened once in this repository: a probe overwrote a workspace-root `AGENTS.md` that no version control could restore.) +- Never edit ACLs by hand — no `Set-Acl` from a constructed SDDL, no `icacls` surgery outside the bundled script. A hand-built security descriptor also marks the SACL for writing, which fails with a privilege error that looks like a bug. +- Never repair anything outside the directory being diagnosed, and never widen `-AllowRoot` beyond the tree that legitimately contains the failing path — its purpose is to keep a repair from reaching a neighbour of the thing that failed. diff --git a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 new file mode 100644 index 0000000000..be39fff718 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 @@ -0,0 +1,392 @@ +<# +.SYNOPSIS + Read-only diagnosis, and whitelisted repair, of Windows ACL objects that the + DSH sandbox cannot open. + +.DESCRIPTION + A denial inside the DSH sandbox is worth diagnosing only when it contradicts + what the active mode promises: writes inside the workspace, or reads that the + signed-in user should plainly have. This script reports why such an object is + unreadable or unopenable, and repairs exactly one class of cause. + + Diagnosis (the default) never writes anything: it reads each requested path + and every ancestor with .NET and native icacls, and prints machine-parseable + lines for the caller to interpret. + + Repair (-Fix) removes ONLY allow ACEs whose SID is an AppContainer package SID + (S-1-15-2-*). Those grant nothing to anyone on this machine while making the + object unopenable to every caller below Medium integrity. Repair requires + -AllowRoot, refuses any target outside it, refuses reparse points and + dangerous roots, records an SDDL/icacls backup, and re-reads each object to + confirm that no other ACE and no mandatory label changed. + + The script never creates, deletes, or writes the contents of any file. Its + only footprint outside the ACLs it is asked to repair is the backup it writes + under -Out. + +.PARAMETER Path + One or more failing paths. Each is diagnosed together with its ancestors. + +.PARAMETER AllowRoot + Required with -Fix. Every repaired object must be inside this directory. + +.PARAMETER Out + Required with -Fix. Directory receiving the ACL backup and rollback notes. + +.PARAMETER Fix + Remove S-1-15-2-* allow ACEs. Absent means diagnose only. + +.PARAMETER GrantFullControl + Grant the current user full control on the requested path. Full control is what + the documented precondition needs: it carries WRITE_DAC for the DACL and + WRITE_OWNER for the mandatory label DSH writes in the same call, while taking + ownership supplies only WRITE_DAC. The owner is never changed. Mutually + exclusive with -Fix. + +.EXAMPLE + pwsh -File diagnose-windows-sandbox-acl.ps1 -Path 'C:\Users\me\.ssh' + +.EXAMPLE + pwsh -File diagnose-windows-sandbox-acl.ps1 -Path '\.dsh-acl-fixture-x\case1' -AllowRoot '\.dsh-acl-fixture-x' -Out '\.dsh-acl-fixture-x\report' -Fix +#> +[CmdletBinding()] +param( + [Parameter(Mandatory = $true)][string[]]$Path, + [string]$AllowRoot, + [string]$Out, + [switch]$Fix, + [switch]$GrantFullControl +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$PACKAGE_SID = '^S-1-15-2-' +$LOW_LABEL_SID = 'S-1-16-4096' + +function Write-Line { param([string]$Text) Write-Output $Text } + +# Unwrap nested exceptions: PowerShell wraps Win32 failures, and only the inner +# exception carries the real HRESULT the caller needs to classify the failure. +function Get-HResultChain { + param([System.Exception]$Exception) + $chain = @() + $e = $Exception + while ($null -ne $e) { + $chain += ('{0}=0x{1:X8}/win32={2}: {3}' -f $e.GetType().Name, $e.HResult, ($e.HResult -band 0xFFFF), $e.Message) + $e = $e.InnerException + } + return ($chain -join ' <- ') +} + +function Get-CurrentIdentity { + return [System.Security.Principal.WindowsIdentity]::GetCurrent() +} + +# Integrity level and sandbox markers decide the symptom shape: the same foreign +# ACE fails a grant when the caller is below Medium and merely blocks the child +# when the caller is not. +# The integrity level lives in the token's group list, which .NET filters out of +# WindowsIdentity.Groups, so read it through GetTokenInformation. Add-Type compiles +# in-process on PowerShell 7. Never spawn whoami.exe for this: inside a below-Medium +# token that process fails to initialize (STATUS_DLL_INIT_FAILED, 0xc0000142) and +# raises an application-error dialog on the user's desktop. +function Get-IntegritySid { + try { + if (-not ('DshTokenInfo' -as [type])) { + Add-Type -Namespace Dsh -Name TokenInfo -MemberDefinition @' +[StructLayout(LayoutKind.Sequential)] +public struct SID_AND_ATTRIBUTES { public IntPtr Sid; public uint Attributes; } +[StructLayout(LayoutKind.Sequential)] +public struct TOKEN_MANDATORY_LABEL { public SID_AND_ATTRIBUTES Label; } +[DllImport("advapi32.dll", SetLastError = true)] +public static extern bool GetTokenInformation(IntPtr token, int infoClass, IntPtr info, uint length, out uint returned); +[DllImport("advapi32.dll", SetLastError = true)] +public static extern IntPtr GetSidSubAuthority(IntPtr sid, uint index); +[DllImport("advapi32.dll", SetLastError = true)] +public static extern IntPtr GetSidSubAuthorityCount(IntPtr sid); +public static int Level(IntPtr token) { + uint length; + GetTokenInformation(token, 25, IntPtr.Zero, 0, out length); + if (length == 0) { return -1; } + IntPtr buffer = Marshal.AllocHGlobal((int)length); + try { + if (!GetTokenInformation(token, 25, buffer, length, out length)) { return -1; } + IntPtr sid = Marshal.PtrToStructure(buffer).Label.Sid; + byte count = Marshal.ReadByte(GetSidSubAuthorityCount(sid)); + return Marshal.ReadInt32(GetSidSubAuthority(sid, (uint)(count - 1))); + } finally { Marshal.FreeHGlobal(buffer); } +} +'@ + } + $level = [Dsh.TokenInfo]::Level([System.Security.Principal.WindowsIdentity]::GetCurrent().Token) + switch ($level) { + 0 { return 'S-1-16-0 (Untrusted)' } + 4096 { return 'S-1-16-4096 (Low)' } + 8192 { return 'S-1-16-8192 (Medium)' } + 12288 { return 'S-1-16-12288 (High)' } + 16384 { return 'S-1-16-16384 (System)' } + default { return 'unknown' } + } + } catch { + return 'unknown' + } +} + +function Get-CallerFacts { + $sid = Get-IntegritySid + $markers = @() + foreach ($name in (Get-ChildItem env: | Select-Object -ExpandProperty Name)) { + if ($name -like 'SBX_*' -or $name -like 'CODEX_*') { $markers += $name } + } + return @{ Integrity = $sid; Markers = $markers } +} + +function Test-DangerousRoot { + param([string]$FullPath) + $trimmed = $FullPath.TrimEnd('\') + if ($trimmed -match '^[A-Za-z]:$') { return 'drive root' } + if ($trimmed -ieq ([System.IO.Path]::GetFullPath($env:USERPROFILE).TrimEnd('\'))) { return 'user profile root' } + if ($trimmed -ieq ([System.IO.Path]::GetFullPath($env:WINDIR).TrimEnd('\'))) { return 'Windows directory' } + return $null +} + +function Test-UnderRoot { + param([string]$FullPath, [string]$Root) + $r = [System.IO.Path]::GetFullPath($Root).TrimEnd('\') + $c = [System.IO.Path]::GetFullPath($FullPath) + return $c.StartsWith($r + '\', [System.StringComparison]::OrdinalIgnoreCase) +} + +function Get-ObjectFacts { + param([string]$FullPath, [System.Security.Principal.WindowsIdentity]$Identity, [string]$MeSid) + $facts = [ordered]@{ + Object = $FullPath + Readable = $false + Error = '' + PackageAces = @() + OtherAppContainerSids = @() + Owner = '' + OwnerIsMe = $false + MyRights = '' + HasWriteDac = $false + HasWriteOwner = $false + LowLabel = $false + AclLines = @() + } + try { + # Get-Acl reads the security descriptor directly. On .NET Core the + # FileSystemInfo.GetAccessControl() form is an extension method, which + # PowerShell cannot invoke with instance syntax. + $acl = Get-Acl -LiteralPath $FullPath + $facts.Readable = $true + } catch { + $facts.Error = Get-HResultChain -Exception $_.Exception + return $facts + } + foreach ($rule in $acl.Access) { + # Orphaned and capability SIDs have no NTAccount form and Translate throws on + # them, so fall back to the reference's own text. + $reference = $rule.IdentityReference + $sid = if ($reference -is [System.Security.Principal.SecurityIdentifier]) { $reference.Value } + else { try { $reference.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { $reference.Value } } + if ($sid -match $PACKAGE_SID -and $rule.AccessControlType -eq 'Allow') { + $facts.PackageAces += ('SID={0} INHERITED={1} INHERITABLE={2} RIGHTS={3}' -f $sid, $rule.IsInherited, $rule.InheritanceFlags, $rule.FileSystemRights) + } elseif ($sid -match '^S-1-15-' -and $sid -notmatch $PACKAGE_SID) { + $facts.OtherAppContainerSids += $sid + } + if ($sid -eq $MeSid -and $rule.AccessControlType -eq 'Allow') { + $facts.MyRights = ($facts.MyRights + ';' + [string]$rule.FileSystemRights).Trim(';') + # FileSystemRights names these ChangePermissions (WRITE_DAC) and + # TakeOwnership (WRITE_OWNER); there is no WriteDac/WriteOwner member. + if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::ChangePermissions) -ne 0) { $facts.HasWriteDac = $true } + if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::TakeOwnership) -ne 0) { $facts.HasWriteOwner = $true } + } + } + try { + $facts.Owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value + $facts.OwnerIsMe = ($facts.Owner -eq $MeSid) + # Ownership carries an implicit READ_CONTROL and WRITE_DAC: a target the caller + # owns stays writable even when no ACE names the caller with WriteDac. + if ($facts.OwnerIsMe) { $facts.HasWriteDac = $true } + } catch { + $facts.Owner = 'unreadable' + } + # The mandatory label lives in the SACL. Reading the SACL needs a privilege while + # icacls prints the label without one, but icacls renders it by name, which is + # localized: try the integrity SID first and keep the English name as a fallback. + try { + $facts.AclLines = @(icacls $FullPath) + $text = $facts.AclLines -join "`n" + $facts.LowLabel = ($text -match $LOW_LABEL_SID) -or ($text -match 'Mandatory Label') + } catch { + $facts.LowLabel = $false + } + return $facts +} + +function Get-Ancestors { + param([string]$FullPath) + $list = @() + $current = [System.IO.Path]::GetFullPath($FullPath) + while ($true) { + $list += $current + $parent = [System.IO.Path]::GetDirectoryName($current) + if ([string]::IsNullOrEmpty($parent) -or $parent -eq $current) { break } + $current = $parent + } + return $list +} + +# --- main --------------------------------------------------------------- + +$identity = Get-CurrentIdentity +$meSid = $identity.User.Value +$caller = Get-CallerFacts +Write-Line ('CALLER SID={0} INTEGRITY={1} SANDBOX_MARKERS={2}' -f $meSid, $caller.Integrity, (($caller.Markers | Sort-Object) -join ',')) + +if ($Fix -and $GrantFullControl) { + Write-Line 'REFUSED -Fix and -GrantFullControl are separate repairs; run one at a time' + exit 2 +} +if ($Fix -or $GrantFullControl) { + foreach ($required in @('AllowRoot', 'Out')) { + if (-not (Get-Variable -Name $required -ValueOnly)) { + Write-Line ('REFUSED the requested repair requires -{0}' -f $required) + exit 2 + } + } + if (-not (Test-Path -LiteralPath $Out)) { New-Item -ItemType Directory -Path $Out | Out-Null } +} + +$fixed = 0 +$granted = 0 +$refused = 0 + +foreach ($requested in $Path) { + $full = [System.IO.Path]::GetFullPath($requested) + Write-Line ('PATH={0}' -f $full) + if (-not (Test-Path -LiteralPath $full)) { + Write-Line ' MISSING' + Write-Line 'VERDICT=NOT_THIS_CLASS' + continue + } + + $packageTargets = @() + foreach ($ancestor in (Get-Ancestors -FullPath $full)) { + if (-not (Test-Path -LiteralPath $ancestor)) { continue } + $f = Get-ObjectFacts -FullPath $ancestor -Identity $identity -MeSid $meSid + Write-Line (' OBJECT={0} READABLE={1}{2}' -f $f.Object, $f.Readable, $(if ($f.Readable) { '' } else { ' ERROR=' + $f.Error })) + if ($f.Readable) { + Write-Line (' OWNER={0} IS_CURRENT_USER={1} MY_RIGHTS=[{2}] WRITE_DAC={3} WRITE_OWNER={4} LOW_LABEL={5}' -f $f.Owner, $f.OwnerIsMe, $f.MyRights, $f.HasWriteDac, $f.HasWriteOwner, $f.LowLabel) + foreach ($ace in $f.PackageAces) { Write-Line (' PACKAGE_ACE {0}' -f $ace) } + foreach ($sid in ($f.OtherAppContainerSids | Sort-Object -Unique)) { Write-Line (' OTHER_S1_15 SID={0} (reported only)' -f $sid) } + } + if ($f.PackageAces.Count -gt 0) { $packageTargets += $f } + } + + # Verdict: a confirmed package ACE is the blocker; otherwise an unmet + # precondition; otherwise this is not the class of failure this skill owns. + # The precondition is judged on the requested path itself. Ancestors above the + # tree DSH grants are not part of that grant, so their ownership and rights would + # otherwise mark every healthy path as a precondition failure. + $targetFacts = Get-ObjectFacts -FullPath $full -Identity $identity -MeSid $meSid + $unreadable = -not $targetFacts.Readable + $needsPrecondition = (-not $unreadable) -and $targetFacts.PackageAces.Count -eq 0 -and + ((-not $targetFacts.OwnerIsMe) -or (-not $targetFacts.HasWriteDac) -or (-not $targetFacts.HasWriteOwner)) + + if ($packageTargets.Count -gt 0) { + $blocked = @($packageTargets | Where-Object { (-not $_.OwnerIsMe) -or (-not $_.HasWriteDac) }) + Write-Line ('VERDICT={0}' -f $(if ($blocked.Count -gt 0) { 'BOTH' } else { 'CULPRIT' })) + } elseif ($unreadable) { + Write-Line 'VERDICT=UNREADABLE' + } elseif ($needsPrecondition) { + Write-Line 'VERDICT=PRECONDITION' + } else { + Write-Line 'VERDICT=NOT_THIS_CLASS' + } + + if ($GrantFullControl) { + if ($unreadable) { Write-Line 'GRANT_REFUSED the object could not be read; the grant needs an unconfined caller'; $refused++; continue } + if ($targetFacts.PackageAces.Count -gt 0) { Write-Line 'GRANT_REFUSED remove the package SID ACE first with -Fix'; $refused++; continue } + if ($targetFacts.HasWriteDac -and $targetFacts.HasWriteOwner) { Write-Line ('GRANT_SKIPPED {0} already carries WRITE_DAC and WRITE_OWNER' -f $full); continue } + if (-not (Test-UnderRoot -FullPath $full -Root $AllowRoot)) { Write-Line ('GRANT_REFUSED {0} is outside -AllowRoot' -f $full); $refused++; continue } + $danger = Test-DangerousRoot -FullPath $full + if ($danger) { Write-Line ('GRANT_REFUSED {0} is a {1}' -f $full, $danger); $refused++; continue } + $item = Get-Item -LiteralPath $full -Force + if ($item.LinkType) { Write-Line ('GRANT_REFUSED {0} is a reparse point ({1})' -f $full, $item.LinkType); $refused++; continue } + $backup = Join-Path $Out ('acl-backup-{0}.txt' -f ([guid]::NewGuid().ToString('N'))) + $before = @(icacls $full) + $before | Set-Content -LiteralPath $backup + Write-Line ('BACKUP {0} -> {1}' -f $full, $backup) + # Address the grant by SID so no account name has to resolve, and leave the + # owner alone: ownership transfer reaches far beyond this fault. + icacls $full /grant "*${meSid}:(F)" | Out-Null + $code = $LASTEXITCODE + $after = Get-ObjectFacts -FullPath $full -Identity $identity -MeSid $meSid + if ($code -eq 0 -and $after.HasWriteDac -and $after.HasWriteOwner) { + Write-Line ('GRANTED {0} SID={1}' -f $full, $meSid); $granted++ + Write-Line ('ROLLBACK icacls "{0}" /remove:g "*{1}"' -f $full, $meSid) + } else { + Write-Line ('GRANT_FAILED {0} exit={1}; an unelevated caller cannot grant WRITE_DAC on a directory it does not own, so one elevated run of this command is required' -f $full, $code) + $refused++ + } + continue + } + + if (-not $Fix) { continue } + + if ($unreadable) { + Write-Line 'FIX_REFUSED the object could not be read; repair needs an unconfined caller' + $refused++ + continue + } + foreach ($target in $packageTargets) { + if (-not (Test-UnderRoot -FullPath $target.Object -Root $AllowRoot)) { + Write-Line ('FIX_REFUSED {0} is outside -AllowRoot' -f $target.Object); $refused++; continue + } + $danger = Test-DangerousRoot -FullPath $target.Object + if ($danger) { Write-Line ('FIX_REFUSED {0} is a {1}' -f $target.Object, $danger); $refused++; continue } + $item = Get-Item -LiteralPath $target.Object -Force + if ($item.LinkType) { Write-Line ('FIX_REFUSED {0} is a reparse point ({1})' -f $target.Object, $item.LinkType); $refused++; continue } + if (-not ($target.OwnerIsMe -and $target.HasWriteDac)) { + Write-Line ('FIX_REFUSED {0} needs WRITE_DAC first: grant the current user full control (WRITE_DAC and WRITE_OWNER); taking ownership alone is not enough' -f $target.Object) + $refused++ + continue + } + $backup = Join-Path $Out ('acl-backup-{0}.txt' -f ([guid]::NewGuid().ToString('N'))) + $before = @(icacls $target.Object) + $before | Set-Content -LiteralPath $backup + Write-Line ('BACKUP {0} -> {1}' -f $target.Object, $backup) + foreach ($ace in $target.PackageAces) { + if ($ace -notmatch 'SID=(S-1-15-2-[\d-]+)') { continue } + $sid = $Matches[1] + icacls $target.Object /remove:g "*$sid" | Out-Null + $code = $LASTEXITCODE + $after = Get-ObjectFacts -FullPath $target.Object -Identity $identity -MeSid $meSid + # Prove the removal was surgical. icacls prints the object's first ACE on the + # same line as the path, so strip the path first: otherwise removing an ACE + # that led that line reads as a change to every following line. + $keepBefore = @($before | ForEach-Object { + $line = $_.Trim() + if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() } + $line + } | Where-Object { $_ -notmatch $PACKAGE_SID }) + $keepAfter = @($after.AclLines | ForEach-Object { + $line = $_.Trim() + if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() } + $line + } | Where-Object { $_ -notmatch $PACKAGE_SID }) + $collateral = (($keepBefore -join "`n") -eq ($keepAfter -join "`n")) + if ($code -eq 0 -and $after.PackageAces.Count -eq 0 -and $collateral) { + Write-Line ('FIXED {0} SID={1}' -f $target.Object, $sid); $fixed++ + Write-Line ('ROLLBACK icacls "{0}" /grant "*{1}:(X,RA)"' -f $target.Object, $sid) + } else { + Write-Line ('FIX_FAILED {0} SID={1} exit={2} collateral_change={3}' -f $target.Object, $sid, $code, (-not $collateral)) + $refused++ + } + } + } +} + +if ($Fix -or $GrantFullControl) { Write-Line ('SUMMARY FIXED={0} GRANTED={1} REFUSED={2}' -f $fixed, $granted, $refused) } diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 7ae0714983..3dee1e7897 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -29,22 +29,26 @@ "lib/index.js", "lib/runner.js", "lib/types-*.js", + "assets", "lib/types/**/*.d.ts" ], "license": "MIT", "peerDependencies": { "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-skill": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*" }, "dependencies": { "@deepseek-ai/dsh-lazy-require": "workspace:*", "@deepseek-ai/dsh-win32-process": "workspace:*", - "koffi": "^3.1.0" + "koffi": "^3.1.0", + "yaml": "^2.4.2" }, "devDependencies": { "@deepseek-ai/dsh-pwsh-local": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/cordis": "workspace:~", + "@deepseek-ai/dsh-skill": "workspace:*", "@deepseek-ai/dsh-subprocess": "workspace:*", "@deepseek-ai/dsh-subprocess-local": "workspace:*" } diff --git a/packages/sandbox/sandbox-windows-acl/src/acl-skill.ts b/packages/sandbox/sandbox-windows-acl/src/acl-skill.ts new file mode 100644 index 0000000000..201cc51e91 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/acl-skill.ts @@ -0,0 +1,68 @@ +/** + * Registers the bundled Windows sandbox ACL diagnosis skill. + * + * The skill ships beside the backend whose failures it explains, so the assets + * resolve relative to this module exactly as `skill-office` resolves its own. The + * command-line side of the ACL backend never imports it: only the always-composed + * local sandbox provider calls {@link registerAclDiagnosisSkill}, and only where + * this backend can be the active one. + * + * @module @deepseek-ai/dsh-sandbox-windows-acl + */ + +import { readFileSync } from 'node:fs' +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Context } from '@deepseek-ai/cordis' +import { BUNDLED_SKILL_RANK, type SkillCandidate, type SkillProvider } from '@deepseek-ai/dsh-skill' +import { parse as parseYaml } from 'yaml' + +/** Bundled skill that diagnoses Windows sandbox ACL failures. */ +export const ACL_DIAGNOSIS_SKILL = 'diagnose-windows-sandbox-acl' + +/** Provider name this module registers the skill under. */ +const PROVIDER = 'dsh-windows-acl' + +function parseSkill(raw: string, path: string): { description: string; content: string } { + const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/u.exec(raw) + if (frontmatter?.[1] === undefined) throw new Error(`dsh-sandbox-windows-acl: ${path} has no YAML frontmatter`) + const metadata: unknown = parseYaml(frontmatter[1]) + const description = typeof metadata === 'object' && metadata !== null && 'description' in metadata + ? metadata.description + : undefined + if (typeof description !== 'string' || description.length === 0) { + throw new Error(`dsh-sandbox-windows-acl: ${path} has no description`) + } + return { description, content: raw.slice(frontmatter[0].length).trim() } +} + +/** + * Register the bundled diagnosis skill on a session's skill registry. + * @param ctx - Context carrying the skill registry. + */ +export function registerAclDiagnosisSkill(ctx: Context): void { + const directory = fileURLToPath(new URL(`../assets/${ACL_DIAGNOSIS_SKILL}/`, import.meta.url)) + const locator = join(directory, 'SKILL.md') + const { description } = parseSkill(readFileSync(locator, 'utf8'), locator) + const candidate: SkillCandidate = { + name: ACL_DIAGNOSIS_SKILL, + description, + invocation: { modelInvocable: true, userInvocable: true }, + provider: PROVIDER, + source: 'bundled', + rank: BUNDLED_SKILL_RANK, + resourceBase: { kind: 'directory', path: directory }, + locator, + } + const provider: SkillProvider = { + name: PROVIDER, + list: () => Promise.resolve([candidate]), + async get(entry, options) { + const { rank: _rank, locator: entryPath, ...summary } = entry + const raw = await readFile(entryPath as string, { encoding: 'utf8', signal: options.signal }) + return { ...summary, content: parseSkill(raw, entryPath as string).content } + }, + } + ctx.skills.registerProvider(() => provider) +} diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 75dd23e7a5..efda04a854 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -159,6 +159,8 @@ function freeSidBestEffort( * `manageDacls: false` the caller owns the grants (the sandbox seam's grant * reuse): init() applies none and dispose() revokes none. */ +export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts' + export class AclSandbox { /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-skill.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-skill.spec.ts new file mode 100644 index 0000000000..8ff7b6cdf3 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-skill.spec.ts @@ -0,0 +1,48 @@ +/** + * The Windows ACL diagnosis skill ships as assets, so its catalog entry and the + * body the registry hands a model are asserted here rather than through a session. + * The registration is exercised on a real skill registry fiber, which is also what + * proves disposal removes the candidate. + */ + +import { Context } from '@deepseek-ai/cordis' +import SkillRegistry from '@deepseek-ai/dsh-skill' +import { describe, expect, it } from 'vitest' +import { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from '../src/acl-skill.ts' + +describe('bundled Windows ACL diagnosis skill', () => { + it('registers the packaged body and removes the candidate on disposal', async () => { + const ctx = new Context() + try { + await ctx.plugin(SkillRegistry) + const fiber = await ctx.plugin({ + name: 'acl-skill-registration', + inject: ['skills'], + apply: (inner: Context) => { registerAclDiagnosisSkill(inner) }, + }) + + const catalog = await ctx.skills.list() + expect(catalog.map(skill => skill.name)).toEqual([ACL_DIAGNOSIS_SKILL]) + const entry = catalog[0]! + expect(entry.description.length).toBeGreaterThan(0) + expect(entry.description.length).toBeLessThanOrEqual(500) + expect(entry).toMatchObject({ + source: 'bundled', + provider: 'dsh-windows-acl', + invocation: { modelInvocable: true, userInvocable: true }, + }) + + const loaded = await ctx.skills.get(ACL_DIAGNOSIS_SKILL) + expect(loaded?.resourceBase).toMatchObject({ kind: 'directory' }) + // The body names the bundled script — the repair path the model must use + // instead of editing ACLs by hand. + expect(loaded?.content).toContain('scripts/diagnose-windows-sandbox-acl.ps1') + expect(loaded?.content).toContain('Step 4') + + await fiber.dispose() + expect(await ctx.skills.list()).toEqual([]) + } finally { + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts new file mode 100644 index 0000000000..6559969e55 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts @@ -0,0 +1,199 @@ +/** + * The diagnosis script shipped with the `diagnose-windows-sandbox-acl` skill is a + * runnable artifact, so its classification and its two repairs are tested directly + * rather than through a model. + * + * Every case builds its own scratch directory under the system temp directory and + * never touches the user profile. The foreign package SID is synthetic: the access + * check that blocks a below-Medium caller keys on the SID class, not on a profile + * that exists, so a made-up `S-1-15-2-*` value exercises the same path. ACEs are + * written with `icacls` and the `*SID` spelling, which no account name has to + * resolve. + * + * Cleanup restores full control before deleting: the grant case leaves the caller + * with `WRITE_DAC` but not `WRITE_OWNER`, and a directory that stripped its own + * `WRITE_OWNER` cannot be removed by the same unelevated caller. + */ + +import { execFileSync } from 'node:child_process' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' + +const isWin32 = process.platform === 'win32' + +function pwshAvailable(): boolean { + try { + execFileSync('where.exe', ['pwsh'], { stdio: 'ignore' }) + return true + } catch { + return false + } +} + +const script = fileURLToPath(new URL('../assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1', import.meta.url)) +const PACKAGE_SID = 'S-1-15-2-1-2-3-4' +const CAPABILITY_SID = 'S-1-15-3-1-2-3-4' + +interface ScriptRun { + readonly code: number + readonly output: string +} + +function pwsh(command: string): string { + return execFileSync('pwsh', ['/NoLogo', '/NonInteractive', '/NoProfile', '-Command', command], { encoding: 'utf8' }) +} + +function runScript(args: readonly string[]): ScriptRun { + try { + const stdout = execFileSync('pwsh', ['/NoLogo', '/NonInteractive', '/NoProfile', '-File', script, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + return { code: 0, output: stdout } + } catch (error) { + const failure = error as { status?: number | null; stdout?: string; stderr?: string } + return { code: failure.status ?? -1, output: `${failure.stdout ?? ''}${failure.stderr ?? ''}` } + } +} + +function icacls(path: string, ...args: readonly string[]): string { + return pwsh(`(icacls '${path}' ${args.map(argument => `"${argument}"`).join(' ')}) -join "\`n"`) +} + +function aclLines(path: string): string[] { + return icacls(path).split(/\r?\n/u).map(line => line.trim()).filter(line => line !== '') +} + +function ownerOf(path: string): string { + return pwsh(`(Get-Acl -LiteralPath '${path}').GetOwner([System.Security.Principal.SecurityIdentifier]).Value`).trim() +} + +function normalized(path: string, lines: readonly string[]): string[] { + return lines.map((line) => { + const trimmed = line.trim() + return trimmed.startsWith(path) ? trimmed.slice(path.length).trim() : trimmed + }) +} + +describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl script', () => { + let scratch!: string + let outDir!: string + let meSid!: string + + function makeDir(name: string): string { + const dir = join(scratch, name) + mkdirSync(dir, { recursive: true }) + return dir + } + + function stamp(path: string, sid: string, type: 'grant' | 'deny' = 'grant'): void { + icacls(path, `/${type}`, `*${sid}:(RX)`) + } + + beforeAll(() => { + scratch = mkdtempSync(join(tmpdir(), 'dsh-acl-diagnose-')) + outDir = makeDir('out') + meSid = pwsh('[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value').trim() + }) + + afterAll(() => { + // Restore what the grant case withheld before deleting the scratch tree. + pwsh( + `Get-ChildItem -LiteralPath '${scratch}' -Recurse -Force -Directory -ErrorAction SilentlyContinue | ` + + `ForEach-Object { icacls $_.FullName /grant:r "*${meSid}:(F)" | Out-Null }; ` + + `icacls '${scratch}' /grant:r "*${meSid}:(F)" | Out-Null`, + ) + rmSync(scratch, { recursive: true, force: true }) + }) + + it('names a foreign package-SID ACE as the blocker and removes only that ACE', () => { + const target = makeDir('stamped') + stamp(target, PACKAGE_SID) + const before = normalized(target, aclLines(target)) + expect(before.join('\n')).toContain(PACKAGE_SID) + + const diagnosis = runScript(['-Path', target]) + expect(diagnosis.output, diagnosis.output).toContain('VERDICT=CULPRIT') + expect(diagnosis.output, diagnosis.output).toContain(`PACKAGE_ACE SID=${PACKAGE_SID}`) + + const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix']) + expect(repair.output, repair.output).toContain(`FIXED ${target} SID=${PACKAGE_SID}`) + expect(repair.output, repair.output).toContain('SUMMARY FIXED=1 GRANTED=0 REFUSED=0') + + // Exactly the foreign ACE disappears; every other line survives unchanged. + expect(normalized(target, aclLines(target))).toEqual(before.filter(line => !line.includes(PACKAGE_SID))) + }, 60_000) + + it('reports a healthy directory as not this class and changes nothing', () => { + const target = makeDir('healthy') + const before = normalized(target, aclLines(target)) + + const diagnosis = runScript(['-Path', target]) + expect(diagnosis.output, diagnosis.output).toContain('VERDICT=NOT_THIS_CLASS') + expect(diagnosis.output, diagnosis.output).not.toContain('PACKAGE_ACE') + + const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix']) + expect(repair.output, repair.output).toContain('SUMMARY FIXED=0 GRANTED=0 REFUSED=0') + expect(normalized(target, aclLines(target))).toEqual(before) + }, 60_000) + + it('reports an inert capability SID and an inert DENY ACE without touching either', () => { + const capability = makeDir('capability') + stamp(capability, CAPABILITY_SID) + const deny = makeDir('denied-package') + stamp(deny, PACKAGE_SID, 'deny') + const before = new Map([ + [capability, normalized(capability, aclLines(capability))], + [deny, normalized(deny, aclLines(deny))], + ]) + + const capabilityDiagnosis = runScript(['-Path', capability]) + expect(capabilityDiagnosis.output, capabilityDiagnosis.output).toContain(`OTHER_S1_15 SID=${CAPABILITY_SID}`) + expect(capabilityDiagnosis.output, capabilityDiagnosis.output).not.toContain('PACKAGE_ACE') + expect(capabilityDiagnosis.output, capabilityDiagnosis.output).not.toContain('VERDICT=CULPRIT') + + const denyDiagnosis = runScript(['-Path', deny]) + expect(denyDiagnosis.output, denyDiagnosis.output).not.toContain('PACKAGE_ACE') + expect(denyDiagnosis.output, denyDiagnosis.output).not.toContain('VERDICT=CULPRIT') + + // `pwsh -File` passes `-Path a,b` literally, so each object is diagnosed on its own. + for (const path of [capability, deny]) { + const repair = runScript(['-Path', path, '-AllowRoot', scratch, '-Out', outDir, '-Fix']) + expect(repair.output, repair.output).toContain('SUMMARY FIXED=0 GRANTED=0 REFUSED=0') + expect(normalized(path, aclLines(path))).toEqual(before.get(path)) + } + }, 90_000) + + it('grants full control for a Modify-only DACL without changing the owner', () => { + const target = makeDir('missing-write-owner') + pwsh(`icacls '${target}' /inheritance:r /grant:r "*${meSid}:(M)" | Out-Null`) + const ownerBefore = ownerOf(target) + + const diagnosis = runScript(['-Path', target]) + expect(diagnosis.output, diagnosis.output).toContain('VERDICT=PRECONDITION') + + const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-GrantFullControl']) + expect(repair.output, repair.output).toContain('SUMMARY FIXED=0 GRANTED=1 REFUSED=0') + expect(aclLines(target).join('\n')).toMatch(/\(F\)/u) + expect(ownerOf(target)).toBe(ownerBefore) + }, 60_000) + + it('refuses a target outside -AllowRoot and refuses contradictory switches', () => { + const target = makeDir('outside-root') + const unrelated = makeDir('unrelated-root') + stamp(target, PACKAGE_SID) + + const refused = runScript(['-Path', target, '-AllowRoot', unrelated, '-Out', outDir, '-Fix']) + expect(refused.output, refused.output).toContain('FIX_REFUSED') + expect(refused.output, refused.output).toContain('is outside -AllowRoot') + expect(refused.output, refused.output).toContain('SUMMARY FIXED=0 GRANTED=0 REFUSED=1') + expect(aclLines(target).join('\n')).toContain(PACKAGE_SID) + + const contradictory = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix', '-GrantFullControl']) + expect(contradictory.code).toBe(2) + expect(contradictory.output, contradictory.output).toContain('run one at a time') + }, 60_000) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tsconfig.json b/packages/sandbox/sandbox-windows-acl/tsconfig.json index d6a476cce8..627fb316f1 100644 --- a/packages/sandbox/sandbox-windows-acl/tsconfig.json +++ b/packages/sandbox/sandbox-windows-acl/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../util/lazy-require" + }, + { + "path": "../../skill/skill" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 36dc0506d3..220321ca7c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10186,6 +10186,9 @@ importers: koffi: specifier: ^3.1.0 version: 3.1.1 + yaml: + specifier: ^2.4.2 + version: 2.9.0 devDependencies: '@deepseek-ai/cordis': specifier: workspace:~ @@ -10196,6 +10199,9 @@ importers: '@deepseek-ai/dsh-sandbox-local': specifier: workspace:* version: link:../sandbox-local + '@deepseek-ai/dsh-skill': + specifier: workspace:* + version: link:../../skill/skill '@deepseek-ai/dsh-subprocess': specifier: workspace:* version: link:../../subprocess/subprocess diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index fee525c803..45820bf334 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -207,7 +207,7 @@ const packageFileExtras: Readonly> = { // The argv-prefix runner entry ships beside the lib as its own bundle; // sandbox-local resolves it through the package's ./runner export. tsdown // also shares its generated FFI code through a hashed runtime chunk. - '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'], + '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js', 'assets'], '@deepseek-ai/dsh-skill-badge': ['assets'], '@deepseek-ai/dsh-skill-office': ['assets'], '@deepseek-ai/dsh-subprocess': ['lib/control.js'], From 927b59e13817166be45db56ea8d8f91d4d5580c8 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 25 Sep 2026 18:26:12 +0800 Subject: [PATCH 02/86] fix(sandbox-windows-acl): make diagnosis repairs safe and observable --- apps/desktop/tests/acl-skill.built.e2e.ts | 77 +++ packages/sandbox/sandbox-local/src/index.ts | 2 +- .../sandbox-windows-acl/README.i18n.yaml | 8 +- .../sandbox/sandbox-windows-acl/README.md | 10 +- .../sandbox/sandbox-windows-acl/README.zh.md | 10 +- .../diagnose-windows-sandbox-acl/SKILL.md | 32 +- .../scripts/diagnose-windows-sandbox-acl.ps1 | 534 ++++++++++++------ .../sandbox-windows-acl/src/acl-skill.ts | 70 ++- .../sandbox/sandbox-windows-acl/src/index.ts | 3 +- .../tests/acl-skill.spec.ts | 32 +- .../tests/diagnose-script.spec.ts | 330 ++++++++++- .../tests/expected/denied-grant-report.txt | 8 + .../expected/parent-package-report.jsonl | 5 + .../build-exe-for-python-sdk-assets.spec.ts | 1 + scripts/build-exe-for-python-sdk.ts | 2 + scripts/run-gates.spec.ts | 1 + scripts/run-gates.ts | 1 + snapshots/session/headless.snapshot.ts | 5 + .../windows-acl-skill/session.v4.jsonl | 23 + .../session/windows-acl-skill/snapshot.yml | 9 + .../.dsh/skills/model-only-skill/SKILL.md | 7 + .../.dsh/skills/snapshot-skill/SKILL.md | 7 + .../.dsh/skills/user-only-skill/SKILL.md | 7 + 23 files changed, 951 insertions(+), 233 deletions(-) create mode 100644 apps/desktop/tests/acl-skill.built.e2e.ts create mode 100644 packages/sandbox/sandbox-windows-acl/tests/expected/denied-grant-report.txt create mode 100644 packages/sandbox/sandbox-windows-acl/tests/expected/parent-package-report.jsonl create mode 100644 snapshots/session/windows-acl-skill/session.v4.jsonl create mode 100644 snapshots/session/windows-acl-skill/snapshot.yml create mode 100644 snapshots/session/windows-acl-skill/workspace/.dsh/skills/model-only-skill/SKILL.md create mode 100644 snapshots/session/windows-acl-skill/workspace/.dsh/skills/snapshot-skill/SKILL.md create mode 100644 snapshots/session/windows-acl-skill/workspace/.dsh/skills/user-only-skill/SKILL.md diff --git a/apps/desktop/tests/acl-skill.built.e2e.ts b/apps/desktop/tests/acl-skill.built.e2e.ts new file mode 100644 index 0000000000..8b7b566f6d --- /dev/null +++ b/apps/desktop/tests/acl-skill.built.e2e.ts @@ -0,0 +1,77 @@ +/** External PowerShell must execute skill resources carried inside the Desktop archive. */ +import { spawnSync } from 'node:child_process' +import { cp, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { createRequire } from 'node:module' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { finished } from 'node:stream/promises' +import type { WriteStream } from 'node:fs' +import { fileURLToPath } from 'node:url' +import { expect, it } from 'vitest' + +const require = createRequire(import.meta.url) +const builderRequire = createRequire(require.resolve('app-builder-lib/package.json')) +const { createPackageWithOptions } = builderRequire('@electron/asar') as { + createPackageWithOptions: (source: string, destination: string, options: object) => Promise +} +const packageDirectory = fileURLToPath(new URL('../../../packages/sandbox/sandbox-windows-acl/', import.meta.url)) + +it.skipIf(process.platform !== 'win32' || process.env.DSH_EXAMPLE_MODE !== 'lib')('executes the archived ACL skill through external PowerShell and disposes its copy', { retry: 0 }, async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-acl-asar-')) + try { + const source = join(root, 'package') + await mkdir(source) + for (const path of ['lib', 'assets', 'package.json']) await cp(join(packageDirectory, path), join(source, path), { recursive: true }) + const archive = join(root, 'app.asar') + await finished(await createPackageWithOptions(source, archive, {})) + const entry = join(root, 'probe.mjs') + await writeFile(entry, ` +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import { createRequire, registerHooks } from 'node:module'; +import { join } from 'node:path'; +import { pathToFileURL } from 'node:url'; +const [packageDirectory, archive, root] = process.argv.slice(2); +const require = createRequire(join(packageDirectory, 'package.json')); +// Only the package under test is archived; resolve its real dependencies from the built workspace. +const archiveUrl = pathToFileURL(archive).href; +registerHooks({ resolve(specifier, context, next) { + if (context.parentURL?.startsWith(archiveUrl) && !specifier.startsWith('.') && !specifier.startsWith('node:')) { + return next(pathToFileURL(require.resolve(specifier)).href, context); + } + return next(specifier, context); +} }); +const { Context } = await import(pathToFileURL(require.resolve('@deepseek-ai/cordis'))); +const { default: SkillRegistry } = await import(pathToFileURL(require.resolve('@deepseek-ai/dsh-skill'))); +const { registerAclDiagnosisSkill, ACL_DIAGNOSIS_SKILL } = await import(pathToFileURL(join(archive, 'lib/index.js'))); +const ctx = new Context(); +try { + await ctx.plugin(SkillRegistry); + const fiber = await ctx.plugin({ name: 'acl-skill-archive-probe', inject: ['skills'], apply: registerAclDiagnosisSkill }); + const skill = await ctx.skills.get(ACL_DIAGNOSIS_SKILL); + assert.equal(skill.resourceBase.kind, 'directory'); + const directory = skill.resourceBase.path; + assert.ok(!directory.includes('.asar')); + const output = execFileSync('pwsh', ['-NoProfile', '-NonInteractive', '-File', + join(directory, 'scripts/diagnose-windows-sandbox-acl.ps1'), '-Path', root], + { encoding: 'utf8', timeout: 30000, windowsHide: true }); + assert.ok(output.includes('READABLE=True')); + await fiber.dispose(); + assert.ok(!existsSync(directory)); + assert.deepEqual(await ctx.skills.list(), []); + process.stdout.write('ACL_SKILL_ASAR_OK'); +} finally { await ctx.fiber.dispose(); } +`) + const electron = require('electron') as string + const result = spawnSync(electron, [entry, packageDirectory, archive, root], { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1', TEMP: root, TMP: root }, encoding: 'utf8', timeout: 60_000, windowsHide: true, + }) + expect(result.error).toBeUndefined() + expect(result.signal).toBeNull() + expect(result.status, result.stderr).toBe(0) + expect(result.stdout).toBe('ACL_SKILL_ASAR_OK') + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 52718771b2..07c4a42f38 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -300,7 +300,7 @@ export class LocalSandboxProvider extends SandboxProvider { // optional: the sandbox must not require the skill registry. /* v8 ignore next 3 -- Windows-only registration; the Linux coverage lane cannot take this branch */ if (process.platform === 'win32') { - ctx.inject(['skills'], skillsCtx => registerAclDiagnosisSkill(skillsCtx)) + ctx.inject(['skills'], (skillsCtx) => { registerAclDiagnosisSkill(skillsCtx) }) } // The temp grants are revoked with the provider: a clean server // shutdown leaves no temp ACEs behind (workspace ACEs stand by design — diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 0813beda01..896ea14a9f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -27,8 +27,8 @@ en: 289e65bddf20dd79 zh: 44b9fcedf944546a /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery: - en: bd847a7890748b9d - zh: 25ef02776687d8c3 + en: 966f167031348e70 + zh: 5bb476df9aee0e3a /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation: en: c2cde86c85c2c619 zh: a29a114e591f9d3f @@ -54,8 +54,8 @@ en: bbb19594a2c2e4d4 zh: 075a65d9b9182aad /deepseek-ai-dsh-sandbox-windows-acl/model-experience/kv-cache-effect: - en: 8dc637e4c015ff15 - zh: 10db9e10111108cc + en: 817eb1b7de13872e + zh: 06fe175e41e7459b /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work: en: 2311808c0ded8564 zh: 845ddb2408c861fd diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index fa905a51bc..c513b5cd87 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -75,7 +75,13 @@ Temp isolation is per live session/workspace pair: sessions sharing a workspace `init()` throws on any Win32 failure — the child is never spawned unrestricted. A runner that fails before executing the command prints `windows-acl-run: ` to stderr and exits 127, which the seam's runner-failure rules classify as a broken sandbox rather than a denial. Cleanup is best-effort by design: `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`. -A denial this backend cannot explain — the child cannot open an object it was granted, or the provisioning call itself fails — is diagnosed by the `diagnose-windows-sandbox-acl` skill bundled in `assets/`, whose script reads unless a repair switch is passed and classifies the failing path and its ancestors before anything changes. `registerAclDiagnosisSkill` contributes it to the session catalog wherever `dsh-sandbox-local` composes on Windows, and the script's `-Fix` and `-GrantFullControl` modes are the only supported repairs: both require `-AllowRoot`, refuse any target outside it, never change an owner, and need an unconfined caller because a confined child can neither open an object carrying a package SID nor write a DACL. +A denial this backend cannot explain — the child cannot open an object it was granted, or the provisioning call itself fails — is diagnosed by the `diagnose-windows-sandbox-acl` skill bundled in `assets/`. `registerAclDiagnosisSkill` contributes it wherever `dsh-sandbox-local` composes with a skill registry on Windows. Registration copies the resources into a private temporary directory readable by external PowerShell, including in ASAR and SEA deployments; fiber disposal unregisters the provider and removes its copy. Missing packaged assets fail registration. + +The script diagnoses without changing files or ACLs; effective `WRITE_DAC` and `WRITE_OWNER` checks open existing objects without modifying contents. `-Fix` removes package allow ACEs and `-GrantFullControl` adds the caller's missing rights, including when a package ACE is present. Both require an unconfined caller and `WRITE_DAC`, enforce a containing `-AllowRoot`, reject every reparse point in the target's ancestor chain, preserve ownership and save recovery files under `-Out` before mutation. The emitted `-Restore` command writes only the saved DACL and its inheritance protection, preserves the SACL, enforces the same path restrictions and remains usable after the session ends. Apply rollback commands in reverse repair order. Refused or failed mutations exit nonzero. + +Every run emits `REPORT` JSON lines describing observations, decisions, operation attempts and results with their reasons, followed by verification and summary records. ACL observations include allow and deny ACEs with SID, rights and inheritance, native listings, effective access and read errors; unknown values remain distinct from absent permissions. Mutations report their start before executing, so a caught exception retains prior results and recovery commands. Operation completion does not imply successful verification or automatic rollback. The [bundled skill](assets/diagnose-windows-sandbox-acl/SKILL.md) defines how the model interprets these records and reports remaining uncertainty. + +Inspection visits the requested object and each ancestor through the filesystem root. Each ACL observation's `path` identifies the inspected object; the classification's `details.packageObjects` lists objects carrying package allow ACEs without establishing the cause of the original failure. The [parent-package example](tests/expected/parent-package-report.jsonl) retains the fixture paths and shows selected fields, with `fixturePackageAces` projecting only the synthetic test SID from `aces`; host-owned ACEs and ancestors are omitted. The [failed-grant example](tests/expected/denied-grant-report.txt) retains operation paths and reasons. ----- @@ -162,7 +168,7 @@ Indirectly, through [`dsh-bash-sandbox`](../../shell/bash-sandbox/README.md), [` #### KV Cache effect -One extra catalog entry on Windows: the skill's `description` (500 characters) rides in the catalog, while its body and script load only when the model invokes it. The denial surface itself stays in the tool layer. +One extra catalog entry on Windows: the skill's description enters context with the catalog, and the body enters only when invoked. Resource extraction happens at registration and does not add script contents to model context. The denial surface itself stays in the tool layer. ## Known Limitations and Deferred Work diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index feda48e15e..7ebc10bcad 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -75,7 +75,13 @@ rmSync(tempDir, { recursive: true, force: true }) `init()` 在任何 Win32 失败时抛出——子进程绝不会不受限制地 spawn。执行命令前失败的 runner 会向 stderr 打印 `windows-acl-run: ` 并以 127 退出,seam 的 runner 失败规则将其归类为损坏的沙箱,而非拒绝。清理按设计尽力而为:`dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`。 -此后端无法解释的拒绝——子进程打不开本已授权的对象,或授权调用本身失败——交给 `assets/` 中随包发布的 `diagnose-windows-sandbox-acl` 技能诊断:该脚本除非传入修复开关,否则只读,并先对待查路径及其祖先做出分类,再动任何东西。只要 `dsh-sandbox-local` 在 Windows 上被组合,`registerAclDiagnosisSkill` 就会把它注册进会话目录;脚本的 `-Fix` 与 `-GrantFullControl` 是唯一受支持的修复,两者都要求 `-AllowRoot`、拒绝其外的任何目标、绝不更改所有者,并且需要不受限的调用者——受限子进程既打不开带包 SID 的对象,也写不了 DACL。 +此后端无法解释的拒绝——子进程打不开本已授权的对象,或授权调用本身失败——交给 `assets/` 中随包发布的 `diagnose-windows-sandbox-acl` 技能诊断。只要 `dsh-sandbox-local` 在 Windows 上与技能注册表一起组合,`registerAclDiagnosisSkill` 就会注册它。注册时会把资源复制到外部 PowerShell 可读取的私有临时目录,ASAR 和 SEA 部署也适用;fiber 释放时注销提供者并删除副本。缺少打包资源会导致注册失败。 + +脚本诊断时不修改文件或 ACL;有效 `WRITE_DAC` 和 `WRITE_OWNER` 检查只打开已有对象,不修改内容。`-Fix` 删除包 SID 的允许 ACE,`-GrantFullControl` 补充调用者缺少的权限,即使包 ACE 同时存在也可执行。两者都要求不受限的调用者及 `WRITE_DAC`,强制目标位于 `-AllowRoot` 内,拒绝目标祖先链上的所有重解析点,保持所有者不变,并在修改前将恢复文件保存到 `-Out`。输出的 `-Restore` 命令只写回原 DACL 及其继承保护状态,保留 SACL,执行相同的路径限制,且在会话结束后仍可使用。多次修复应按相反顺序回滚。被拒绝或失败的修改均以非零状态退出。 + +每次执行都会输出 `REPORT` JSON 行,说明观察、决策、操作尝试及结果和各自原因,并记录验证结果与最终摘要。ACL 观察包含允许和拒绝 ACE 的 SID、权限与继承信息、原生列表、有效权限及读取错误;未知值与权限缺失明确区分。修改在执行前报告开始状态,捕获异常后仍保留此前结果和恢复命令。操作完成不代表验证成功或已经自动回滚。[内置技能](assets/diagnose-windows-sandbox-acl/SKILL.md) 规定模型如何解读这些记录并报告尚未确认的事项。 + +检查会从请求对象逐级访问祖先,直到文件系统根目录。每条 ACL 观察的 `path` 标识实际检查的对象;分类结果的 `details.packageObjects` 列出包含包允许 ACE 的对象,但不能据此确认原始失败的原因。[父目录包 SID 示例](tests/expected/parent-package-report.jsonl) 保留测试目录路径并展示选定字段,其中 `fixturePackageAces` 只从 `aces` 提取合成测试 SID,省略机器自带的 ACE 和祖先目录。[授权失败示例](tests/expected/denied-grant-report.txt) 保留操作路径和原因。 ----- @@ -162,7 +168,7 @@ seam 先把确定性工作区 SID 的 ACE 常驻物化(每个工作区每服 #### KV Cache 影响 -Windows 上多一个目录条目:技能的 `description`(500 字符)随目录进入上下文,其正文与脚本仅在模型调用该技能时加载。拒绝面本身仍属于工具层。 +Windows 上多一个目录条目:技能描述随目录进入上下文,正文只在调用时进入。资源在注册时提取,不会把脚本内容加入模型上下文。拒绝面本身仍属于工具层。 ## 已知限制与延期工作 diff --git a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md index 08c19e17d0..97acd07a53 100644 --- a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md +++ b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md @@ -42,7 +42,9 @@ Run the bundled script over the failing path. It reads ACLs with .NET and native pwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path ``` -It reports, for the target and every ancestor: any AppContainer package SID (`S-1-15-2-*`) as explicit or inherited, other `S-1-15-*` SIDs (reported but inert), the owner, whether the current user holds `WRITE_DAC` and `WRITE_OWNER`, whether the ACL could be read at all, and the caller's integrity level plus any sandbox markers in the environment. +It reports every observed allow and deny ACE on the target and its ancestors, including SID, rights, inheritance and propagation flags, plus the native ACL listing, owner, effective `WRITE_DAC` and `WRITE_OWNER`, caller integrity and sandbox marker names. Well-known groups `S-1-15-2-1` and `S-1-15-2-2` are reported but are not individual package ACEs eligible for removal. Access checks open existing objects without changing contents; Windows accounts for group membership, deny ACEs and inheritance. An unreadable or unrecognized value is unknown, not absent. + +Every `REPORT` line contains a JSON record with `kind`, `operation`, `path`, `status`, `reason` and `details`. Read observations and reasons alongside the verdict: a deny ACE's presence alone does not prove that it caused the failure. Decisions explain skips and refusals. Actions report `started` before execution and `completed` or `failed` afterward; completion means the operation ran, while a separate verification reports whether the intended result was observed. A final summary lists attempted operations, outcomes and available recovery commands, including after a caught exception. If execution ends before a completion or summary record, treat the result as unconfirmed. The failing API names the family. DSH's fail-closed errors carry the API and the exact Win32 code, so read the error rather than guessing: @@ -56,13 +58,15 @@ The script needs a caller that can open the object. `read-only` pwsh cannot run ## Step 2 — Read the verdict -| Package SID present | Owner is the current user | Verdict | Action | +For `UNREADABLE` or `INCOMPLETE`, report the failed observation and its error; do not treat missing evidence as missing permissions or proceed with a repair. `NOT_THIS_CLASS` only rules out the conditions the script inspected, not every cause of the original failure. + +| Package SID present | Effective permissions | Verdict | Action | |---|---|---|---| -| yes | yes | a foreign AppContainer ACE is the blocker | remove the ACE (Step 4) | -| yes | no | both problems at once | grant full control first, then remove the ACE; the grant needs Windows elevation | -| no | yes, and the DACL lacks `WRITE_OWNER` | a precondition DSH documents is unmet | grant full control (Step 4); ownership already carries `WRITE_DAC`, so no elevation is needed | -| no | no | a precondition this caller cannot repair | hand the user the single command to run from an elevated terminal (Step 4) | -| no | yes and the DACL is complete | not this class | say so and stop; report what was actually found | +| yes | the target has both rights and every affected object has `WRITE_DAC` | package ACE found | remove the ACE (Step 4), then verify the original failure | +| yes | either condition above is unmet | both problems at once | grant full control on the affected object first, then remove the ACE; if `WRITE_DAC` is missing, the user must run the grant from an elevated terminal | +| no | `WRITE_DAC` present, `WRITE_OWNER` missing | a provisioning precondition is unmet | grant full control (Step 4) | +| no | `WRITE_DAC` missing | this caller cannot repair the DACL | hand the user the single command to run from an elevated terminal (Step 4) | +| no | both rights present | not this class | say so and stop; report what was actually found | ## Step 3 — Explain, and apologize @@ -79,7 +83,7 @@ Two things must reach the user either way: Two repairs exist, and both run through the same script so that no ACL is edited by hand. -**Remove a foreign package-SID ACE.** It grants nothing to anyone yet blocks every reader below Medium: +**Remove a conflicting package-SID ACE.** This also removes that package's access, so explain which SID and permissions will be removed: ```powershell pwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path -AllowRoot -Out -Fix @@ -93,11 +97,13 @@ pwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path -AllowRoot (buffer).Label.Sid; byte count = Marshal.ReadByte(GetSidSubAuthorityCount(sid)); return Marshal.ReadInt32(GetSidSubAuthority(sid, (uint)(count - 1))); @@ -119,6 +190,10 @@ public static int Level(IntPtr token) { } '@ } +} + +function Get-IntegritySid { + try { $level = [Dsh.TokenInfo]::Level([System.Security.Principal.WindowsIdentity]::GetCurrent().Token) switch ($level) { 0 { return 'S-1-16-0 (Untrusted)' } @@ -126,9 +201,10 @@ public static int Level(IntPtr token) { 8192 { return 'S-1-16-8192 (Medium)' } 12288 { return 'S-1-16-12288 (High)' } 16384 { return 'S-1-16-16384 (System)' } - default { return 'unknown' } + default { return "S-1-16-$level (unrecognized level)" } } } catch { + Write-Report observation integrity '' unknown 'The token integrity level could not be read; do not infer that the caller is elevated or confined.' @{ error = (Get-HResultChain $_.Exception) } return 'unknown' } } @@ -158,8 +234,53 @@ function Test-UnderRoot { return $c.StartsWith($r + '\', [System.StringComparison]::OrdinalIgnoreCase) } +function Get-RepairRefusal { + param([string]$FullPath, [string]$Root) + if (-not (Test-UnderRoot -FullPath $FullPath -Root $Root)) { return "$FullPath is outside -AllowRoot" } + $danger = Test-DangerousRoot -FullPath $FullPath + if ($danger) { return "$FullPath is a $danger" } + # Checking every component also covers a junction used as AllowRoot itself. + foreach ($component in (Get-Ancestors -FullPath $FullPath)) { + $item = Get-Item -LiteralPath $component -Force + if (($item.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0) { + return "$FullPath traverses a reparse point: $component" + } + } + return $null +} + +function Save-AclBackup { + param([string]$FullPath, [string]$Directory, [string]$Root) + $fullDirectory = [System.IO.Path]::GetFullPath($Directory) + if (-not (Test-Path -LiteralPath $fullDirectory)) { + Invoke-ReportedOperation create_backup_directory $fullDirectory 'The requested recovery directory does not exist; create it before writing backup files.' files { + New-Item -ItemType Directory -Path $fullDirectory | Out-Null + } + } + $backup = Join-Path $fullDirectory ('acl-backup-{0}.txt' -f ([guid]::NewGuid().ToString('N'))) + $record = $backup + '.json' + # The skill's extracted directory expires with its registration. Recovery must + # remain runnable from the backup directory after that registration is gone. + $restoreScript = $backup + '.ps1' + $command = "pwsh -NoProfile -File '{0}' -Path '{1}' -AllowRoot '{2}' -Restore '{3}'" -f + $restoreScript.Replace("'", "''"), $FullPath.Replace("'", "''"), ([System.IO.Path]::GetFullPath($Root)).Replace("'", "''"), $record.Replace("'", "''") + Write-Report decision backup $FullPath selected 'All recovery files must be written successfully before attempting the ACL change.' @{ files = @($backup, $record, $restoreScript, ($backup + '.rollback.txt')) } + Invoke-ReportedOperation backup $FullPath 'Save the original DACL and an independent recovery command before changing permissions.' files { + $output = @(icacls $FullPath /save $backup 2>&1) + if ($LASTEXITCODE -ne 0) { throw "Could not back up the DACL (icacls exit $LASTEXITCODE): $($output -join "`n")" } + @{ Path = $FullPath; Dacl = (Get-Acl -LiteralPath $FullPath).GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access) } | + ConvertTo-Json | Set-Content -LiteralPath $record -Encoding utf8 + Copy-Item -LiteralPath $PSCommandPath -Destination $restoreScript + $command | Set-Content -LiteralPath ($backup + '.rollback.txt') + } + $script:recoveries.Add(@{ path = $FullPath; backup = $backup; record = $record; script = $restoreScript; command = $command }) + Write-Report observation recovery $FullPath available 'These files can restore the saved DACL; no rollback has been executed.' $script:recoveries[-1] + Write-Line ('BACKUP {0} -> {1}' -f $FullPath, $backup) + Write-Line ('ROLLBACK {0}' -f $command) +} + function Get-ObjectFacts { - param([string]$FullPath, [System.Security.Principal.WindowsIdentity]$Identity, [string]$MeSid) + param([string]$FullPath, [string]$MeSid) $facts = [ordered]@{ Object = $FullPath Readable = $false @@ -169,9 +290,11 @@ function Get-ObjectFacts { Owner = '' OwnerIsMe = $false MyRights = '' - HasWriteDac = $false - HasWriteOwner = $false - LowLabel = $false + HasWriteDac = $null + HasWriteOwner = $null + Aces = @() + Errors = @() + LowLabel = $null AclLines = @() } try { @@ -182,14 +305,15 @@ function Get-ObjectFacts { $facts.Readable = $true } catch { $facts.Error = Get-HResultChain -Exception $_.Exception + Write-Report observation inspect_acl $FullPath unreadable 'The ACL read failed; permissions and the cause of denial remain unknown.' @{ error = $facts.Error } return $facts } - foreach ($rule in $acl.Access) { - # Orphaned and capability SIDs have no NTAccount form and Translate throws on - # them, so fall back to the reference's own text. - $reference = $rule.IdentityReference - $sid = if ($reference -is [System.Security.Principal.SecurityIdentifier]) { $reference.Value } - else { try { $reference.Translate([System.Security.Principal.SecurityIdentifier]).Value } catch { $reference.Value } } + foreach ($rule in $acl.GetAccessRules($true, $true, [System.Security.Principal.SecurityIdentifier])) { + $sid = $rule.IdentityReference.Value + $facts.Aces += [ordered]@{ + sid = $sid; type = [string]$rule.AccessControlType; rights = [string]$rule.FileSystemRights + inherited = $rule.IsInherited; inheritance = [string]$rule.InheritanceFlags; propagation = [string]$rule.PropagationFlags + } if ($sid -match $PACKAGE_SID -and $rule.AccessControlType -eq 'Allow') { $facts.PackageAces += ('SID={0} INHERITED={1} INHERITABLE={2} RIGHTS={3}' -f $sid, $rule.IsInherited, $rule.InheritanceFlags, $rule.FileSystemRights) } elseif ($sid -match '^S-1-15-' -and $sid -notmatch $PACKAGE_SID) { @@ -197,30 +321,34 @@ function Get-ObjectFacts { } if ($sid -eq $MeSid -and $rule.AccessControlType -eq 'Allow') { $facts.MyRights = ($facts.MyRights + ';' + [string]$rule.FileSystemRights).Trim(';') - # FileSystemRights names these ChangePermissions (WRITE_DAC) and - # TakeOwnership (WRITE_OWNER); there is no WriteDac/WriteOwner member. - if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::ChangePermissions) -ne 0) { $facts.HasWriteDac = $true } - if (($rule.FileSystemRights -band [System.Security.AccessControl.FileSystemRights]::TakeOwnership) -ne 0) { $facts.HasWriteOwner = $true } } } try { $facts.Owner = $acl.GetOwner([System.Security.Principal.SecurityIdentifier]).Value $facts.OwnerIsMe = ($facts.Owner -eq $MeSid) - # Ownership carries an implicit READ_CONTROL and WRITE_DAC: a target the caller - # owns stays writable even when no ACE names the caller with WriteDac. - if ($facts.OwnerIsMe) { $facts.HasWriteDac = $true } } catch { $facts.Owner = 'unreadable' + $facts.Errors += @{ operation = 'read_owner'; error = (Get-HResultChain $_.Exception) } + } + foreach ($check in @(@{ field = 'HasWriteDac'; mask = 0x40000 }, @{ field = 'HasWriteOwner'; mask = 0x80000 })) { + try { $facts[$check.field] = [Dsh.TokenInfo]::HasAccess($FullPath, $check.mask) } + catch { $facts.Errors += @{ operation = $check.field; error = (Get-HResultChain $_.Exception) } } } # The mandatory label lives in the SACL. Reading the SACL needs a privilege while # icacls prints the label without one, but icacls renders it by name, which is # localized: try the integrity SID first and keep the English name as a fallback. try { - $facts.AclLines = @(icacls $FullPath) + $facts.AclLines = @(icacls $FullPath 2>&1 | ForEach-Object { [string]$_ }) + if ($LASTEXITCODE -ne 0) { throw "icacls exit ${LASTEXITCODE}: $($facts.AclLines -join "`n")" } $text = $facts.AclLines -join "`n" - $facts.LowLabel = ($text -match $LOW_LABEL_SID) -or ($text -match 'Mandatory Label') + # An unrecognized localized label is unknown, not evidence of a Low label. + if ($text -match $LOW_LABEL_SID -or $text -match 'Mandatory Label\\Low Mandatory Level') { $facts.LowLabel = $true } } catch { - $facts.LowLabel = $false + $facts.Errors += @{ operation = 'icacls'; error = (Get-HResultChain $_.Exception) } + } + Write-Report observation inspect_acl $FullPath $(if ($facts.Errors.Count) { 'partial' } else { 'read' }) 'Read the ACL and check effective WRITE_DAC and WRITE_OWNER; observed ACEs alone do not identify which rule caused a denial.' @{ + owner = $facts.Owner; writeDac = $facts.HasWriteDac; writeOwner = $facts.HasWriteOwner + aces = $facts.Aces; nativeListing = @($facts.AclLines | ForEach-Object { $_.Trim() } | Where-Object { $_ }); lowLabel = $facts.LowLabel; errors = $facts.Errors } return $facts } @@ -240,153 +368,229 @@ function Get-Ancestors { # --- main --------------------------------------------------------------- -$identity = Get-CurrentIdentity -$meSid = $identity.User.Value -$caller = Get-CallerFacts -Write-Line ('CALLER SID={0} INTEGRITY={1} SANDBOX_MARKERS={2}' -f $meSid, $caller.Integrity, (($caller.Markers | Sort-Object) -join ',')) - -if ($Fix -and $GrantFullControl) { - Write-Line 'REFUSED -Fix and -GrantFullControl are separate repairs; run one at a time' - exit 2 -} -if ($Fix -or $GrantFullControl) { - foreach ($required in @('AllowRoot', 'Out')) { - if (-not (Get-Variable -Name $required -ValueOnly)) { - Write-Line ('REFUSED the requested repair requires -{0}' -f $required) - exit 2 - } - } - if (-not (Test-Path -LiteralPath $Out)) { New-Item -ItemType Directory -Path $Out | Out-Null } -} - +$operations = [System.Collections.Generic.List[object]]::new() +$recoveries = [System.Collections.Generic.List[object]]::new() $fixed = 0 $granted = 0 $refused = 0 +$restored = 0 +$exitCode = 0 +$observationFailures = 0 +$currentPath = '' +$mode = if ($Restore) { 'restore' } elseif ($GrantFullControl) { 'grant' } elseif ($Fix) { 'fix' } else { 'diagnose' } -foreach ($requested in $Path) { - $full = [System.IO.Path]::GetFullPath($requested) - Write-Line ('PATH={0}' -f $full) - if (-not (Test-Path -LiteralPath $full)) { - Write-Line ' MISSING' - Write-Line 'VERDICT=NOT_THIS_CLASS' - continue +try { + Write-Report invocation $mode '' started 'Inspect the requested paths before deciding whether the requested operation can run.' @{ + paths = $Path; allowRoot = $AllowRoot; outputDirectory = $Out; recoveryRecord = $Restore; fix = $Fix.IsPresent; grantFullControl = $GrantFullControl.IsPresent } + $identity = Get-CurrentIdentity + $meSid = $identity.User.Value + Invoke-ReportedOperation initialize '' 'Load read-only access checks and DACL-only writes before inspecting permissions.' none { Initialize-NativeApi } + $caller = Get-CallerFacts + Write-Line ('CALLER SID={0} INTEGRITY={1} SANDBOX_MARKERS={2}' -f $meSid, $caller.Integrity, (($caller.Markers | Sort-Object) -join ',')) + Write-Report observation caller '' read 'The caller token determines effective access; sandbox marker names do not prove token permissions.' @{ sid = $meSid; integrity = $caller.Integrity; sandboxMarkers = $caller.Markers } - $packageTargets = @() - foreach ($ancestor in (Get-Ancestors -FullPath $full)) { - if (-not (Test-Path -LiteralPath $ancestor)) { continue } - $f = Get-ObjectFacts -FullPath $ancestor -Identity $identity -MeSid $meSid - Write-Line (' OBJECT={0} READABLE={1}{2}' -f $f.Object, $f.Readable, $(if ($f.Readable) { '' } else { ' ERROR=' + $f.Error })) - if ($f.Readable) { - Write-Line (' OWNER={0} IS_CURRENT_USER={1} MY_RIGHTS=[{2}] WRITE_DAC={3} WRITE_OWNER={4} LOW_LABEL={5}' -f $f.Owner, $f.OwnerIsMe, $f.MyRights, $f.HasWriteDac, $f.HasWriteOwner, $f.LowLabel) - foreach ($ace in $f.PackageAces) { Write-Line (' PACKAGE_ACE {0}' -f $ace) } - foreach ($sid in ($f.OtherAppContainerSids | Sort-Object -Unique)) { Write-Line (' OTHER_S1_15 SID={0} (reported only)' -f $sid) } + if (@(@($Fix.IsPresent, $GrantFullControl.IsPresent, [bool]$Restore) | Where-Object { $_ }).Count -gt 1) { + throw [System.ArgumentException]::new('-Fix, -GrantFullControl and -Restore are separate operations; run one at a time') + } + if ($Fix -or $GrantFullControl -or $Restore) { + $requiredArguments = if ($Restore) { @('AllowRoot') } else { @('AllowRoot', 'Out') } + foreach ($required in $requiredArguments) { + if (-not (Get-Variable -Name $required -ValueOnly)) { + throw [System.ArgumentException]::new("The requested repair requires -$required") + } } - if ($f.PackageAces.Count -gt 0) { $packageTargets += $f } + if ($Restore -and $Path.Count -ne 1) { throw [System.ArgumentException]::new('-Restore requires exactly one -Path') } } - # Verdict: a confirmed package ACE is the blocker; otherwise an unmet - # precondition; otherwise this is not the class of failure this skill owns. - # The precondition is judged on the requested path itself. Ancestors above the - # tree DSH grants are not part of that grant, so their ownership and rights would - # otherwise mark every healthy path as a precondition failure. - $targetFacts = Get-ObjectFacts -FullPath $full -Identity $identity -MeSid $meSid - $unreadable = -not $targetFacts.Readable - $needsPrecondition = (-not $unreadable) -and $targetFacts.PackageAces.Count -eq 0 -and - ((-not $targetFacts.OwnerIsMe) -or (-not $targetFacts.HasWriteDac) -or (-not $targetFacts.HasWriteOwner)) + foreach ($requested in $Path) { + $currentPath = $requested + $full = [System.IO.Path]::GetFullPath($requested) + $currentPath = $full + Write-Line ('PATH={0}' -f $full) + if (-not (Test-Path -LiteralPath $full)) { + Write-Line ' MISSING' + Write-Line 'VERDICT=NOT_THIS_CLASS' + Write-Decision $mode $full skipped 'The requested path does not exist; no ACL was read or changed for it.' + if ($mode -ne 'diagnose') { $refused++ } + continue + } - if ($packageTargets.Count -gt 0) { - $blocked = @($packageTargets | Where-Object { (-not $_.OwnerIsMe) -or (-not $_.HasWriteDac) }) - Write-Line ('VERDICT={0}' -f $(if ($blocked.Count -gt 0) { 'BOTH' } else { 'CULPRIT' })) - } elseif ($unreadable) { - Write-Line 'VERDICT=UNREADABLE' - } elseif ($needsPrecondition) { - Write-Line 'VERDICT=PRECONDITION' - } else { - Write-Line 'VERDICT=NOT_THIS_CLASS' - } + $packageTargets = @() + $targetFacts = $null + foreach ($ancestor in (Get-Ancestors -FullPath $full)) { + if (-not (Test-Path -LiteralPath $ancestor)) { continue } + $f = Get-ObjectFacts -FullPath $ancestor -MeSid $meSid + if ($ancestor -eq $full) { $targetFacts = $f } + Write-Line (' OBJECT={0} READABLE={1}{2}' -f $f.Object, $f.Readable, $(if ($f.Readable) { '' } else { ' ERROR=' + $f.Error })) + if ($f.Readable) { + Write-Line (' OWNER={0} IS_CURRENT_USER={1} MY_RIGHTS=[{2}] WRITE_DAC={3} WRITE_OWNER={4} LOW_LABEL={5}' -f $f.Owner, $f.OwnerIsMe, $f.MyRights, $f.HasWriteDac, $f.HasWriteOwner, $f.LowLabel) + foreach ($ace in $f.PackageAces) { Write-Line (' PACKAGE_ACE {0}' -f $ace) } + foreach ($sid in ($f.OtherAppContainerSids | Sort-Object -Unique)) { Write-Line (' OTHER_S1_15 SID={0} (reported only)' -f $sid) } + } + if ($f.PackageAces.Count -gt 0) { $packageTargets += $f } + } - if ($GrantFullControl) { - if ($unreadable) { Write-Line 'GRANT_REFUSED the object could not be read; the grant needs an unconfined caller'; $refused++; continue } - if ($targetFacts.PackageAces.Count -gt 0) { Write-Line 'GRANT_REFUSED remove the package SID ACE first with -Fix'; $refused++; continue } - if ($targetFacts.HasWriteDac -and $targetFacts.HasWriteOwner) { Write-Line ('GRANT_SKIPPED {0} already carries WRITE_DAC and WRITE_OWNER' -f $full); continue } - if (-not (Test-UnderRoot -FullPath $full -Root $AllowRoot)) { Write-Line ('GRANT_REFUSED {0} is outside -AllowRoot' -f $full); $refused++; continue } - $danger = Test-DangerousRoot -FullPath $full - if ($danger) { Write-Line ('GRANT_REFUSED {0} is a {1}' -f $full, $danger); $refused++; continue } - $item = Get-Item -LiteralPath $full -Force - if ($item.LinkType) { Write-Line ('GRANT_REFUSED {0} is a reparse point ({1})' -f $full, $item.LinkType); $refused++; continue } - $backup = Join-Path $Out ('acl-backup-{0}.txt' -f ([guid]::NewGuid().ToString('N'))) - $before = @(icacls $full) - $before | Set-Content -LiteralPath $backup - Write-Line ('BACKUP {0} -> {1}' -f $full, $backup) - # Address the grant by SID so no account name has to resolve, and leave the - # owner alone: ownership transfer reaches far beyond this fault. - icacls $full /grant "*${meSid}:(F)" | Out-Null - $code = $LASTEXITCODE - $after = Get-ObjectFacts -FullPath $full -Identity $identity -MeSid $meSid - if ($code -eq 0 -and $after.HasWriteDac -and $after.HasWriteOwner) { - Write-Line ('GRANTED {0} SID={1}' -f $full, $meSid); $granted++ - Write-Line ('ROLLBACK icacls "{0}" /remove:g "*{1}"' -f $full, $meSid) + # The precondition is judged on the requested path itself. Ancestors above the + # tree DSH grants are not part of that grant, so their ownership and rights would + # otherwise mark every healthy path as a precondition failure. + if ($null -eq $targetFacts) { throw "The requested path disappeared before its ACL could be inspected: $full" } + $unreadable = -not $targetFacts.Readable + $needsPrecondition = (-not $unreadable) -and + ((-not $targetFacts.HasWriteDac) -or (-not $targetFacts.HasWriteOwner)) + + if ($targetFacts.Errors.Count -gt 0) { + $verdict = 'INCOMPLETE' + $reason = 'Some observations failed; missing evidence is not evidence that a right is absent or a repair is safe.' + } elseif ($packageTargets.Count -gt 0) { + $blocked = @($packageTargets | Where-Object { -not $_.HasWriteDac }) + $verdict = if ($needsPrecondition -or $blocked.Count -gt 0) { 'BOTH' } else { 'CULPRIT' } + $reason = 'Package allow ACEs were observed. Required access checks determine whether the caller can perform the requested repair; other causes remain possible.' + } elseif ($unreadable) { + $verdict = 'UNREADABLE' + $reason = 'The requested ACL could not be read; its entries and the cause of denial are unknown.' + } elseif ($needsPrecondition) { + $verdict = 'PRECONDITION' + $reason = 'The caller cannot open the requested object with both WRITE_DAC and WRITE_OWNER. The reported allow and deny ACEs are evidence, not an attribution to one rule.' } else { - Write-Line ('GRANT_FAILED {0} exit={1}; an unelevated caller cannot grant WRITE_DAC on a directory it does not own, so one elevated run of this command is required' -f $full, $code) - $refused++ + $verdict = 'NOT_THIS_CLASS' + $reason = 'No package allow ACE was observed and both required rights are available. Other ACL restrictions and causes of the original failure are not ruled out.' } - continue - } + Write-Line ('VERDICT={0}' -f $verdict) + Write-Report decision classify $full $verdict $reason @{ writeDac = $targetFacts.HasWriteDac; writeOwner = $targetFacts.HasWriteOwner; packageObjects = @($packageTargets | ForEach-Object { $_.Object }) } - if (-not $Fix) { continue } - - if ($unreadable) { - Write-Line 'FIX_REFUSED the object could not be read; repair needs an unconfined caller' - $refused++ - continue - } - foreach ($target in $packageTargets) { - if (-not (Test-UnderRoot -FullPath $target.Object -Root $AllowRoot)) { - Write-Line ('FIX_REFUSED {0} is outside -AllowRoot' -f $target.Object); $refused++; continue + if ($mode -eq 'diagnose') { + Write-Decision diagnose $full skipped 'No mutation mode was requested; all reported ACLs were left unchanged.' + continue } - $danger = Test-DangerousRoot -FullPath $target.Object - if ($danger) { Write-Line ('FIX_REFUSED {0} is a {1}' -f $target.Object, $danger); $refused++; continue } - $item = Get-Item -LiteralPath $target.Object -Force - if ($item.LinkType) { Write-Line ('FIX_REFUSED {0} is a reparse point ({1})' -f $target.Object, $item.LinkType); $refused++; continue } - if (-not ($target.OwnerIsMe -and $target.HasWriteDac)) { - Write-Line ('FIX_REFUSED {0} needs WRITE_DAC first: grant the current user full control (WRITE_DAC and WRITE_OWNER); taking ownership alone is not enough' -f $target.Object) + if ($targetFacts.Errors.Count -gt 0) { + Write-Decision $mode $full refused 'Required observations are incomplete; no repair was attempted for this path.' + $refused++; continue + } + + if ($Restore) { + $refusal = Get-RepairRefusal -FullPath $full -Root $AllowRoot + if ($refusal) { Write-Line ('RESTORE_REFUSED {0}' -f $refusal); Write-Decision restore $full refused $refusal; $refused++; continue } + $saved = Invoke-ReportedOperation read_recovery $Restore 'Read the recovery record and verify it belongs to the requested path before restoring its DACL.' none { + Get-Content -LiteralPath $Restore -Raw | ConvertFrom-Json + } + if ($saved.Path -isnot [string] -or $saved.Path -ine $full -or $saved.Dacl -isnot [string]) { + Write-Line 'RESTORE_REFUSED backup does not describe the requested path' + Write-Decision restore $full refused 'The backup does not describe the requested path; no DACL write was attempted.' + $refused++; continue + } + if (-not $targetFacts.HasWriteDac) { Write-Line 'RESTORE_REFUSED the caller lacks WRITE_DAC'; Write-Decision restore $full refused 'Effective WRITE_DAC is absent; the saved DACL cannot be written by this caller.'; $refused++; continue } + Invoke-ReportedOperation restore_dacl $full 'Restore the requested backup DACL and inheritance protection, preserving owner and SACL.' acl { + [Dsh.TokenInfo]::SetDacl($full, $saved.Dacl) + } + $actual = Invoke-ReportedOperation read_restored_dacl $full 'Read the restored DACL to compare it with the saved record.' none { + (Get-Acl -LiteralPath $full).GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access) + } + Write-Report verification restore $full $(if ($actual -eq $saved.Dacl) { 'verified' } else { 'failed' }) 'Compare the observed DACL with the recovery record after writing it.' @{ expectedDacl = $saved.Dacl; actualDacl = $actual } + if ($actual -ne $saved.Dacl) { Write-Line 'RESTORE_FAILED the restored DACL differs from the backup'; $refused++; continue } + Write-Line ('RESTORED {0}' -f $full); $restored++ + continue + } + + if ($GrantFullControl) { + if ($unreadable) { Write-Line 'GRANT_REFUSED the object could not be read; the grant needs an unconfined caller'; Write-Decision grant $full refused 'The ACL is unreadable, so a preserving grant cannot be constructed.'; $refused++; continue } + $refusal = Get-RepairRefusal -FullPath $full -Root $AllowRoot + if ($refusal) { Write-Line ('GRANT_REFUSED {0}' -f $refusal); Write-Decision grant $full refused $refusal; $refused++; continue } + if ($targetFacts.HasWriteDac -and $targetFacts.HasWriteOwner) { Write-Line ('GRANT_SKIPPED {0} already carries WRITE_DAC and WRITE_OWNER' -f $full); Write-Decision grant $full skipped 'Effective WRITE_DAC and WRITE_OWNER are already available; no grant or backup is needed.'; continue } + if (-not $targetFacts.HasWriteDac) { Write-Line 'GRANT_REFUSED the caller lacks WRITE_DAC; ask the user to run this command from an elevated terminal'; Write-Decision grant $full refused 'Effective WRITE_DAC is absent; this caller cannot change the DACL. Elevation may still be blocked by a deny ACE.'; $refused++; continue } + Save-AclBackup -FullPath $full -Directory $Out -Root $AllowRoot + # AddAccessRule preserves explicit denies; icacls /grant can remove a deny + # for the same principal. Write only the DACL, leaving owner and SACL intact. + $grantAcl = Get-Acl -LiteralPath $full + $grantAcl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new( + [System.Security.Principal.SecurityIdentifier]::new($meSid), + [System.Security.AccessControl.FileSystemRights]::FullControl, + [System.Security.AccessControl.AccessControlType]::Allow)) + Invoke-ReportedOperation grant_dacl $full "WRITE_OWNER is missing and WRITE_DAC is available; add a FullControl allow ACE for $meSid while preserving deny ACEs, owner and SACL." acl { + [Dsh.TokenInfo]::SetDacl($full, $grantAcl.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access)) + } + $after = Get-ObjectFacts -FullPath $full -MeSid $meSid + $verified = $after.Readable -and $after.Errors.Count -eq 0 -and $after.HasWriteDac -and $after.HasWriteOwner + Write-Report verification grant $full $(if ($verified) { 'verified' } else { 'failed' }) 'The DACL write completed; recheck effective access before claiming that provisioning can succeed. No automatic rollback is performed.' @{ + before = @{ writeDac = $targetFacts.HasWriteDac; writeOwner = $targetFacts.HasWriteOwner } + after = @{ writeDac = $after.HasWriteDac; writeOwner = $after.HasWriteOwner } + recovery = $recoveries[-1].command + } + if ($verified) { + Write-Line ('GRANTED {0} SID={1}' -f $full, $meSid); $granted++ + } else { + Write-Line ('GRANT_FAILED {0}; effective WRITE_DAC and WRITE_OWNER were not both confirmed; use the saved rollback and report the error' -f $full) + $refused++ + } + continue + } + + if (-not $Fix) { continue } + + if ($unreadable) { + Write-Line 'FIX_REFUSED the object could not be read; repair needs an unconfined caller' + Write-Decision fix $full refused 'The requested ACL could not be read; no package removal was attempted.' $refused++ continue } - $backup = Join-Path $Out ('acl-backup-{0}.txt' -f ([guid]::NewGuid().ToString('N'))) - $before = @(icacls $target.Object) - $before | Set-Content -LiteralPath $backup - Write-Line ('BACKUP {0} -> {1}' -f $target.Object, $backup) - foreach ($ace in $target.PackageAces) { - if ($ace -notmatch 'SID=(S-1-15-2-[\d-]+)') { continue } - $sid = $Matches[1] - icacls $target.Object /remove:g "*$sid" | Out-Null - $code = $LASTEXITCODE - $after = Get-ObjectFacts -FullPath $target.Object -Identity $identity -MeSid $meSid - # Prove the removal was surgical. icacls prints the object's first ACE on the - # same line as the path, so strip the path first: otherwise removing an ACE - # that led that line reads as a change to every following line. - $keepBefore = @($before | ForEach-Object { - $line = $_.Trim() - if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() } - $line - } | Where-Object { $_ -notmatch $PACKAGE_SID }) - $keepAfter = @($after.AclLines | ForEach-Object { - $line = $_.Trim() - if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() } - $line - } | Where-Object { $_ -notmatch $PACKAGE_SID }) - $collateral = (($keepBefore -join "`n") -eq ($keepAfter -join "`n")) - if ($code -eq 0 -and $after.PackageAces.Count -eq 0 -and $collateral) { - Write-Line ('FIXED {0} SID={1}' -f $target.Object, $sid); $fixed++ - Write-Line ('ROLLBACK icacls "{0}" /grant "*{1}:(X,RA)"' -f $target.Object, $sid) - } else { - Write-Line ('FIX_FAILED {0} SID={1} exit={2} collateral_change={3}' -f $target.Object, $sid, $code, (-not $collateral)) + if ($packageTargets.Count -eq 0) { Write-Decision fix $full skipped 'No package allow ACE was observed on the inspected objects; no ACL change or backup is needed.' } + foreach ($target in $packageTargets) { + $refusal = Get-RepairRefusal -FullPath $target.Object -Root $AllowRoot + if ($refusal) { Write-Line ('FIX_REFUSED {0}' -f $refusal); Write-Decision fix $target.Object refused $refusal; $refused++; continue } + if ($target.Errors.Count -gt 0) { Write-Decision fix $target.Object refused 'The ACL observations are incomplete; collateral changes could not be verified.'; $refused++; continue } + if (-not $target.HasWriteDac) { + Write-Line ('FIX_REFUSED {0} needs WRITE_DAC; ask the user to run the full-control grant from an elevated terminal first' -f $target.Object) + Write-Decision fix $target.Object refused 'Effective WRITE_DAC is absent; removing a package allow ACE requires that right.' $refused++ + continue + } + $before = $target.AclLines + Save-AclBackup -FullPath $target.Object -Directory $Out -Root $AllowRoot + foreach ($ace in $target.PackageAces) { + if ($ace -notmatch 'SID=(S-1-15-2-[\d-]+)') { continue } + $sid = $Matches[1] + $repairPath = $target.Object + Invoke-ReportedOperation remove_package_allow $repairPath "Remove the observed package allow ACE for $sid as requested by -Fix; preserve deny ACEs and all other principals." acl { + $output = @(icacls $repairPath /remove:g "*$sid" 2>&1) + if ($LASTEXITCODE -ne 0) { throw "icacls removal exit ${LASTEXITCODE}: $($output -join "`n")" } + } + $after = Get-ObjectFacts -FullPath $target.Object -MeSid $meSid + # icacls prefixes only the first ACE with the path, so compare entries + # without that prefix when checking for collateral changes. + $keepBefore = @($before | ForEach-Object { + $line = $_.Trim() + if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() } + $line + } | Where-Object { $_ -notmatch $PACKAGE_SID }) + $keepAfter = @($after.AclLines | ForEach-Object { + $line = $_.Trim() + if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() } + $line + } | Where-Object { $_ -notmatch $PACKAGE_SID }) + $collateral = (($keepBefore -join "`n") -eq ($keepAfter -join "`n")) + $verified = $after.Readable -and $after.Errors.Count -eq 0 -and $after.PackageAces.Count -eq 0 -and $collateral + Write-Report verification fix $target.Object $(if ($verified) { 'verified' } else { 'failed' }) 'The removal command completed; verify that package allow ACEs disappeared and other ACL listing entries remained unchanged. No automatic rollback is performed.' @{ + removedSid = $sid; remainingPackageAces = $after.PackageAces; otherEntriesUnchanged = $collateral; recovery = $recoveries[-1].command + } + if ($verified) { + Write-Line ('FIXED {0} SID={1}' -f $target.Object, $sid); $fixed++ + } else { + Write-Line ('FIX_FAILED {0} SID={1} collateral_change={2}' -f $target.Object, $sid, (-not $collateral)) + $refused++ + } } } } +} catch { + $exitCode = if ($_.Exception -is [System.ArgumentException]) { 2 } else { 1 } + Write-Report error $mode $currentPath stopped 'Execution stopped after an exception. Completed operations remain completed; an interrupted write may have changed state. No automatic rollback was performed.' @{ error = (Get-HResultChain $_.Exception); location = $_.InvocationInfo.PositionMessage } +} finally { + if ($refused -gt 0 -and $exitCode -eq 0) { $exitCode = 2 } + Write-Line ('SUMMARY FIXED={0} GRANTED={1} REFUSED={2} RESTORED={3}' -f $fixed, $granted, $refused, $restored) + $status = if ($exitCode -ne 0) { 'failed' } elseif ($observationFailures -gt 0) { 'partial' } else { 'completed' } + Write-Report summary $mode $currentPath $status 'Operation completion records API execution; verification records the observed result. Only rerunning the original confined operation can confirm its failure is resolved.' @{ + exitCode = $exitCode; fixed = $fixed; granted = $granted; refused = $refused; restored = $restored + operations = @($operations.ToArray()); recoveries = @($recoveries.ToArray()); automaticRollback = $false; observationFailures = $observationFailures + } } - -if ($Fix -or $GrantFullControl) { Write-Line ('SUMMARY FIXED={0} GRANTED={1} REFUSED={2}' -f $fixed, $granted, $refused) } +exit $exitCode diff --git a/packages/sandbox/sandbox-windows-acl/src/acl-skill.ts b/packages/sandbox/sandbox-windows-acl/src/acl-skill.ts index 201cc51e91..67c5475ca7 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl-skill.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl-skill.ts @@ -1,17 +1,16 @@ /** * Registers the bundled Windows sandbox ACL diagnosis skill. * - * The skill ships beside the backend whose failures it explains, so the assets - * resolve relative to this module exactly as `skill-office` resolves its own. The - * command-line side of the ACL backend never imports it: only the always-composed - * local sandbox provider calls {@link registerAclDiagnosisSkill}, and only where - * this backend can be the active one. + * The provider owns a private filesystem copy of its resources so external + * PowerShell can execute them even when the package lives inside ASAR or SEA. + * Disposing the registration removes both the provider and its resource copy. * * @module @deepseek-ai/dsh-sandbox-windows-acl */ -import { readFileSync } from 'node:fs' -import { readFile } from 'node:fs/promises' +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs' +import { readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' @@ -38,31 +37,40 @@ function parseSkill(raw: string, path: string): { description: string; content: } /** - * Register the bundled diagnosis skill on a session's skill registry. + * Register the bundled diagnosis skill with a private resource directory owned by this fiber. + * Missing or invalid packaged assets fail registration; disposal removes the directory. * @param ctx - Context carrying the skill registry. */ export function registerAclDiagnosisSkill(ctx: Context): void { - const directory = fileURLToPath(new URL(`../assets/${ACL_DIAGNOSIS_SKILL}/`, import.meta.url)) - const locator = join(directory, 'SKILL.md') - const { description } = parseSkill(readFileSync(locator, 'utf8'), locator) - const candidate: SkillCandidate = { - name: ACL_DIAGNOSIS_SKILL, - description, - invocation: { modelInvocable: true, userInvocable: true }, - provider: PROVIDER, - source: 'bundled', - rank: BUNDLED_SKILL_RANK, - resourceBase: { kind: 'directory', path: directory }, - locator, - } - const provider: SkillProvider = { - name: PROVIDER, - list: () => Promise.resolve([candidate]), - async get(entry, options) { - const { rank: _rank, locator: entryPath, ...summary } = entry - const raw = await readFile(entryPath as string, { encoding: 'utf8', signal: options.signal }) - return { ...summary, content: parseSkill(raw, entryPath as string).content } - }, - } - ctx.skills.registerProvider(() => provider) + const packaged = fileURLToPath(new URL(`../assets/${ACL_DIAGNOSIS_SKILL}/`, import.meta.url)) + ctx.effect(function* () { + const directory = mkdtempSync(join(tmpdir(), 'dsh-acl-skill-')) + yield async () => { await rm(directory, { recursive: true, force: true }) } + mkdirSync(join(directory, 'scripts')) + for (const path of ['SKILL.md', 'scripts/diagnose-windows-sandbox-acl.ps1']) { + writeFileSync(join(directory, path), readFileSync(join(packaged, path)), { flag: 'wx', mode: 0o600 }) + } + const locator = join(directory, 'SKILL.md') + const { description } = parseSkill(readFileSync(locator, 'utf8'), locator) + const candidate: SkillCandidate = { + name: ACL_DIAGNOSIS_SKILL, + description, + invocation: { modelInvocable: true, userInvocable: true }, + provider: PROVIDER, + source: 'bundled', + rank: BUNDLED_SKILL_RANK, + resourceBase: { kind: 'directory', path: directory }, + locator, + } + const provider: SkillProvider = { + name: PROVIDER, + list: () => Promise.resolve([candidate]), + async get(entry, options) { + const { rank: _rank, locator: entryPath, ...summary } = entry + const raw = await readFile(entryPath as string, { encoding: 'utf8', signal: options.signal }) + return { ...summary, content: parseSkill(raw, entryPath as string).content } + }, + } + yield ctx.skills.registerProvider(() => provider) + }, 'Windows ACL diagnosis skill resources') } diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index efda04a854..5de5d47b72 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -58,6 +58,7 @@ import * as abi from './win32-abi.ts' export { AclWriteGrant } from './grant.ts' export { assertTempRootOutsideWorkspace } from './path-boundary.ts' export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' +export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts' /** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { /** Directories the confined child may write into (must exist and be caller-owned). */ @@ -159,8 +160,6 @@ function freeSidBestEffort( * `manageDacls: false` the caller owns the grants (the sandbox seam's grant * reuse): init() applies none and dispose() revokes none. */ -export { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from './acl-skill.ts' - export class AclSandbox { /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-skill.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-skill.spec.ts index 8ff7b6cdf3..e5cf2173cc 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-skill.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-skill.spec.ts @@ -1,11 +1,8 @@ -/** - * The Windows ACL diagnosis skill ships as assets, so its catalog entry and the - * body the registry hands a model are asserted here rather than through a session. - * The registration is exercised on a real skill registry fiber, which is also what - * proves disposal removes the candidate. - */ +/** The bundled skill owns its registry candidate and extracted resource directory. */ import { Context } from '@deepseek-ai/cordis' +import { existsSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' import SkillRegistry from '@deepseek-ai/dsh-skill' import { describe, expect, it } from 'vitest' import { ACL_DIAGNOSIS_SKILL, registerAclDiagnosisSkill } from '../src/acl-skill.ts' @@ -34,6 +31,10 @@ describe('bundled Windows ACL diagnosis skill', () => { const loaded = await ctx.skills.get(ACL_DIAGNOSIS_SKILL) expect(loaded?.resourceBase).toMatchObject({ kind: 'directory' }) + if (loaded?.resourceBase?.kind !== 'directory') throw new Error('Missing physical skill resource directory') + const resourceDirectory = loaded.resourceBase.path + expect(readFileSync(join(resourceDirectory, 'scripts/diagnose-windows-sandbox-acl.ps1'), 'utf8')) + .toBe(readFileSync(new URL('../assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1', import.meta.url), 'utf8')) // The body names the bundled script — the repair path the model must use // instead of editing ACLs by hand. expect(loaded?.content).toContain('scripts/diagnose-windows-sandbox-acl.ps1') @@ -41,6 +42,25 @@ describe('bundled Windows ACL diagnosis skill', () => { await fiber.dispose() expect(await ctx.skills.list()).toEqual([]) + expect(existsSync(resourceDirectory)).toBe(false) + } finally { + await ctx.fiber.dispose() + } + }) + + it.each([ + ['missing frontmatter', '# Invalid skill', 'has no YAML frontmatter'], + ['non-object metadata', '---\nnull\n---\nBody', 'has no description'], + ['empty description', '---\ndescription: ""\n---\nBody', 'has no description'], + ])('rejects an extracted skill with %s', async (_name, raw, message) => { + const ctx = new Context() + try { + await ctx.plugin(SkillRegistry) + await ctx.plugin({ name: 'acl-skill-invalid-resource', inject: ['skills'], apply: registerAclDiagnosisSkill }) + const skill = await ctx.skills.get(ACL_DIAGNOSIS_SKILL) + if (skill?.resourceBase?.kind !== 'directory') throw new Error('Missing physical skill resource directory') + writeFileSync(join(skill.resourceBase.path, 'SKILL.md'), raw) + await expect(ctx.skills.get(ACL_DIAGNOSIS_SKILL)).rejects.toThrow(message) } finally { await ctx.fiber.dispose() } diff --git a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts index 6559969e55..8c682c22e3 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts @@ -10,15 +10,13 @@ * written with `icacls` and the `*SID` spelling, which no account name has to * resolve. * - * Cleanup restores full control before deleting: the grant case leaves the caller - * with `WRITE_DAC` but not `WRITE_OWNER`, and a directory that stripped its own - * `WRITE_OWNER` cannot be removed by the same unelevated caller. + * Cleanup restores full control on the fixture directories before removing them. */ import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' @@ -42,13 +40,35 @@ interface ScriptRun { readonly output: string } +interface ScriptReport { + readonly kind: string + readonly operation: string + readonly path: string + readonly status: string + readonly reason: string + readonly details: Record +} + +function reports(run: ScriptRun): ScriptReport[] { + const result = run.output.split(/\r?\n/u).filter(line => line.startsWith('REPORT ')) + .map(line => JSON.parse(line.slice('REPORT '.length)) as ScriptReport) + expect(result.length).toBeGreaterThan(0) + for (const entry of result) expect(entry.reason.length).toBeGreaterThan(0) + expect(result.at(-1)).toMatchObject({ kind: 'summary', details: { exitCode: run.code } }) + return result +} + function pwsh(command: string): string { return execFileSync('pwsh', ['/NoLogo', '/NonInteractive', '/NoProfile', '-Command', command], { encoding: 'utf8' }) } function runScript(args: readonly string[]): ScriptRun { + return runPowerShell(['-File', script, ...args]) +} + +function runPowerShell(args: readonly string[]): ScriptRun { try { - const stdout = execFileSync('pwsh', ['/NoLogo', '/NonInteractive', '/NoProfile', '-File', script, ...args], { + const stdout = execFileSync('pwsh', ['/NoLogo', '/NonInteractive', '/NoProfile', ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], }) @@ -60,7 +80,11 @@ function runScript(args: readonly string[]): ScriptRun { } function icacls(path: string, ...args: readonly string[]): string { - return pwsh(`(icacls '${path}' ${args.map(argument => `"${argument}"`).join(' ')}) -join "\`n"`) + return execFileSync('icacls', [path, ...args], { encoding: 'utf8', windowsHide: true }) +} + +function sddlOf(path: string): string { + return pwsh(`(Get-Acl -LiteralPath '${path.replaceAll("'", "''")}').Sddl`).trim() } function aclLines(path: string): string[] { @@ -122,6 +146,11 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix']) expect(repair.output, repair.output).toContain(`FIXED ${target} SID=${PACKAGE_SID}`) expect(repair.output, repair.output).toContain('SUMMARY FIXED=1 GRANTED=0 REFUSED=0') + expect(reports(repair)).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'action', operation: 'remove_package_allow', path: target, status: 'started' }), + expect.objectContaining({ kind: 'action', operation: 'remove_package_allow', path: target, status: 'completed' }), + expect.objectContaining({ kind: 'verification', operation: 'fix', path: target, status: 'verified' }), + ])) // Exactly the foreign ACE disappears; every other line survives unchanged. expect(normalized(target, aclLines(target))).toEqual(before.filter(line => !line.includes(PACKAGE_SID))) @@ -134,12 +163,64 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri const diagnosis = runScript(['-Path', target]) expect(diagnosis.output, diagnosis.output).toContain('VERDICT=NOT_THIS_CLASS') expect(diagnosis.output, diagnosis.output).not.toContain('PACKAGE_ACE') + expect(reports(diagnosis)).toContainEqual(expect.objectContaining({ kind: 'decision', operation: 'diagnose', path: target, status: 'skipped' })) const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix']) expect(repair.output, repair.output).toContain('SUMMARY FIXED=0 GRANTED=0 REFUSED=0') + expect(reports(repair)).toContainEqual(expect.objectContaining({ kind: 'decision', operation: 'fix', path: target, status: 'skipped' })) expect(normalized(target, aclLines(target))).toEqual(before) }, 60_000) + it('reports every inspected ancestor and locates a package ACE present only on the parent', async () => { + const parent = makeDir('parent-package-report') + const target = join(parent, 'child') + mkdirSync(target) + stamp(parent, PACKAGE_SID) + const before = [sddlOf(parent), sddlOf(target)] + + const diagnosis = runScript(['-Path', target]) + expect(diagnosis.code, diagnosis.output).toBe(0) + const entries = reports(diagnosis) + const inspected = entries.filter(entry => entry.kind === 'observation' && entry.operation === 'inspect_acl') + const expectedPaths = [target] + for (let ancestor = dirname(target); ; ancestor = dirname(ancestor)) { + expectedPaths.push(ancestor) + if (dirname(ancestor) === ancestor) break + } + expect(inspected.map(entry => entry.path)).toEqual(expectedPaths) + expect(inspected[0]).toMatchObject({ + path: target, status: 'read', + details: { aces: expect.not.arrayContaining([expect.objectContaining({ sid: PACKAGE_SID })]) }, + }) + expect(inspected[1]).toMatchObject({ + path: parent, status: 'read', + details: { aces: expect.arrayContaining([expect.objectContaining({ sid: PACKAGE_SID, type: 'Allow', inherited: false })]) }, + }) + expect(entries).toContainEqual(expect.objectContaining({ + kind: 'decision', operation: 'classify', path: target, status: 'CULPRIT', + details: expect.objectContaining({ packageObjects: [parent] }), + })) + expect(entries.filter(entry => entry.kind === 'action' && entry.details.effect !== 'none')).toEqual([]) + expect([sddlOf(parent), sddlOf(target)]).toEqual(before) + + // The example retains both fixture paths and the fixture SID. Host-owned ACEs + // and ancestors remain covered by the assertions above instead of the snapshot. + const transcript = entries.filter(entry => entry.path === parent || entry.path === target).map((entry) => { + let details: Record = {} + if (entry.operation === 'inspect_acl') { + details = { fixturePackageAces: (entry.details.aces as { readonly sid: string }[]).filter(ace => ace.sid === PACKAGE_SID) } + } else if (entry.operation === 'classify') { + details = { packageObjects: entry.details.packageObjects } + } else if (entry.kind === 'summary') { + details = { + exitCode: entry.details.exitCode, operations: entry.details.operations, automaticRollback: entry.details.automaticRollback, + } + } + return JSON.stringify({ ...entry, details }) + }).join('\n').replaceAll(JSON.stringify(parent).slice(1, -1), '{{parent}}') + '\n' + await expect(transcript).toMatchFileSnapshot(fileURLToPath(new URL('./expected/parent-package-report.jsonl', import.meta.url))) + }, 60_000) + it('reports an inert capability SID and an inert DENY ACE without touching either', () => { const capability = makeDir('capability') stamp(capability, CAPABILITY_SID) @@ -158,6 +239,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri const denyDiagnosis = runScript(['-Path', deny]) expect(denyDiagnosis.output, denyDiagnosis.output).not.toContain('PACKAGE_ACE') expect(denyDiagnosis.output, denyDiagnosis.output).not.toContain('VERDICT=CULPRIT') + expect(reports(denyDiagnosis)).toContainEqual(expect.objectContaining({ + kind: 'observation', operation: 'inspect_acl', path: deny, + details: expect.objectContaining({ aces: expect.arrayContaining([expect.objectContaining({ sid: PACKAGE_SID, type: 'Deny', inherited: false })]) }), + })) // `pwsh -File` passes `-Path a,b` literally, so each object is diagnosed on its own. for (const path of [capability, deny]) { @@ -195,5 +280,236 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri const contradictory = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix', '-GrantFullControl']) expect(contradictory.code).toBe(2) expect(contradictory.output, contradictory.output).toContain('run one at a time') + expect(reports(contradictory)).toContainEqual(expect.objectContaining({ kind: 'error', status: 'stopped' })) }, 60_000) + + it.each(['-Fix', '-GrantFullControl'])('refuses ancestor junctions for %s without changing their destination', (repairSwitch) => { + const allowed = makeDir(`junction-${repairSwitch}`) + const outside = makeDir(`outside-${repairSwitch}`) + const target = join(outside, 'target') + mkdirSync(target) + icacls(target, '/inheritance:r', '/grant:r', `*${meSid}:(M)`) + stamp(target, PACKAGE_SID) + const before = sddlOf(target) + const link = join(allowed, 'link') + symlinkSync(outside, link, 'junction') + try { + for (const allowRoot of [allowed, link]) { + const repair = runScript(['-Path', join(link, 'target'), '-AllowRoot', allowRoot, '-Out', outDir, repairSwitch]) + expect(repair.output, repair.output).toContain('reparse point') + expect(repair.code).not.toBe(0) + expect(reports(repair)).toContainEqual(expect.objectContaining({ kind: 'decision', status: 'refused', reason: expect.stringContaining('reparse point') })) + expect(sddlOf(target)).toBe(before) + } + } finally { + unlinkSync(link) + } + }) + + it.each(['-Fix', '-GrantFullControl'])('restores the original DACL with the rollback emitted by %s', (repairSwitch) => { + const target = makeDir(`rollback-${repairSwitch}'s-directory`) + icacls(target, '/setintegritylevel', 'L') + if (repairSwitch === '-GrantFullControl') icacls(target, '/inheritance:r', '/grant:r', `*${meSid}:(M)`) + else icacls(target, '/grant', `*${PACKAGE_SID}:(OI)(CI)(RX)`) + icacls(target, '/grant', `*${CAPABILITY_SID}:(OI)(CI)(RX)`) + const before = sddlOf(target) + const linesBefore = normalized(target, aclLines(target)).sort() + const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, repairSwitch]) + expect(repair.code, repair.output).toBe(0) + expect(sddlOf(target)).not.toBe(before) + const rollback = repair.output.split(/\r?\n/u).find(line => line.startsWith('ROLLBACK ')) + expect(rollback, repair.output).toBeDefined() + const restored = pwsh(rollback!.slice('ROLLBACK '.length)) + expect(reports({ code: 0, output: restored })).toContainEqual(expect.objectContaining({ kind: 'verification', operation: 'restore', status: 'verified' })) + expect(sddlOf(target)).toBe(before) + expect(normalized(target, aclLines(target)).sort()).toEqual(linesBefore) + }, 60_000) + + it('repairs a directory whose full-control ACE only applies to children', () => { + const target = makeDir('inherit-only-full-control') + icacls(target, '/inheritance:r', '/grant:r', `*${meSid}:(M)`) + icacls(target, '/grant', `*${meSid}:(OI)(CI)(IO)(F)`) + const diagnosis = runScript(['-Path', target]) + expect(diagnosis.output, diagnosis.output).toContain('VERDICT=PRECONDITION') + const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-GrantFullControl']) + expect(repair.output, repair.output).toContain('SUMMARY FIXED=0 GRANTED=1 REFUSED=0') + icacls(target, '/setintegritylevel', 'L') + }) + + it('refuses a recovery record for another path or a path outside the allowed root', () => { + const target = makeDir('restore-guards') + const other = makeDir('restore-other') + const backups = makeDir('restore-backups') + icacls(target, '/inheritance:r', '/grant:r', `*${meSid}:(M)`) + const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', backups, '-GrantFullControl']) + expect(repair.code, repair.output).toBe(0) + const record = join(backups, readdirSync(backups).find(file => file.endsWith('.json'))!) + const before = sddlOf(target) + const otherBefore = sddlOf(other) + const mismatch = runScript(['-Path', other, '-AllowRoot', scratch, '-Restore', record]) + expect(mismatch.code).toBe(2) + expect(mismatch.output).toContain('backup does not describe the requested path') + expect(sddlOf(other)).toBe(otherBefore) + const outside = runScript(['-Path', target, '-AllowRoot', other, '-Restore', record]) + expect(outside.code).toBe(2) + expect(outside.output).toContain('outside -AllowRoot') + expect(sddlOf(target)).toBe(before) + const link = join(other, 'link') + symlinkSync(target, link, 'junction') + try { + const throughLink = runScript(['-Path', link, '-AllowRoot', other, '-Restore', record]) + expect(throughLink.code).toBe(2) + expect(throughLink.output).toContain('reparse point') + expect(sddlOf(target)).toBe(before) + } finally { + unlinkSync(link) + } + }, 60_000) + + it('does not report a successful grant when a deny ACE blocks WRITE_OWNER', async () => { + const target = makeDir('denied-write-owner') + icacls(target, '/deny', `*${meSid}:(WO)`) + try { + const diagnosis = runScript(['-Path', target]) + expect(diagnosis.output, diagnosis.output).toContain('VERDICT=PRECONDITION') + expect(reports(diagnosis)).toContainEqual(expect.objectContaining({ + kind: 'observation', operation: 'inspect_acl', path: target, + details: expect.objectContaining({ + writeOwner: false, + aces: expect.arrayContaining([expect.objectContaining({ sid: meSid, type: 'Deny', rights: 'TakeOwnership', inherited: false })]), + }), + })) + const grant = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-GrantFullControl']) + expect(grant.code, `${grant.output}\n${aclLines(target).join('\n')}`).toBe(2) + expect(grant.output, grant.output).toContain('GRANT_FAILED') + expect(grant.output).toContain('GRANTED=0 REFUSED=1') + expect(aclLines(target).join('\n')).toContain('(DENY)(WO)') + expect(reports(grant)).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'action', operation: 'grant_dacl', status: 'completed' }), + expect.objectContaining({ kind: 'verification', operation: 'grant', status: 'failed', details: expect.objectContaining({ recovery: expect.stringContaining('-Restore') }) }), + expect.objectContaining({ kind: 'summary', details: expect.objectContaining({ automaticRollback: false, granted: 0 }) }), + ])) + const transcript = reports(grant) + .filter(entry => entry.path === target && entry.kind !== 'observation') + .map(entry => `${entry.kind} ${entry.operation} ${entry.status} path={{target}}: ${entry.reason.replaceAll(meSid, '{{caller_sid}}')}`) + .join('\n') + '\n' + await expect(transcript).toMatchFileSnapshot(fileURLToPath(new URL('./expected/denied-grant-report.txt', import.meta.url))) + } finally { + icacls(target, '/remove:d', `*${meSid}`) + } + }) + + it('reports backup errors and no ACL write when the output path is a file', () => { + const target = makeDir('backup-failure') + icacls(target, '/inheritance:r', '/grant:r', `*${meSid}:(M)`) + const outputFile = join(scratch, 'not-a-directory') + writeFileSync(outputFile, 'Existing contents') + const before = sddlOf(target) + const run = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outputFile, '-GrantFullControl']) + expect(run.code).toBe(1) + const entries = reports(run) + expect(entries).toContainEqual(expect.objectContaining({ kind: 'action', operation: 'backup', status: 'failed', details: expect.objectContaining({ error: expect.any(String) }) })) + expect(entries.filter(entry => entry.kind === 'action' && entry.details.effect === 'acl')).toEqual([]) + expect(sddlOf(target)).toBe(before) + }) + + it('reports unreadable ACLs as missing observations without inventing permissions', () => { + const target = makeDir('unreadable-observation') + const before = sddlOf(target) + const run = runPowerShell(['-Command', ` +function Get-Acl { param([string]$LiteralPath); throw [System.IO.IOException]::new('ACL observation unavailable') } +& '${script.replaceAll("'", "''")}' -Path '${target.replaceAll("'", "''")}' +exit $LASTEXITCODE +`]) + expect(run.code).toBe(0) + expect(run.output).toContain('VERDICT=UNREADABLE') + const entries = reports(run) + expect(entries).toContainEqual(expect.objectContaining({ + kind: 'observation', path: target, status: 'unreadable', + details: { error: expect.stringContaining('ACL observation unavailable') }, + })) + expect(entries.at(-1)).toMatchObject({ status: 'partial', details: { observationFailures: expect.any(Number) } }) + expect(entries.filter(entry => entry.kind === 'action' && entry.details.effect !== 'none')).toEqual([]) + expect(sddlOf(target)).toBe(before) + }) + + it('reports the completed write and recovery command when the verification ACL read throws', () => { + const target = makeDir('post-write-read-failure') + icacls(target, '/inheritance:r', '/grant:r', `*${meSid}:(M)`) + const before = sddlOf(target) + const quote = (value: string): string => `'${value.replaceAll("'", "''")}'` + const wrapper = join(scratch, 'fail-verification.ps1') + // The real grant runs; only its subsequent observation fails in this child. + writeFileSync(wrapper, ` +$global:targetReads = 0 +function Get-Acl { + param([string]$LiteralPath) + if ($LiteralPath -eq ${quote(target)}) { + $global:targetReads++ + if ($global:targetReads -eq 4) { throw [System.IO.IOException]::new('verification read unavailable') } + } + Microsoft.PowerShell.Security\\Get-Acl -LiteralPath $LiteralPath +} +& ${quote(script)} -Path ${quote(target)} -AllowRoot ${quote(scratch)} -Out ${quote(outDir)} -GrantFullControl +exit $LASTEXITCODE +`) + const run = runPowerShell(['-File', wrapper]) + expect(run.code).toBe(2) + const entries = reports(run) + expect(entries).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: 'action', operation: 'grant_dacl', status: 'completed' }), + expect.objectContaining({ kind: 'observation', operation: 'inspect_acl', path: target, status: 'unreadable', details: expect.objectContaining({ error: expect.stringContaining('verification read unavailable') }) }), + expect.objectContaining({ kind: 'verification', operation: 'grant', status: 'failed', details: expect.objectContaining({ recovery: expect.stringContaining('-Restore') }) }), + ])) + expect(sddlOf(target)).not.toBe(before) + const rollback = run.output.split(/\r?\n/u).find(line => line.startsWith('ROLLBACK '))! + pwsh(rollback.slice('ROLLBACK '.length)) + expect(sddlOf(target)).toBe(before) + }, 60_000) + + it('reports a missing path and an already-satisfied grant without mutating either', () => { + const missing = join(scratch, 'missing') + const run = runScript(['-Path', missing, '-AllowRoot', scratch, '-Out', outDir, '-GrantFullControl']) + expect(run.code).toBe(2) + expect(reports(run)).toContainEqual(expect.objectContaining({ kind: 'decision', path: missing, status: 'skipped', reason: expect.stringContaining('does not exist') })) + const healthy = makeDir('already-satisfied') + const before = sddlOf(healthy) + const grant = runScript(['-Path', healthy, '-AllowRoot', scratch, '-Out', outDir, '-GrantFullControl']) + expect(grant.code, grant.output).toBe(0) + expect(reports(grant)).toContainEqual(expect.objectContaining({ kind: 'decision', operation: 'grant', status: 'skipped', reason: expect.stringContaining('already available') })) + expect(sddlOf(healthy)).toBe(before) + }) + + it('reports well-known package groups without treating them as a removable package ACE', () => { + const target = makeDir('well-known-package-groups') + for (const sid of ['S-1-15-2-1', 'S-1-15-2-2']) stamp(target, sid) + const before = sddlOf(target) + const run = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix']) + expect(run.code, run.output).toBe(0) + expect(run.output).toContain('VERDICT=NOT_THIS_CLASS') + expect(reports(run)).toContainEqual(expect.objectContaining({ + kind: 'observation', operation: 'inspect_acl', path: target, + details: expect.objectContaining({ aces: expect.arrayContaining([ + expect.objectContaining({ sid: 'S-1-15-2-1', type: 'Allow' }), + expect.objectContaining({ sid: 'S-1-15-2-2', type: 'Allow' }), + ]) }), + })) + expect(sddlOf(target)).toBe(before) + }) + + it('grants missing rights before removing a package ACE when both problems are present', () => { + const target = makeDir('both') + icacls(target, '/inheritance:r', '/grant:r', `*${meSid}:(M)`) + stamp(target, PACKAGE_SID) + const ownerBefore = ownerOf(target) + const diagnosis = runScript(['-Path', target]) + expect(diagnosis.output, diagnosis.output).toContain('VERDICT=BOTH') + const grant = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-GrantFullControl']) + expect(grant.output, grant.output).toContain('SUMMARY FIXED=0 GRANTED=1 REFUSED=0') + expect(aclLines(target).join('\n')).toContain(PACKAGE_SID) + const fix = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix']) + expect(fix.output, fix.output).toContain('SUMMARY FIXED=1 GRANTED=0 REFUSED=0') + expect(aclLines(target).join('\n')).not.toContain(PACKAGE_SID) + expect(ownerOf(target)).toBe(ownerBefore) + }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/expected/denied-grant-report.txt b/packages/sandbox/sandbox-windows-acl/tests/expected/denied-grant-report.txt new file mode 100644 index 0000000000..1d0dbbb0f0 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/expected/denied-grant-report.txt @@ -0,0 +1,8 @@ +decision classify PRECONDITION path={{target}}: The caller cannot open the requested object with both WRITE_DAC and WRITE_OWNER. The reported allow and deny ACEs are evidence, not an attribution to one rule. +decision backup selected path={{target}}: All recovery files must be written successfully before attempting the ACL change. +action backup started path={{target}}: Save the original DACL and an independent recovery command before changing permissions. +action backup completed path={{target}}: Save the original DACL and an independent recovery command before changing permissions. +action grant_dacl started path={{target}}: WRITE_OWNER is missing and WRITE_DAC is available; add a FullControl allow ACE for {{caller_sid}} while preserving deny ACEs, owner and SACL. +action grant_dacl completed path={{target}}: WRITE_OWNER is missing and WRITE_DAC is available; add a FullControl allow ACE for {{caller_sid}} while preserving deny ACEs, owner and SACL. +verification grant failed path={{target}}: The DACL write completed; recheck effective access before claiming that provisioning can succeed. No automatic rollback is performed. +summary grant failed path={{target}}: Operation completion records API execution; verification records the observed result. Only rerunning the original confined operation can confirm its failure is resolved. diff --git a/packages/sandbox/sandbox-windows-acl/tests/expected/parent-package-report.jsonl b/packages/sandbox/sandbox-windows-acl/tests/expected/parent-package-report.jsonl new file mode 100644 index 0000000000..5d692e3919 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/expected/parent-package-report.jsonl @@ -0,0 +1,5 @@ +{"kind":"observation","operation":"inspect_acl","path":"{{parent}}\\child","status":"read","reason":"Read the ACL and check effective WRITE_DAC and WRITE_OWNER; observed ACEs alone do not identify which rule caused a denial.","details":{"fixturePackageAces":[]}} +{"kind":"observation","operation":"inspect_acl","path":"{{parent}}","status":"read","reason":"Read the ACL and check effective WRITE_DAC and WRITE_OWNER; observed ACEs alone do not identify which rule caused a denial.","details":{"fixturePackageAces":[{"sid":"S-1-15-2-1-2-3-4","type":"Allow","rights":"ReadAndExecute, Synchronize","inherited":false,"inheritance":"None","propagation":"None"}]}} +{"kind":"decision","operation":"classify","path":"{{parent}}\\child","status":"CULPRIT","reason":"Package allow ACEs were observed. Required access checks determine whether the caller can perform the requested repair; other causes remain possible.","details":{"packageObjects":["{{parent}}"]}} +{"kind":"decision","operation":"diagnose","path":"{{parent}}\\child","status":"skipped","reason":"No mutation mode was requested; all reported ACLs were left unchanged.","details":{}} +{"kind":"summary","operation":"diagnose","path":"{{parent}}\\child","status":"completed","reason":"Operation completion records API execution; verification records the observed result. Only rerunning the original confined operation can confirm its failure is resolved.","details":{"exitCode":0,"operations":[{"id":1,"operation":"initialize","path":"","effect":"none","status":"completed"}],"automaticRollback":false}} diff --git a/scripts/build-exe-for-python-sdk-assets.spec.ts b/scripts/build-exe-for-python-sdk-assets.spec.ts index 4debf6fdc5..0a963f3f1e 100644 --- a/scripts/build-exe-for-python-sdk-assets.spec.ts +++ b/scripts/build-exe-for-python-sdk-assets.spec.ts @@ -23,6 +23,7 @@ describe('Python runtime executable assets', () => { expect(result.status).toBe(0) expect(result.stdout).toContain('node_modules/@deepseek-ai/dsh-web-frontend/dist/**/*') expect(result.stdout).toContain('node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*') + expect(result.stdout).toContain('node_modules/@deepseek-ai/dsh-sandbox-windows-acl/assets/**/*') expect(result.stdout).not.toContain('node_modules/**/*.py') }) }) diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index ba4f2bc726..e2000c960b 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -62,6 +62,8 @@ const ASSET_GLOBS = [ 'node_modules/@deepseek-ai/dsh-web-frontend/dist/**/*', // skill-badge resolves both Markdown and image resources through import.meta.url. 'node_modules/@deepseek-ai/dsh-skill-badge/assets/**/*', + // The diagnosis provider extracts its PowerShell script for an external interpreter. + 'node_modules/@deepseek-ai/dsh-sandbox-windows-acl/assets/**/*', ] const PLATFORMS = ['linux', 'macos', 'win'] as const diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 9d7a929446..4631258c16 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -770,6 +770,7 @@ describe('Node 24 lane ownership', () => { expect(subject.find(item => item.id === 'built-bin-smoke')?.args).toEqual( expect.arrayContaining([ 'apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts', + 'apps/desktop/tests/acl-skill.built.e2e.ts', 'packages/subprocess/subprocess-local/tests/spawn-runner-built.e2e.ts', 'packages/subagent/subagent-codex/tests/loader-composition.e2e.ts', 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index d0c4773c66..e1e2de9c21 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -855,6 +855,7 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { 'apps/cli/tests/profiles/headless/tests/keyless-smoke.e2e.ts', 'apps/cli/tests/profiles/headless/tests/source-tool.built.e2e.ts', 'apps/cli/tests/built-bin.e2e.ts', + 'apps/desktop/tests/acl-skill.built.e2e.ts', 'packages/host/directory-picker-native/tests/built-worker.e2e.ts', 'packages/sdk/server/tests/built-scope-carrier.e2e.ts', 'packages/deliverables/tool-present/tests/built-errors.e2e.ts', diff --git a/snapshots/session/headless.snapshot.ts b/snapshots/session/headless.snapshot.ts index 5135ae4748..c5524284ac 100644 --- a/snapshots/session/headless.snapshot.ts +++ b/snapshots/session/headless.snapshot.ts @@ -475,6 +475,11 @@ async function seedWorkspace(scenario: HeadlessScenario, cwd: string): Promise Promise> = { + async 'windows-acl-skill'(cwd) { + const target = join(cwd, '.dsh', 'skills', 'diagnose-windows-sandbox-acl', 'SKILL.md') + await mkdir(dirname(target), { recursive: true }) + await copyFile(join(repoRoot, 'packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md'), target) + }, async 'office-skills'(cwd) { await cp(join(repoRoot, 'packages/skill/skill-office/assets'), join(cwd, 'office-skills'), { recursive: true }) await symlink(process.execPath, join(cwd, 'office-node')) diff --git a/snapshots/session/windows-acl-skill/session.v4.jsonl b/snapshots/session/windows-acl-skill/session.v4.jsonl new file mode 100644 index 0000000000..ddf043ce64 --- /dev/null +++ b/snapshots/session/windows-acl-skill/session.v4.jsonl @@ -0,0 +1,23 @@ +{"type":"session","version":4,"id":"{{session:1}}","createdAt":1783654655599,"cwd":"{{cwd}}","isSeeded":false,"delegationDepth":0} +{"type":"permission/preset","data":{"preset":"danger-full-access"}} +{"type":"sandbox/mode","data":{"mode":"danger-full-access"}} +{"type":"approval/policy","data":{"policy":"never"}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Load the diagnose-windows-sandbox-acl skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"}]}} +{"type":"turn/start","data":{"turn":1}} +{"type":"agent/inbox/spliced","data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","data":{"turn":1,"step":1}} +{"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Load the diagnose-windows-sandbox-acl skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `diagnose-windows-sandbox-acl`: Use on Windows when the DSH sandbox denies something it promised: cannot write or list in the workspace, or read a path the user should be able to read. Run the bundled script over the failing path and its ancestors; it only reads without a repair switch. It separates an AppContainer package-SID ACE, which it removes under an explicit root, from an unmet ownership or permission precondition, which it repairs with a full-control grant. Never change an owner or edit ACLs outside the script.\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"diagnose-windows-sandbox-acl","description":"Use on Windows when the DSH sandbox denies something it promised: cannot write or list in the workspace, or read a path the user should be able to read. Run the bundled script over the failing path and its ancestors; it only reads without a repair switch. It separates an AppContainer package-SID ACE, which it removes under an explicit root, from an unmet ownership or permission precondition, which it repairs with a full-control grant. Never change an owner or edit ACLs outside the script."},{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"session/title","data":{"title":"Load the diagnose-windows-sandbox-acl sk","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5},"stream":[{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430688,"index":0,"dt":[],"texts":["Load the requested skill."]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699430688,"index":1,"dt":[],"id":"call_skill_load","name":"skill","args":["{\"name\":\"diagnose-windows-sandbox-acl\"}"]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} +{"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/diagnose-windows-sandbox-acl\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Diagnose Windows sandbox ACL failures\n\nA denial is only worth diagnosing when it contradicts what the active mode promises. Establish that first: repairing a denial the sandbox is supposed to produce means weakening confinement for nothing.\n\n## Step 0 — Decide whether this denial is unexpected\n\nEnter the diagnosis only for these:\n\n| Symptom | Why it contradicts the promise |\n|---|---|\n| Cannot write inside the workspace (root or any subdirectory) | `workspace-write` promises writes there |\n| Cannot enumerate inside the workspace (`dir` prints nothing for a directory that has entries) | same |\n| Anything the signed-in user should plainly be able to read cannot be read | reads are never confined in either restricted mode |\n| Only the workspace **root** is denied while its subdirectories work normally | points at one object, not at the whole tree |\n\nStrong but not required corroboration: `Test-Path` returns true while reads fail; `dir` reports an empty directory that is not empty; `cmd` cannot read a file that .NET reads fine.\n\nStop and explain instead of repairing for these — they are the confinement working as documented:\n\n| Symptom | Why it is expected | What to do instead |\n|---|---|---|\n| Writing or deleting **outside** the workspace under `workspace-write` | the fence is doing its job | escalate that one call, or write inside the workspace |\n| Any write under `read-only` | the mode itself | escalate that one call, or switch mode |\n| `spawn EPERM` when a confined command captures a grandchild's output through piped stdio | named-pipe opens are denied in both restricted modes (a Win32 default-SD property, not a permission mistake); anonymous pipes and inherited stdio still work | escalate that one call, or restructure the command to avoid piping |\n| `Add-Type`, `[System.IO.*]::`, COM, or reflection failing with \"only core types\" | `read-only` pwsh runs in ConstrainedLanguage | not a permission problem; escalate or use `workspace-write` |\n\nThen split \"cannot write in the workspace\" by shape, because the two shapes have different causes:\n\n- **Uniformly denied** — the root and every subdirectory fail together: the sandbox could not provision its grant.\n- **Only the root denied** — subdirectories are fine: one object carries something that blocks it.\n\n## Step 1 — Diagnose read-only before changing anything\n\nRun the bundled script over the failing path. It reads ACLs with .NET and native `icacls`, changes nothing, and prints machine-parseable lines:\n\n```powershell\npwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path \n```\n\nIt reports every observed allow and deny ACE on the target and its ancestors, including SID, rights, inheritance and propagation flags, plus the native ACL listing, owner, effective `WRITE_DAC` and `WRITE_OWNER`, caller integrity and sandbox marker names. Well-known groups `S-1-15-2-1` and `S-1-15-2-2` are reported but are not individual package ACEs eligible for removal. Access checks open existing objects without changing contents; Windows accounts for group membership, deny ACEs and inheritance. An unreadable or unrecognized value is unknown, not absent.\n\nEvery `REPORT` line contains a JSON record with `kind`, `operation`, `path`, `status`, `reason` and `details`. Read observations and reasons alongside the verdict: a deny ACE's presence alone does not prove that it caused the failure. Decisions explain skips and refusals. Actions report `started` before execution and `completed` or `failed` afterward; completion means the operation ran, while a separate verification reports whether the intended result was observed. A final summary lists attempted operations, outcomes and available recovery commands, including after a caught exception. If execution ends before a completion or summary record, treat the result as unconfirmed.\n\nThe failing API names the family. DSH's fail-closed errors carry the API and the exact Win32 code, so read the error rather than guessing:\n\n| Error | Meaning |\n|---|---|\n| `GetNamedSecurityInfoW` … (Win32 5) | the object could not be **read** — either it carries a package SID and the caller is below Medium, or the caller has no access to it at all |\n| `SetNamedSecurityInfoW` … (Win32 5) | the object was read but not written — the caller lacks `WRITE_DAC`, `WRITE_OWNER`, or both |\n| `grantWrite` succeeded, yet the confined child still cannot use the directory | the grant landed but the child cannot open the object |\n\nThe script needs a caller that can open the object. `read-only` pwsh cannot run it at all, because ConstrainedLanguage blocks the .NET calls it needs; under `workspace-write` the confined child cannot open an object carrying a package SID, so the diagnosis comes back `READABLE=False` for the very object that caused the problem. In both modes the diagnosis needs an unconfined caller, so escalate once and run the diagnosis together with any repair in that same call (Step 4) — rather than concluding that the path is unreadable or that the failure is something else.\n\n## Step 2 — Read the verdict\n\nFor `UNREADABLE` or `INCOMPLETE`, report the failed observation and its error; do not treat missing evidence as missing permissions or proceed with a repair. `NOT_THIS_CLASS` only rules out the conditions the script inspected, not every cause of the original failure.\n\n| Package SID present | Effective permissions | Verdict | Action |\n|---|---|---|---|\n| yes | the target has both rights and every affected object has `WRITE_DAC` | package ACE found | remove the ACE (Step 4), then verify the original failure |\n| yes | either condition above is unmet | both problems at once | grant full control on the affected object first, then remove the ACE; if `WRITE_DAC` is missing, the user must run the grant from an elevated terminal |\n| no | `WRITE_DAC` present, `WRITE_OWNER` missing | a provisioning precondition is unmet | grant full control (Step 4) |\n| no | `WRITE_DAC` missing | this caller cannot repair the DACL | hand the user the single command to run from an elevated terminal (Step 4) |\n| no | both rights present | not this class | say so and stop; report what was actually found |\n\n## Step 3 — Explain, and apologize\n\nSay what the finding is and what it will cost before doing anything.\n\nApologize on the harness's behalf, in the language the user is writing in, in one or two sentences, then move to the technical explanation. The gap being owned is ours: the harness neither notices the conflict when it provisions the grant nor gives any direction when the denial surfaces, so the user only sees a bare \"Access is denied\" and has to guess. Do not apologize before the cause is confirmed, do not repeat it, do not blame another tool in the apology (the cause belongs in the explanation), and do not let it replace the explanation.\n\nTwo things must reach the user either way:\n\n- **Which shape this is** — one object carrying a foreign ACE, or a directory whose ownership/DACL does not satisfy DSH's documented precondition.\n- **That removing a foreign ACE may be undone** — if whatever tool placed it runs again with this directory as its root, it will place it again. The repair is not a permanent fix.\n\n## Step 4 — Repair, or hand over steps\n\nTwo repairs exist, and both run through the same script so that no ACL is edited by hand.\n\n**Remove a conflicting package-SID ACE.** This also removes that package's access, so explain which SID and permissions will be removed:\n\n```powershell\npwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path -AllowRoot -Out -Fix\n```\n\n**Grant the current user full control when the precondition is unmet:**\n\n```powershell\npwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path -AllowRoot -Out -GrantFullControl\n```\n\nFull control carries both rights this needs: `WRITE_DAC` for the DACL and `WRITE_OWNER` for the mandatory label DSH writes in the same call. **Taking ownership alone does not work** — it yields only `WRITE_DAC`, so the label write still fails. Never change an object's owner, and never \"replace permissions on all child objects\": that erases deny ACEs somebody set deliberately.\n\nBoth modes require `-AllowRoot` to be a containing directory and reject the target or any ancestor if it is a reparse point, including a junction used as `-AllowRoot`. Before mutation they save the native `icacls` listing format, a DACL recovery record, a standalone recovery script and its exact command under `-Out`. The recovery command uses `-Restore` to restore only the original DACL and inheritance protection; it never changes ownership or the mandatory label and remains usable after the session ends. It requires `WRITE_DAC` and enforces the same path restrictions. Run multiple rollback commands in reverse repair order.\n\nAfter a grant, the script rechecks effective `WRITE_DAC` and `WRITE_OWNER`; after removal it checks that package ACEs disappeared and other `icacls` lines stayed unchanged. Refused or failed repairs exit nonzero. A failed verification does not undo a completed DACL write. Report which operation ran, its reason, what verification observed and the recovery command; the script never rolls back automatically. If the requested repair failed after a write, use its emitted recovery command before trying another repair, and report the recovery result. Both repairs write a DACL, which the confined child cannot do, so run the diagnosis and the repair inside one escalated call rather than spending two approvals on the same directory.\n\nThe caller needs effective `WRITE_DAC` to repair the DACL. Ownership often supplies it, but an explicit grant or group grant can also supply it to a non-owner. Follow the script's access result rather than inferring permissions from the owner's name. If the caller lacks `WRITE_DAC`, ask the user to run the exact grant command from an elevated terminal. Elevation does not override every deny or system policy; verify the result.\n\nDo not reach for `sandbox_permissions` to solve a Windows-token permission failure. Escalation gives the script an unconfined caller (Step 1); it does not elevate the Windows token. If the unconfined caller still lacks `WRITE_DAC`, another sandbox escalation cannot supply it.\n\nDo not make the harness trigger UAC either. An agent-initiated `runas` is a privilege-escalation path, the elevated child would run outside both the sandbox and the approval ledger the session log is built from, and a UAC prompt cannot be routed through this harness's approval channel. Hand the user the single exact command to run from an elevated terminal, and say plainly what it changes.\n\nNever recommend running the whole agent elevated instead: that is a downgrade, and the community habit of \"just run it as administrator\" trades the isolation away for a working prompt.\n\nAlso stop short when the directory cannot be repaired this way at all, and say which applies: the owner is a system principal such as `TrustedInstaller`, the path is one the user has no business owning, or the volume is not ACL-capable. Moving the workspace is then the honest answer.\n\n## Step 5 — Verify against the original failure\n\nRe-run the operation that failed, from the same confined context. The repair is only done when that operation succeeds. Whether diagnosis, repair or verification succeeds or fails, tell the user what the script observed, what it changed or left untouched, why it took those actions, and what remains unverified. Keep observations separate from suspected causes. If the original operation still fails, report the new error verbatim rather than starting another round of changes.\n\n## Step 6 — Report recurrence\n\nTell the user the condition that brings it back, so they can recognize it instead of re-diagnosing it.\n\n## Never\n\n- Never write, create, or delete a file to *test* permissions. Writing to probe is how an existing file gets clobbered, and it answers nothing the read-only diagnosis does not. (This has already happened once in this repository: a probe overwrote a workspace-root `AGENTS.md` that no version control could restore.)\n- Never edit ACLs by hand — use the bundled repairs or their generated recovery command. The recovery script writes only the saved DACL; do not replace it with a security-descriptor write that also attempts to change the SACL.\n- Never repair anything outside the directory being diagnosed, and never widen `-AllowRoot` beyond the tree that legitimately contains the failing path — its purpose is to keep a repair from reaching a neighbour of the thing that failed.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":1}} +{"type":"step/start","data":{"turn":1,"step":2}} +{"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4},"stream":[{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430709,"index":0,"dt":[],"texts":["The skill is loaded."]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699430709,"index":1,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} +{"type":"step/end","data":{"turn":1,"step":2}} +{"type":"turn/end","data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/snapshots/session/windows-acl-skill/snapshot.yml b/snapshots/session/windows-acl-skill/snapshot.yml new file mode 100644 index 0000000000..fd9c8d4202 --- /dev/null +++ b/snapshots/session/windows-acl-skill/snapshot.yml @@ -0,0 +1,9 @@ +version: 1 +scenario: windows-acl-skill +profile: headless +composition: default +recording: authored +header: + class: skill +workspace: + setup: windows-acl-skill diff --git a/snapshots/session/windows-acl-skill/workspace/.dsh/skills/model-only-skill/SKILL.md b/snapshots/session/windows-acl-skill/workspace/.dsh/skills/model-only-skill/SKILL.md new file mode 100644 index 0000000000..ac7c02e29f --- /dev/null +++ b/snapshots/session/windows-acl-skill/workspace/.dsh/skills/model-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: model-only-skill +description: Prove user-disabled skills remain available to the model. +user-invocable: false +--- + +Follow these model-only snapshot instructions. diff --git a/snapshots/session/windows-acl-skill/workspace/.dsh/skills/snapshot-skill/SKILL.md b/snapshots/session/windows-acl-skill/workspace/.dsh/skills/snapshot-skill/SKILL.md new file mode 100644 index 0000000000..b0816d6bee --- /dev/null +++ b/snapshots/session/windows-acl-skill/workspace/.dsh/skills/snapshot-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: snapshot-skill +description: Exercise project skill discovery and loading in snapshot tests. +--- + +Follow these snapshot-only instructions. +Resolve referenced resources relative to this skill directory. diff --git a/snapshots/session/windows-acl-skill/workspace/.dsh/skills/user-only-skill/SKILL.md b/snapshots/session/windows-acl-skill/workspace/.dsh/skills/user-only-skill/SKILL.md new file mode 100644 index 0000000000..4b02550d15 --- /dev/null +++ b/snapshots/session/windows-acl-skill/workspace/.dsh/skills/user-only-skill/SKILL.md @@ -0,0 +1,7 @@ +--- +name: user-only-skill +description: Prove model-disabled skills stay outside the model catalog. +disable-model-invocation: true +--- + +Follow these user-only snapshot instructions. From b876f825c7f8c13551bc2462e06d05432a4c35ab Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 25 Sep 2026 21:36:48 +0800 Subject: [PATCH 03/86] fix(sandbox): stop failed ACL repairs and preserve diagnostic reports --- .../sandbox-windows-acl/README.i18n.yaml | 4 +- .../sandbox/sandbox-windows-acl/README.md | 4 +- .../sandbox/sandbox-windows-acl/README.zh.md | 4 +- .../diagnose-windows-sandbox-acl/SKILL.md | 140 +++------- .../scripts/diagnose-windows-sandbox-acl.ps1 | 245 +++++++++++++----- .../tests/acl-skill.spec.ts | 5 +- .../tests/diagnose-script.spec.ts | 193 +++++++++++--- .../tests/expected/denied-grant-report.txt | 8 +- .../expected/parent-package-report.jsonl | 2 +- .../windows-acl-skill/session.v4.jsonl | 4 +- 10 files changed, 375 insertions(+), 234 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 896ea14a9f..992b5a53cc 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -27,8 +27,8 @@ en: 289e65bddf20dd79 zh: 44b9fcedf944546a /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery: - en: 966f167031348e70 - zh: 5bb476df9aee0e3a + en: c3a2ebffb3ca81d1 + zh: 1b9b392fe084aba5 /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation: en: c2cde86c85c2c619 zh: a29a114e591f9d3f diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index c513b5cd87..e7bff61376 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -77,9 +77,9 @@ Temp isolation is per live session/workspace pair: sessions sharing a workspace A denial this backend cannot explain — the child cannot open an object it was granted, or the provisioning call itself fails — is diagnosed by the `diagnose-windows-sandbox-acl` skill bundled in `assets/`. `registerAclDiagnosisSkill` contributes it wherever `dsh-sandbox-local` composes with a skill registry on Windows. Registration copies the resources into a private temporary directory readable by external PowerShell, including in ASAR and SEA deployments; fiber disposal unregisters the provider and removes its copy. Missing packaged assets fail registration. -The script diagnoses without changing files or ACLs; effective `WRITE_DAC` and `WRITE_OWNER` checks open existing objects without modifying contents. `-Fix` removes package allow ACEs and `-GrantFullControl` adds the caller's missing rights, including when a package ACE is present. Both require an unconfined caller and `WRITE_DAC`, enforce a containing `-AllowRoot`, reject every reparse point in the target's ancestor chain, preserve ownership and save recovery files under `-Out` before mutation. The emitted `-Restore` command writes only the saved DACL and its inheritance protection, preserves the SACL, enforces the same path restrictions and remains usable after the session ends. Apply rollback commands in reverse repair order. Refused or failed mutations exit nonzero. +The script diagnoses without changing ACLs or existing file contents; effective `WRITE_DAC` and `WRITE_OWNER` checks open existing objects without modifying contents. `-Fix` removes package allow ACEs only when the requested path has both rights; `-GrantFullControl` adds the caller's missing rights while preserving explicit denies. The modes run separately, require an unconfined caller and `WRITE_DAC`, enforce a containing `-AllowRoot`, reject every reparse point in the target's ancestor chain, preserve ownership and save recovery files under `-Out` before mutation. Failed repairs automatically restore attempted DACL changes from that invocation in reverse order and exit nonzero, even when restoration succeeds. The emitted standalone `-Restore` commands preserve owner and SACL and enforce the same path restrictions. Restore earlier successful invocations in reverse order if a later step fails. -Every run emits `REPORT` JSON lines describing observations, decisions, operation attempts and results with their reasons, followed by verification and summary records. ACL observations include allow and deny ACEs with SID, rights and inheritance, native listings, effective access and read errors; unknown values remain distinct from absent permissions. Mutations report their start before executing, so a caught exception retains prior results and recovery commands. Operation completion does not imply successful verification or automatic rollback. The [bundled skill](assets/diagnose-windows-sandbox-acl/SKILL.md) defines how the model interprets these records and reports remaining uncertainty. +Every run emits `REPORT` JSON records describing observations, decisions, operation attempts and results with their reasons, followed by verification and summary records. `-Compact -Out ` saves all records in a unique JSONL file and prints a summary containing its path, every inspected path, findings, ACL actions, verification, rollback status, pending recovery commands and `nextAction`. ACL observations include allow and deny ACEs with SID, rights and inheritance, native listings, effective access and read errors; unknown values remain distinct from absent permissions. Mutations report their start before executing, so a caught exception retains prior results and recovery commands. A completed write does not imply verified repair or recovery. The [bundled skill](assets/diagnose-windows-sandbox-acl/SKILL.md) requires stopping after failure and verifying the original operation in its confined context after successful repair. Inspection visits the requested object and each ancestor through the filesystem root. Each ACL observation's `path` identifies the inspected object; the classification's `details.packageObjects` lists objects carrying package allow ACEs without establishing the cause of the original failure. The [parent-package example](tests/expected/parent-package-report.jsonl) retains the fixture paths and shows selected fields, with `fixturePackageAces` projecting only the synthetic test SID from `aces`; host-owned ACEs and ancestors are omitted. The [failed-grant example](tests/expected/denied-grant-report.txt) retains operation paths and reasons. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 7ebc10bcad..e2687a4f77 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -77,9 +77,9 @@ rmSync(tempDir, { recursive: true, force: true }) 此后端无法解释的拒绝——子进程打不开本已授权的对象,或授权调用本身失败——交给 `assets/` 中随包发布的 `diagnose-windows-sandbox-acl` 技能诊断。只要 `dsh-sandbox-local` 在 Windows 上与技能注册表一起组合,`registerAclDiagnosisSkill` 就会注册它。注册时会把资源复制到外部 PowerShell 可读取的私有临时目录,ASAR 和 SEA 部署也适用;fiber 释放时注销提供者并删除副本。缺少打包资源会导致注册失败。 -脚本诊断时不修改文件或 ACL;有效 `WRITE_DAC` 和 `WRITE_OWNER` 检查只打开已有对象,不修改内容。`-Fix` 删除包 SID 的允许 ACE,`-GrantFullControl` 补充调用者缺少的权限,即使包 ACE 同时存在也可执行。两者都要求不受限的调用者及 `WRITE_DAC`,强制目标位于 `-AllowRoot` 内,拒绝目标祖先链上的所有重解析点,保持所有者不变,并在修改前将恢复文件保存到 `-Out`。输出的 `-Restore` 命令只写回原 DACL 及其继承保护状态,保留 SACL,执行相同的路径限制,且在会话结束后仍可使用。多次修复应按相反顺序回滚。被拒绝或失败的修改均以非零状态退出。 +脚本诊断时不修改 ACL 或已有文件内容;有效 `WRITE_DAC` 和 `WRITE_OWNER` 检查只打开已有对象,不修改内容。`-Fix` 仅在请求路径具有这两项权限时删除包 SID 的允许 ACE;`-GrantFullControl` 补充调用者缺少的权限,同时保留显式拒绝条目。两种模式分别执行,要求不受限的调用者及 `WRITE_DAC`,强制目标位于 `-AllowRoot` 内,拒绝目标祖先链上的所有重解析点,保持所有者不变,并在修改前将恢复文件保存到 `-Out`。修复失败时,脚本按相反顺序自动恢复本次调用中尝试修改的 DACL,即使恢复成功也以非零状态退出。输出的独立 `-Restore` 命令保留所有者和 SACL,并执行相同的路径限制。如果后续步骤失败,还应按相反顺序恢复此前成功调用的修改。 -每次执行都会输出 `REPORT` JSON 行,说明观察、决策、操作尝试及结果和各自原因,并记录验证结果与最终摘要。ACL 观察包含允许和拒绝 ACE 的 SID、权限与继承信息、原生列表、有效权限及读取错误;未知值与权限缺失明确区分。修改在执行前报告开始状态,捕获异常后仍保留此前结果和恢复命令。操作完成不代表验证成功或已经自动回滚。[内置技能](assets/diagnose-windows-sandbox-acl/SKILL.md) 规定模型如何解读这些记录并报告尚未确认的事项。 +每次执行都会输出 `REPORT` JSON 记录,说明观察、决策、操作尝试及结果和各自原因,并记录验证结果与最终摘要。`-Compact -Out ` 将全部记录保存到独立 JSONL 文件,仅打印包含报告路径、全部分析路径、发现、ACL 操作、验证结果、回滚状态、待执行恢复命令和 `nextAction` 的摘要。ACL 观察包含允许和拒绝 ACE 的 SID、权限与继承信息、原生列表、有效权限及读取错误;未知值与权限缺失明确区分。修改在执行前报告开始状态,捕获异常后仍保留此前结果和恢复命令。写入完成不代表修复或恢复已通过验证。[内置技能](assets/diagnose-windows-sandbox-acl/SKILL.md) 要求失败后停止,修复成功后在原受限环境中验证最初失败的操作。 检查会从请求对象逐级访问祖先,直到文件系统根目录。每条 ACL 观察的 `path` 标识实际检查的对象;分类结果的 `details.packageObjects` 列出包含包允许 ACE 的对象,但不能据此确认原始失败的原因。[父目录包 SID 示例](tests/expected/parent-package-report.jsonl) 保留测试目录路径并展示选定字段,其中 `fixturePackageAces` 只从 `aces` 提取合成测试 SID,省略机器自带的 ACE 和祖先目录。[授权失败示例](tests/expected/denied-grant-report.txt) 保留操作路径和原因。 diff --git a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md index 97acd07a53..db3b0701f2 100644 --- a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md +++ b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md @@ -1,126 +1,46 @@ --- name: diagnose-windows-sandbox-acl -description: 'Use on Windows when the DSH sandbox denies something it promised: cannot write or list in the workspace, or read a path the user should be able to read. Run the bundled script over the failing path and its ancestors; it only reads without a repair switch. It separates an AppContainer package-SID ACE, which it removes under an explicit root, from an unmet ownership or permission precondition, which it repairs with a full-control grant. Never change an owner or edit ACLs outside the script.' +description: 'Use on Windows for unexpected DSH sandbox access denials: workspace writes or listing fail, or an ordinarily readable path cannot be read. The bundled script inspects the path and every ancestor, reports observations and action reasons, and supports scoped, backed-up ACL repairs. Expected confinement denials need no ACL repair.' --- # Diagnose Windows sandbox ACL failures -A denial is only worth diagnosing when it contradicts what the active mode promises. Establish that first: repairing a denial the sandbox is supposed to produce means weakening confinement for nothing. +**Stop after any failed/refused repair or failed verification.** A completed DACL write is not a successful repair. Never continue from a failed grant to `-Fix`, repeat the grant, or remove a deny ACE. The script automatically restores attempted changes from the failed invocation; verify its rollback result. Restore earlier successful invocations in reverse order using their exact recovery commands, then stop. A later backup alone cannot undo earlier repairs. -## Step 0 — Decide whether this denial is unexpected +## Decide whether diagnosis applies -Enter the diagnosis only for these: +Diagnose unexpected writes/listing denied inside a `workspace-write` workspace, or reads the signed-in user should plainly have. Root-only failure and uniform failure are both eligible. -| Symptom | Why it contradicts the promise | +Stop and explain expected denials: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, or ConstrainedLanguage errors for .NET/COM/reflection. Use an authorized unconfined call or another permitted operation; do not repair ACLs for these cases. + +## Read the report before choosing a repair + +Resolve the script from this skill's resource directory. Run it **unconfined from the first diagnostic call**: the confined token cannot inspect the conflicting object. This does not elevate the Windows token. Use one path per invocation; do not repeat `-Path` or pass a comma-separated string to `pwsh -File`. + +```powershell +& '\scripts\diagnose-windows-sandbox-acl.ps1' -Path '' -Out '' -Compact +exit $LASTEXITCODE +``` + +Substitute full, quoted paths. Choose a persistent, user-owned `-Out` directory, preferably beside the failing workspace within the authorized tree. Never put reports or backups in the skill resource directory: it is deleted when the skill unloads. `-Compact` saves a unique full JSONL report and prints one `REPORT` summary. Diagnosis changes no ACL or existing content; it only writes this requested report. The summary includes all `inspectedPaths`, findings, decisions, ACL actions, verification, rollback state and `nextAction`. Use the summary to decide; read only specific records from the saved report when evidence is missing, without dumping the whole report or reading the implementation to rediscover these instructions. + +Each full record has `kind`, `operation`, `path`, `status`, `reason`, `details`. Unknown observations remain unknown. A deny ACE's presence alone does not establish causation. DSH provisions `S-1-4-*` workspace grants and an Everyone `DeleteSubdirectoriesAndFiles` deny; these are expected confinement entries, not package conflicts or automatically unresolved faults. Coexisting package ACEs may still block the confined child even when provisioning fails first; do not dismiss either finding. `completed` actions only confirm API execution. Check verification and the final summary. Missing summary means unconfirmed completion: inspect the report and recovery artifacts before doing anything else. + +| Verdict | Next step, only with complete observations | |---|---| -| Cannot write inside the workspace (root or any subdirectory) | `workspace-write` promises writes there | -| Cannot enumerate inside the workspace (`dir` prints nothing for a directory that has entries) | same | -| Anything the signed-in user should plainly be able to read cannot be read | reads are never confined in either restricted mode | -| Only the workspace **root** is denied while its subdirectories work normally | points at one object, not at the whole tree | +| `CULPRIT` | Explain the affected paths and individual package allow SIDs; select `-Fix`. | +| `PRECONDITION` | Explain missing effective `WRITE_DAC`/`WRITE_OWNER`; select `-GrantFullControl` if the caller has `WRITE_DAC`. | +| `BOTH` | Grant on the affected object first. Only after verified success may a separate invocation use `-Fix`. Failure ends this sequence. | +| `UNREADABLE`, `INCOMPLETE`, `NOT_THIS_CLASS` | Report observations and stop; do not infer a safe repair. | -Strong but not required corroboration: `Test-Path` returns true while reads fail; `dir` reports an empty directory that is not empty; `cmd` cannot read a file that .NET reads fine. +## Run the selected repair -Stop and explain instead of repairing for these — they are the confinement working as documented: +Explain the finding and cost first, and briefly apologize for the harness's unhelpful denial after confirming the conflict. `-Fix` removes individual `S-1-15-2-*` allow ACEs and their access; well-known groups ending in 1 or 2 are preserved. `-GrantFullControl` adds a current-user allow ACE; it cannot cancel an explicit deny. Both preserve owner and SACL. Full control supplies `WRITE_DAC` and `WRITE_OWNER`; taking ownership alone does not. -| Symptom | Why it is expected | What to do instead | -|---|---|---| -| Writing or deleting **outside** the workspace under `workspace-write` | the fence is doing its job | escalate that one call, or write inside the workspace | -| Any write under `read-only` | the mode itself | escalate that one call, or switch mode | -| `spawn EPERM` when a confined command captures a grandchild's output through piped stdio | named-pipe opens are denied in both restricted modes (a Win32 default-SD property, not a permission mistake); anonymous pipes and inherited stdio still work | escalate that one call, or restructure the command to avoid piping | -| `Add-Type`, `[System.IO.*]::`, COM, or reflection failing with "only core types" | `read-only` pwsh runs in ConstrainedLanguage | not a permission problem; escalate or use `workspace-write` | +Append `-AllowRoot ''` and **exactly one** of `-Fix` or `-GrantFullControl` to the diagnostic command, before `exit`. Keep `-Compact -Out`. Targets must be strictly inside that root; reparse paths are refused. Never widen the authorized root to reach an ancestor. Missing effective `WRITE_DAC` in the unconfined caller requires handing the user an exact command for their elevated terminal, not another sandbox escalation. Never initiate UAC/`runas` or run the whole agent elevated. -Then split "cannot write in the workspace" by shape, because the two shapes have different causes: +Follow `nextAction`: `stop` ends repairs; `restore_pending_then_stop` requires the reported recovery commands in their listed order, then stop. Verified rollback does not turn a failed repair into success. Keep full report/backup paths and exact recovery commands for every invocation; never abbreviate executable paths. On recovery failure, stop and report the remaining commands without more repairs. -- **Uniformly denied** — the root and every subdirectory fail together: the sandbox could not provision its grant. -- **Only the root denied** — subdirectories are fine: one object carries something that blocks it. +For `verify_original_confined_operation`, repeat the original failed operation in the original confined context. If it still fails, restore this workflow's successful repairs in reverse order, report the error and stop. Do not start another repair cycle. -## Step 1 — Diagnose read-only before changing anything - -Run the bundled script over the failing path. It reads ACLs with .NET and native `icacls`, changes nothing, and prints machine-parseable lines: - -```powershell -pwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path -``` - -It reports every observed allow and deny ACE on the target and its ancestors, including SID, rights, inheritance and propagation flags, plus the native ACL listing, owner, effective `WRITE_DAC` and `WRITE_OWNER`, caller integrity and sandbox marker names. Well-known groups `S-1-15-2-1` and `S-1-15-2-2` are reported but are not individual package ACEs eligible for removal. Access checks open existing objects without changing contents; Windows accounts for group membership, deny ACEs and inheritance. An unreadable or unrecognized value is unknown, not absent. - -Every `REPORT` line contains a JSON record with `kind`, `operation`, `path`, `status`, `reason` and `details`. Read observations and reasons alongside the verdict: a deny ACE's presence alone does not prove that it caused the failure. Decisions explain skips and refusals. Actions report `started` before execution and `completed` or `failed` afterward; completion means the operation ran, while a separate verification reports whether the intended result was observed. A final summary lists attempted operations, outcomes and available recovery commands, including after a caught exception. If execution ends before a completion or summary record, treat the result as unconfirmed. - -The failing API names the family. DSH's fail-closed errors carry the API and the exact Win32 code, so read the error rather than guessing: - -| Error | Meaning | -|---|---| -| `GetNamedSecurityInfoW` … (Win32 5) | the object could not be **read** — either it carries a package SID and the caller is below Medium, or the caller has no access to it at all | -| `SetNamedSecurityInfoW` … (Win32 5) | the object was read but not written — the caller lacks `WRITE_DAC`, `WRITE_OWNER`, or both | -| `grantWrite` succeeded, yet the confined child still cannot use the directory | the grant landed but the child cannot open the object | - -The script needs a caller that can open the object. `read-only` pwsh cannot run it at all, because ConstrainedLanguage blocks the .NET calls it needs; under `workspace-write` the confined child cannot open an object carrying a package SID, so the diagnosis comes back `READABLE=False` for the very object that caused the problem. In both modes the diagnosis needs an unconfined caller, so escalate once and run the diagnosis together with any repair in that same call (Step 4) — rather than concluding that the path is unreadable or that the failure is something else. - -## Step 2 — Read the verdict - -For `UNREADABLE` or `INCOMPLETE`, report the failed observation and its error; do not treat missing evidence as missing permissions or proceed with a repair. `NOT_THIS_CLASS` only rules out the conditions the script inspected, not every cause of the original failure. - -| Package SID present | Effective permissions | Verdict | Action | -|---|---|---|---| -| yes | the target has both rights and every affected object has `WRITE_DAC` | package ACE found | remove the ACE (Step 4), then verify the original failure | -| yes | either condition above is unmet | both problems at once | grant full control on the affected object first, then remove the ACE; if `WRITE_DAC` is missing, the user must run the grant from an elevated terminal | -| no | `WRITE_DAC` present, `WRITE_OWNER` missing | a provisioning precondition is unmet | grant full control (Step 4) | -| no | `WRITE_DAC` missing | this caller cannot repair the DACL | hand the user the single command to run from an elevated terminal (Step 4) | -| no | both rights present | not this class | say so and stop; report what was actually found | - -## Step 3 — Explain, and apologize - -Say what the finding is and what it will cost before doing anything. - -Apologize on the harness's behalf, in the language the user is writing in, in one or two sentences, then move to the technical explanation. The gap being owned is ours: the harness neither notices the conflict when it provisions the grant nor gives any direction when the denial surfaces, so the user only sees a bare "Access is denied" and has to guess. Do not apologize before the cause is confirmed, do not repeat it, do not blame another tool in the apology (the cause belongs in the explanation), and do not let it replace the explanation. - -Two things must reach the user either way: - -- **Which shape this is** — one object carrying a foreign ACE, or a directory whose ownership/DACL does not satisfy DSH's documented precondition. -- **That removing a foreign ACE may be undone** — if whatever tool placed it runs again with this directory as its root, it will place it again. The repair is not a permanent fix. - -## Step 4 — Repair, or hand over steps - -Two repairs exist, and both run through the same script so that no ACL is edited by hand. - -**Remove a conflicting package-SID ACE.** This also removes that package's access, so explain which SID and permissions will be removed: - -```powershell -pwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path -AllowRoot -Out -Fix -``` - -**Grant the current user full control when the precondition is unmet:** - -```powershell -pwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path -AllowRoot -Out -GrantFullControl -``` - -Full control carries both rights this needs: `WRITE_DAC` for the DACL and `WRITE_OWNER` for the mandatory label DSH writes in the same call. **Taking ownership alone does not work** — it yields only `WRITE_DAC`, so the label write still fails. Never change an object's owner, and never "replace permissions on all child objects": that erases deny ACEs somebody set deliberately. - -Both modes require `-AllowRoot` to be a containing directory and reject the target or any ancestor if it is a reparse point, including a junction used as `-AllowRoot`. Before mutation they save the native `icacls` listing format, a DACL recovery record, a standalone recovery script and its exact command under `-Out`. The recovery command uses `-Restore` to restore only the original DACL and inheritance protection; it never changes ownership or the mandatory label and remains usable after the session ends. It requires `WRITE_DAC` and enforces the same path restrictions. Run multiple rollback commands in reverse repair order. - -After a grant, the script rechecks effective `WRITE_DAC` and `WRITE_OWNER`; after removal it checks that package ACEs disappeared and other `icacls` lines stayed unchanged. Refused or failed repairs exit nonzero. A failed verification does not undo a completed DACL write. Report which operation ran, its reason, what verification observed and the recovery command; the script never rolls back automatically. If the requested repair failed after a write, use its emitted recovery command before trying another repair, and report the recovery result. Both repairs write a DACL, which the confined child cannot do, so run the diagnosis and the repair inside one escalated call rather than spending two approvals on the same directory. - -The caller needs effective `WRITE_DAC` to repair the DACL. Ownership often supplies it, but an explicit grant or group grant can also supply it to a non-owner. Follow the script's access result rather than inferring permissions from the owner's name. If the caller lacks `WRITE_DAC`, ask the user to run the exact grant command from an elevated terminal. Elevation does not override every deny or system policy; verify the result. - -Do not reach for `sandbox_permissions` to solve a Windows-token permission failure. Escalation gives the script an unconfined caller (Step 1); it does not elevate the Windows token. If the unconfined caller still lacks `WRITE_DAC`, another sandbox escalation cannot supply it. - -Do not make the harness trigger UAC either. An agent-initiated `runas` is a privilege-escalation path, the elevated child would run outside both the sandbox and the approval ledger the session log is built from, and a UAC prompt cannot be routed through this harness's approval channel. Hand the user the single exact command to run from an elevated terminal, and say plainly what it changes. - -Never recommend running the whole agent elevated instead: that is a downgrade, and the community habit of "just run it as administrator" trades the isolation away for a working prompt. - -Also stop short when the directory cannot be repaired this way at all, and say which applies: the owner is a system principal such as `TrustedInstaller`, the path is one the user has no business owning, or the volume is not ACL-capable. Moving the workspace is then the honest answer. - -## Step 5 — Verify against the original failure - -Re-run the operation that failed, from the same confined context. The repair is only done when that operation succeeds. Whether diagnosis, repair or verification succeeds or fails, tell the user what the script observed, what it changed or left untouched, why it took those actions, and what remains unverified. Keep observations separate from suspected causes. If the original operation still fails, report the new error verbatim rather than starting another round of changes. - -## Step 6 — Report recurrence - -Tell the user the condition that brings it back, so they can recognize it instead of re-diagnosing it. - -## Never - -- Never write, create, or delete a file to *test* permissions. Writing to probe is how an existing file gets clobbered, and it answers nothing the read-only diagnosis does not. (This has already happened once in this repository: a probe overwrote a workspace-root `AGENTS.md` that no version control could restore.) -- Never edit ACLs by hand — use the bundled repairs or their generated recovery command. The recovery script writes only the saved DACL; do not replace it with a security-descriptor write that also attempts to change the SACL. -- Never repair anything outside the directory being diagnosed, and never widen `-AllowRoot` beyond the tree that legitimately contains the failing path — its purpose is to keep a repair from reaching a neighbour of the thing that failed. +Use the user's language for progress and final reporting. Concisely report observations and analyzed paths (including relevant ancestors), actions and reasons, verification, and the next step; link full reports. Label any authorized unconfined fallback as such, not as successful repair. Explain that another tool may recreate a removed package ACE. Never write probe files, edit ACLs by hand, change owners, erase denies, or replace child permissions recursively. Do not provide manual ACL-edit commands for the user either: only bundled repairs and generated recovery commands are supported; unresolved denies require permission-policy review or moving the workspace. diff --git a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 index 469498817d..3173bab5ab 100644 --- a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 +++ b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 @@ -9,11 +9,12 @@ signed-in user should plainly have. This script reports why such an object is unreadable or unopenable, and repairs package-ACE conflicts or missing rights. - Diagnosis (the default) never writes anything: it reads each requested path + Diagnosis (the default) never changes ACLs: it reads each requested path and every ancestor with .NET and native icacls, and prints machine-parseable lines for the caller to interpret. REPORT JSON records distinguish observed facts, decisions and their reasons, attempted operations, and verification. A caught exception still emits the operation history and recovery commands. + Failed repairs restore attempted DACL changes in reverse order and stop. Repair (-Fix) removes ONLY allow ACEs whose SID is an AppContainer package SID (S-1-15-2-*, excluding the well-known groups ending in 1 or 2). Removing them @@ -23,8 +24,7 @@ effective rights; removal verifies that other icacls lines remain unchanged. The script never creates, deletes, or writes the contents of any file. Its - only footprint outside the ACLs it is asked to repair is the backup it writes - under -Out. + only content writes are recovery artifacts and explicitly requested reports. .PARAMETER Path One or more failing paths. Each is diagnosed together with its ancestors. @@ -33,7 +33,12 @@ Required with any mutation. Every modified object must be strictly inside this directory. .PARAMETER Out - Required with -Fix or -GrantFullControl. Receives ACL backups and recovery scripts. + Required with -Fix, -GrantFullControl or -Compact. Receives recovery artifacts + and, with -Compact, a unique full JSONL report. + +.PARAMETER Compact + Save every REPORT record under -Out and print a decision summary with the + inspected paths, findings, actions, verification and pending recovery commands. .PARAMETER Fix Remove individual package allow ACEs, preserving well-known package groups. @@ -63,7 +68,8 @@ param( [string]$Out, [switch]$Fix, [switch]$GrantFullControl, - [string]$Restore + [string]$Restore, + [switch]$Compact ) Set-StrictMode -Version Latest @@ -73,21 +79,36 @@ $ErrorActionPreference = 'Stop' $PACKAGE_SID = '^S-1-15-2-(?![12]$)' $LOW_LABEL_SID = 'S-1-16-4096' -function Write-Line { param([string]$Text) Write-Output $Text } +function Write-Line { param([string]$Text) if (-not $Compact) { Write-Output $Text } } # Reports bypass the success pipeline so diagnostics cannot become a function's # return value. Each JSON record occupies one stdout line, including paths/errors. function Write-Report { param([string]$Kind, [string]$Operation, [string]$Target, [string]$Status, [string]$Reason, $Details = @{}) $record = [ordered]@{ kind = $Kind; operation = $Operation; path = $Target; status = $Status; reason = $Reason; details = $Details } + $script:reports.Add($record) if ($Kind -eq 'observation' -and $Status -in @('unknown', 'unreadable', 'partial')) { $script:observationFailures++ } - [Console]::Out.WriteLine('REPORT ' + ($record | ConvertTo-Json -Depth 12 -Compress)) + $json = $record | ConvertTo-Json -Depth 12 -Compress + if ($script:reportWriter) { + try { $script:reportWriter.WriteLine($json); $script:reportWriter.Flush() } + catch { + $script:reportWriter.Dispose() + $script:reportWriter = $null + $script:reportFailed = $true + [Console]::Out.WriteLine('REPORT ' + $json) + throw + } + } + if (-not $Compact -or $script:reportFailed) { [Console]::Out.WriteLine('REPORT ' + $json) } } function Invoke-ReportedOperation { param([string]$Operation, [string]$Target, [string]$Reason, [string]$Effect, [scriptblock]$Action) $entry = [ordered]@{ id = $script:operations.Count + 1; operation = $Operation; path = $Target; effect = $Effect; status = 'started' } $script:operations.Add($entry) + if ($Effect -eq 'acl' -and $Operation -ne 'restore_dacl' -and $script:recoveries.Count -gt 0) { + $script:recoveries[-1].attempted = $true + } Write-Report action $Operation $Target started $Reason @{ id = $entry.id; effect = $Effect } try { $result = & $Action @@ -273,12 +294,64 @@ function Save-AclBackup { Copy-Item -LiteralPath $PSCommandPath -Destination $restoreScript $command | Set-Content -LiteralPath ($backup + '.rollback.txt') } - $script:recoveries.Add(@{ path = $FullPath; backup = $backup; record = $record; script = $restoreScript; command = $command }) + $script:recoveries.Add(@{ path = $FullPath; backup = $backup; record = $record; script = $restoreScript; command = $command; attempted = $false; restored = $false }) Write-Report observation recovery $FullPath available 'These files can restore the saved DACL; no rollback has been executed.' $script:recoveries[-1] Write-Line ('BACKUP {0} -> {1}' -f $FullPath, $backup) Write-Line ('ROLLBACK {0}' -f $command) } +function Restore-SavedDacl { + param([string]$FullPath, [string]$Record, [string]$Root) + $refusal = Get-RepairRefusal -FullPath $FullPath -Root $Root + if ($refusal) { throw [System.ArgumentException]::new("RESTORE_REFUSED $refusal") } + $saved = Invoke-ReportedOperation read_recovery $Record 'Read the recovery record and verify it belongs to the requested path before restoring its DACL.' none { + Get-Content -LiteralPath $Record -Raw | ConvertFrom-Json + } + if ($saved.Path -isnot [string] -or $saved.Path -ine $FullPath -or $saved.Dacl -isnot [string]) { + throw [System.ArgumentException]::new('RESTORE_REFUSED backup does not describe the requested path') + } + if (-not [Dsh.TokenInfo]::HasAccess($FullPath, 0x40000)) { throw 'RESTORE_REFUSED the caller lacks WRITE_DAC' } + Invoke-ReportedOperation restore_dacl $FullPath 'Restore the requested backup DACL and inheritance protection, preserving owner and SACL.' acl { + [Dsh.TokenInfo]::SetDacl($FullPath, $saved.Dacl) + } + $actual = Invoke-ReportedOperation read_restored_dacl $FullPath 'Read the restored DACL to compare it with the saved record.' none { + (Get-Acl -LiteralPath $FullPath).GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access) + } + Write-Report verification restore $FullPath $(if ($actual -eq $saved.Dacl) { 'verified' } else { 'failed' }) 'Compare the observed DACL with the recovery record after writing it.' @{ expectedDacl = $saved.Dacl; actualDacl = $actual } + if ($actual -ne $saved.Dacl) { throw 'RESTORE_FAILED the restored DACL differs from the backup' } + $script:restored++ + Write-Line ('RESTORED {0}' -f $FullPath) +} + +function Write-CompactSummary { + param([string]$Status, [string]$NextAction, [array]$RollbackCommands) + $byPath = [ordered]@{} + foreach ($record in $script:reports) { if ($record.operation -eq 'inspect_acl') { $byPath[$record.path] = $record } } + $observations = @($byPath.Values) + $classifications = @($script:reports | Where-Object { $_.operation -eq 'classify' }) + $findings = @($observations | Where-Object { + $_.path -in $script:requestedPaths -or $_.status -ne 'read' -or + @($_.details['aces'] | Where-Object { $_ -and $_.sid -match $PACKAGE_SID -and $_.type -eq 'Allow' }).Count -gt 0 + } | ForEach-Object { + @{ path = $_.path; status = $_.status; writeDac = $_.details['writeDac']; writeOwner = $_.details['writeOwner'] + packageAllowSids = @($_.details['aces'] | Where-Object { $_ -and $_.sid -match $PACKAGE_SID -and $_.type -eq 'Allow' } | ForEach-Object { $_.sid }) + denies = @($_.details['aces'] | Where-Object { $_ -and $_.type -eq 'Deny' }); errors = @($_.details['errors']; $_.details['error']) } + }) + $details = [ordered]@{ + report = $script:reportPath; exitCode = $script:exitCode; nextAction = $NextAction + inspectedPaths = @($observations | ForEach-Object { $_.path } | Select-Object -Unique) + findings = $findings + decisions = @($classifications | ForEach-Object { @{ path = $_.path; verdict = $_.status; reason = $_.reason; packageObjects = $_.details.packageObjects } }) + actions = @($script:reports | Where-Object { $_.kind -eq 'action' -and $_.details['effect'] -eq 'acl' -and $_.status -ne 'started' } | ForEach-Object { @{ operation = $_.operation; path = $_.path; status = $_.status; reason = $_.reason } }) + verification = @($script:reports | Where-Object { $_.kind -eq 'verification' } | ForEach-Object { @{ operation = $_.operation; path = $_.path; status = $_.status } }) + rollback = $script:rollbackStatus; rollbackCommands = $RollbackCommands + errors = @($script:reports | Where-Object { $_.kind -eq 'error' -or ($_.kind -eq 'decision' -and $_.status -eq 'refused') } | ForEach-Object { @{ path = $_.path; reason = $_.reason; details = $_.details } }) + } + $record = @{ kind = 'summary'; operation = $script:mode; path = $script:currentPath; status = $Status + reason = 'Full observations, actions and reasons are in the report. Follow nextAction; a failed repair must not be followed by another repair.'; details = $details } + [Console]::Out.WriteLine('REPORT ' + ($record | ConvertTo-Json -Depth 12 -Compress)) +} + function Get-ObjectFacts { param([string]$FullPath, [string]$MeSid) $facts = [ordered]@{ @@ -370,6 +443,12 @@ function Get-Ancestors { $operations = [System.Collections.Generic.List[object]]::new() $recoveries = [System.Collections.Generic.List[object]]::new() +$reports = [System.Collections.Generic.List[object]]::new() +$reportWriter = $null +$reportPath = $null +$reportFailed = $false +$requestedPaths = @() +$rollbackStatus = 'not-needed' $fixed = 0 $granted = 0 $refused = 0 @@ -380,9 +459,21 @@ $currentPath = '' $mode = if ($Restore) { 'restore' } elseif ($GrantFullControl) { 'grant' } elseif ($Fix) { 'fix' } else { 'diagnose' } try { + $requestedPaths = @($Path | ForEach-Object { [System.IO.Path]::GetFullPath($_) }) Write-Report invocation $mode '' started 'Inspect the requested paths before deciding whether the requested operation can run.' @{ paths = $Path; allowRoot = $AllowRoot; outputDirectory = $Out; recoveryRecord = $Restore; fix = $Fix.IsPresent; grantFullControl = $GrantFullControl.IsPresent } + if ($Compact) { + if (-not $Out) { throw [System.ArgumentException]::new('-Compact requires -Out for the complete report') } + Invoke-ReportedOperation prepare_report $Out 'Create the requested report directory and a new JSONL report; existing reports are never overwritten.' files { + $directory = [System.IO.Path]::GetFullPath($Out) + [System.IO.Directory]::CreateDirectory($directory) | Out-Null + $script:reportPath = Join-Path $directory ('acl-report-{0}.jsonl' -f [guid]::NewGuid().ToString('N')) + $stream = [System.IO.File]::Open($script:reportPath, [System.IO.FileMode]::CreateNew, [System.IO.FileAccess]::Write) + $script:reportWriter = [System.IO.StreamWriter]::new($stream, [System.Text.UTF8Encoding]::new($false)) + foreach ($record in $script:reports) { $script:reportWriter.WriteLine(($record | ConvertTo-Json -Depth 12 -Compress)) } + } + } $identity = Get-CurrentIdentity $meSid = $identity.User.Value Invoke-ReportedOperation initialize '' 'Load read-only access checks and DACL-only writes before inspecting permissions.' none { Initialize-NativeApi } @@ -403,7 +494,7 @@ try { if ($Restore -and $Path.Count -ne 1) { throw [System.ArgumentException]::new('-Restore requires exactly one -Path') } } - foreach ($requested in $Path) { + :paths foreach ($requested in $Path) { $currentPath = $requested $full = [System.IO.Path]::GetFullPath($requested) $currentPath = $full @@ -412,7 +503,7 @@ try { Write-Line ' MISSING' Write-Line 'VERDICT=NOT_THIS_CLASS' Write-Decision $mode $full skipped 'The requested path does not exist; no ACL was read or changed for it.' - if ($mode -ne 'diagnose') { $refused++ } + if ($mode -ne 'diagnose') { $refused++; break paths } continue } @@ -465,39 +556,20 @@ try { } if ($targetFacts.Errors.Count -gt 0) { Write-Decision $mode $full refused 'Required observations are incomplete; no repair was attempted for this path.' - $refused++; continue + $refused++; break paths } if ($Restore) { - $refusal = Get-RepairRefusal -FullPath $full -Root $AllowRoot - if ($refusal) { Write-Line ('RESTORE_REFUSED {0}' -f $refusal); Write-Decision restore $full refused $refusal; $refused++; continue } - $saved = Invoke-ReportedOperation read_recovery $Restore 'Read the recovery record and verify it belongs to the requested path before restoring its DACL.' none { - Get-Content -LiteralPath $Restore -Raw | ConvertFrom-Json - } - if ($saved.Path -isnot [string] -or $saved.Path -ine $full -or $saved.Dacl -isnot [string]) { - Write-Line 'RESTORE_REFUSED backup does not describe the requested path' - Write-Decision restore $full refused 'The backup does not describe the requested path; no DACL write was attempted.' - $refused++; continue - } - if (-not $targetFacts.HasWriteDac) { Write-Line 'RESTORE_REFUSED the caller lacks WRITE_DAC'; Write-Decision restore $full refused 'Effective WRITE_DAC is absent; the saved DACL cannot be written by this caller.'; $refused++; continue } - Invoke-ReportedOperation restore_dacl $full 'Restore the requested backup DACL and inheritance protection, preserving owner and SACL.' acl { - [Dsh.TokenInfo]::SetDacl($full, $saved.Dacl) - } - $actual = Invoke-ReportedOperation read_restored_dacl $full 'Read the restored DACL to compare it with the saved record.' none { - (Get-Acl -LiteralPath $full).GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access) - } - Write-Report verification restore $full $(if ($actual -eq $saved.Dacl) { 'verified' } else { 'failed' }) 'Compare the observed DACL with the recovery record after writing it.' @{ expectedDacl = $saved.Dacl; actualDacl = $actual } - if ($actual -ne $saved.Dacl) { Write-Line 'RESTORE_FAILED the restored DACL differs from the backup'; $refused++; continue } - Write-Line ('RESTORED {0}' -f $full); $restored++ + Restore-SavedDacl -FullPath $full -Record $Restore -Root $AllowRoot continue } if ($GrantFullControl) { - if ($unreadable) { Write-Line 'GRANT_REFUSED the object could not be read; the grant needs an unconfined caller'; Write-Decision grant $full refused 'The ACL is unreadable, so a preserving grant cannot be constructed.'; $refused++; continue } + if ($unreadable) { Write-Line 'GRANT_REFUSED the object could not be read; the grant needs an unconfined caller'; Write-Decision grant $full refused 'The ACL is unreadable, so a preserving grant cannot be constructed.'; $refused++; break paths } $refusal = Get-RepairRefusal -FullPath $full -Root $AllowRoot - if ($refusal) { Write-Line ('GRANT_REFUSED {0}' -f $refusal); Write-Decision grant $full refused $refusal; $refused++; continue } + if ($refusal) { Write-Line ('GRANT_REFUSED {0}' -f $refusal); Write-Decision grant $full refused $refusal; $refused++; break paths } if ($targetFacts.HasWriteDac -and $targetFacts.HasWriteOwner) { Write-Line ('GRANT_SKIPPED {0} already carries WRITE_DAC and WRITE_OWNER' -f $full); Write-Decision grant $full skipped 'Effective WRITE_DAC and WRITE_OWNER are already available; no grant or backup is needed.'; continue } - if (-not $targetFacts.HasWriteDac) { Write-Line 'GRANT_REFUSED the caller lacks WRITE_DAC; ask the user to run this command from an elevated terminal'; Write-Decision grant $full refused 'Effective WRITE_DAC is absent; this caller cannot change the DACL. Elevation may still be blocked by a deny ACE.'; $refused++; continue } + if (-not $targetFacts.HasWriteDac) { Write-Line 'GRANT_REFUSED the caller lacks WRITE_DAC; ask the user to run this command from an elevated terminal'; Write-Decision grant $full refused 'Effective WRITE_DAC is absent; this caller cannot change the DACL. Elevation may still be blocked by a deny ACE.'; $refused++; break paths } Save-AclBackup -FullPath $full -Directory $Out -Root $AllowRoot # AddAccessRule preserves explicit denies; icacls /grant can remove a deny # for the same principal. Write only the DACL, leaving owner and SACL intact. @@ -511,7 +583,7 @@ try { } $after = Get-ObjectFacts -FullPath $full -MeSid $meSid $verified = $after.Readable -and $after.Errors.Count -eq 0 -and $after.HasWriteDac -and $after.HasWriteOwner - Write-Report verification grant $full $(if ($verified) { 'verified' } else { 'failed' }) 'The DACL write completed; recheck effective access before claiming that provisioning can succeed. No automatic rollback is performed.' @{ + Write-Report verification grant $full $(if ($verified) { 'verified' } else { 'failed' }) 'The DACL write completed; recheck effective access before claiming that provisioning can succeed. On failure, restore this invocation and stop.' @{ before = @{ writeDac = $targetFacts.HasWriteDac; writeOwner = $targetFacts.HasWriteOwner } after = @{ writeDac = $after.HasWriteDac; writeOwner = $after.HasWriteOwner } recovery = $recoveries[-1].command @@ -519,8 +591,9 @@ try { if ($verified) { Write-Line ('GRANTED {0} SID={1}' -f $full, $meSid); $granted++ } else { - Write-Line ('GRANT_FAILED {0}; effective WRITE_DAC and WRITE_OWNER were not both confirmed; use the saved rollback and report the error' -f $full) + Write-Line ('GRANT_FAILED {0}; effective WRITE_DAC and WRITE_OWNER were not both confirmed; restore this invocation and stop' -f $full) $refused++ + break paths } continue } @@ -531,66 +604,100 @@ try { Write-Line 'FIX_REFUSED the object could not be read; repair needs an unconfined caller' Write-Decision fix $full refused 'The requested ACL could not be read; no package removal was attempted.' $refused++ - continue + break paths } if ($packageTargets.Count -eq 0) { Write-Decision fix $full skipped 'No package allow ACE was observed on the inspected objects; no ACL change or backup is needed.' } foreach ($target in $packageTargets) { $refusal = Get-RepairRefusal -FullPath $target.Object -Root $AllowRoot - if ($refusal) { Write-Line ('FIX_REFUSED {0}' -f $refusal); Write-Decision fix $target.Object refused $refusal; $refused++; continue } - if ($target.Errors.Count -gt 0) { Write-Decision fix $target.Object refused 'The ACL observations are incomplete; collateral changes could not be verified.'; $refused++; continue } + if ($refusal) { Write-Line ('FIX_REFUSED {0}' -f $refusal); Write-Decision fix $target.Object refused $refusal; $refused++; break paths } + } + if ($packageTargets.Count -gt 0 -and $needsPrecondition) { + Write-Decision fix $full refused 'Package removal requires verified WRITE_DAC and WRITE_OWNER on the requested path. A failed grant must not be followed by -Fix.' + $refused++; break paths + } + foreach ($target in $packageTargets) { + $refusal = Get-RepairRefusal -FullPath $target.Object -Root $AllowRoot + if ($refusal) { Write-Line ('FIX_REFUSED {0}' -f $refusal); Write-Decision fix $target.Object refused $refusal; $refused++; break paths } + if ($target.Errors.Count -gt 0) { Write-Decision fix $target.Object refused 'The ACL observations are incomplete; collateral changes could not be verified.'; $refused++; break paths } if (-not $target.HasWriteDac) { Write-Line ('FIX_REFUSED {0} needs WRITE_DAC; ask the user to run the full-control grant from an elevated terminal first' -f $target.Object) Write-Decision fix $target.Object refused 'Effective WRITE_DAC is absent; removing a package allow ACE requires that right.' $refused++ - continue + break paths } $before = $target.AclLines Save-AclBackup -FullPath $target.Object -Directory $Out -Root $AllowRoot - foreach ($ace in $target.PackageAces) { - if ($ace -notmatch 'SID=(S-1-15-2-[\d-]+)') { continue } - $sid = $Matches[1] + $sids = @($target.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID } | ForEach-Object { $_.sid } | Sort-Object -Unique) + foreach ($sid in $sids) { $repairPath = $target.Object Invoke-ReportedOperation remove_package_allow $repairPath "Remove the observed package allow ACE for $sid as requested by -Fix; preserve deny ACEs and all other principals." acl { $output = @(icacls $repairPath /remove:g "*$sid" 2>&1) if ($LASTEXITCODE -ne 0) { throw "icacls removal exit ${LASTEXITCODE}: $($output -join "`n")" } } - $after = Get-ObjectFacts -FullPath $target.Object -MeSid $meSid - # icacls prefixes only the first ACE with the path, so compare entries - # without that prefix when checking for collateral changes. - $keepBefore = @($before | ForEach-Object { - $line = $_.Trim() - if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() } - $line - } | Where-Object { $_ -notmatch $PACKAGE_SID }) - $keepAfter = @($after.AclLines | ForEach-Object { - $line = $_.Trim() - if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() } - $line - } | Where-Object { $_ -notmatch $PACKAGE_SID }) - $collateral = (($keepBefore -join "`n") -eq ($keepAfter -join "`n")) - $verified = $after.Readable -and $after.Errors.Count -eq 0 -and $after.PackageAces.Count -eq 0 -and $collateral - Write-Report verification fix $target.Object $(if ($verified) { 'verified' } else { 'failed' }) 'The removal command completed; verify that package allow ACEs disappeared and other ACL listing entries remained unchanged. No automatic rollback is performed.' @{ - removedSid = $sid; remainingPackageAces = $after.PackageAces; otherEntriesUnchanged = $collateral; recovery = $recoveries[-1].command - } - if ($verified) { - Write-Line ('FIXED {0} SID={1}' -f $target.Object, $sid); $fixed++ - } else { - Write-Line ('FIX_FAILED {0} SID={1} collateral_change={2}' -f $target.Object, $sid, (-not $collateral)) - $refused++ - } + } + $after = Get-ObjectFacts -FullPath $target.Object -MeSid $meSid + # icacls prefixes only the first ACE with the path, so compare entries + # without that prefix when checking for collateral changes. + $keepBefore = @($before | ForEach-Object { + $line = $_.Trim() + if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() } + $line + } | Where-Object { $_ -notmatch $PACKAGE_SID }) + $keepAfter = @($after.AclLines | ForEach-Object { + $line = $_.Trim() + if ($line.StartsWith($target.Object, [System.StringComparison]::OrdinalIgnoreCase)) { $line = $line.Substring($target.Object.Length).Trim() } + $line + } | Where-Object { $_ -notmatch $PACKAGE_SID }) + # A package deny can share the removed allow's SID. Preserve it explicitly + # even though native listing comparison omits lines naming package SIDs. + $expectedAces = @($target.Aces | Where-Object { -not ($_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID) }) + $collateral = (($keepBefore -join "`n") -eq ($keepAfter -join "`n")) -and + (($expectedAces | ConvertTo-Json -Compress) -eq ($after.Aces | ConvertTo-Json -Compress)) + $verified = $after.Readable -and $after.Errors.Count -eq 0 -and $after.PackageAces.Count -eq 0 -and $collateral + Write-Report verification fix $target.Object $(if ($verified) { 'verified' } else { 'failed' }) 'The removal command completed; verify that package allow ACEs disappeared and other ACL listing entries remained unchanged. On failure, restore this invocation and stop.' @{ + removedSids = $sids; remainingPackageAces = $after.PackageAces; otherEntriesUnchanged = $collateral; recovery = $recoveries[-1].command + } + if ($verified) { + foreach ($sid in $sids) { Write-Line ('FIXED {0} SID={1}' -f $target.Object, $sid); $fixed++ } + } else { + Write-Line ('FIX_FAILED {0} collateral_change={1}' -f $target.Object, (-not $collateral)) + $refused++ + break paths } } } } catch { $exitCode = if ($_.Exception -is [System.ArgumentException]) { 2 } else { 1 } - Write-Report error $mode $currentPath stopped 'Execution stopped after an exception. Completed operations remain completed; an interrupted write may have changed state. No automatic rollback was performed.' @{ error = (Get-HResultChain $_.Exception); location = $_.InvocationInfo.PositionMessage } + Write-Report error $mode $currentPath stopped 'Execution stopped after an exception. An interrupted write may have changed state; attempted repairs will be restored before the final summary.' @{ error = (Get-HResultChain $_.Exception); location = $_.InvocationInfo.PositionMessage } } finally { if ($refused -gt 0 -and $exitCode -eq 0) { $exitCode = 2 } + if ($exitCode -ne 0 -and -not $Restore) { + for ($i = $recoveries.Count - 1; $i -ge 0; $i--) { + $recovery = $recoveries[$i] + if (-not $recovery.attempted) { continue } + Write-Decision rollback $recovery.path selected 'The repair failed; restore every attempted DACL change from this invocation in reverse order before stopping.' + try { + Restore-SavedDacl -FullPath $recovery.path -Record $recovery.record -Root $AllowRoot + $recovery.restored = $true + $rollbackStatus = 'verified' + } catch { + $rollbackStatus = 'failed' + Write-Report error rollback $recovery.path stopped 'Recovery was not verified. Stop repairs and retain the pending recovery commands in reverse order.' @{ error = (Get-HResultChain $_.Exception) } + break + } + } + } + $pending = @($recoveries | Where-Object { $_.attempted -and -not $_.restored } | ForEach-Object { $_.command }) + [array]::Reverse($pending) + $nextAction = if ($rollbackStatus -eq 'failed') { 'restore_pending_then_stop' } elseif ($exitCode -ne 0 -or $observationFailures -gt 0 -or $Restore) { 'stop' } elseif ($mode -eq 'diagnose') { 'review_findings' } else { 'verify_original_confined_operation' } Write-Line ('SUMMARY FIXED={0} GRANTED={1} REFUSED={2} RESTORED={3}' -f $fixed, $granted, $refused, $restored) $status = if ($exitCode -ne 0) { 'failed' } elseif ($observationFailures -gt 0) { 'partial' } else { 'completed' } Write-Report summary $mode $currentPath $status 'Operation completion records API execution; verification records the observed result. Only rerunning the original confined operation can confirm its failure is resolved.' @{ exitCode = $exitCode; fixed = $fixed; granted = $granted; refused = $refused; restored = $restored - operations = @($operations.ToArray()); recoveries = @($recoveries.ToArray()); automaticRollback = $false; observationFailures = $observationFailures + operations = @($operations.ToArray()); recoveries = @($recoveries.ToArray()); automaticRollback = $true; observationFailures = $observationFailures + rollback = $rollbackStatus; rollbackCommands = $pending; nextAction = $nextAction; report = $reportPath } + if ($reportWriter) { $reportWriter.Dispose() } + if ($Compact) { Write-CompactSummary -Status $status -NextAction $nextAction -RollbackCommands $pending } } exit $exitCode diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl-skill.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl-skill.spec.ts index e5cf2173cc..0a540827ed 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl-skill.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl-skill.spec.ts @@ -37,8 +37,9 @@ describe('bundled Windows ACL diagnosis skill', () => { .toBe(readFileSync(new URL('../assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1', import.meta.url), 'utf8')) // The body names the bundled script — the repair path the model must use // instead of editing ACLs by hand. - expect(loaded?.content).toContain('scripts/diagnose-windows-sandbox-acl.ps1') - expect(loaded?.content).toContain('Step 4') + expect(loaded?.content).toContain('scripts\\diagnose-windows-sandbox-acl.ps1') + // Leave room for the tool wrapper below the default 8192-character prune threshold. + expect(loaded?.content.length).toBeLessThan(7_000) await fiber.dispose() expect(await ctx.skills.list()).toEqual([]) diff --git a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts index 8c682c22e3..ff1dc37c25 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts @@ -14,7 +14,7 @@ */ import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -35,6 +35,13 @@ const script = fileURLToPath(new URL('../assets/diagnose-windows-sandbox-acl/scr const PACKAGE_SID = 'S-1-15-2-1-2-3-4' const CAPABILITY_SID = 'S-1-15-3-1-2-3-4' +// Vitest's asymmetric factories return any; expected matchers are opaque values. +const containingObject = (value: Record): unknown => expect.objectContaining(value) +const containingArray = (value: unknown[]): unknown => expect.arrayContaining(value) +const excludingArray = (value: unknown[]): unknown => expect.not.arrayContaining(value) +const containingString = (value: string): unknown => expect.stringContaining(value) +const anyValue = (constructor: object): unknown => expect.any(constructor) + interface ScriptRun { readonly code: number readonly output: string @@ -146,10 +153,10 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix']) expect(repair.output, repair.output).toContain(`FIXED ${target} SID=${PACKAGE_SID}`) expect(repair.output, repair.output).toContain('SUMMARY FIXED=1 GRANTED=0 REFUSED=0') - expect(reports(repair)).toEqual(expect.arrayContaining([ - expect.objectContaining({ kind: 'action', operation: 'remove_package_allow', path: target, status: 'started' }), - expect.objectContaining({ kind: 'action', operation: 'remove_package_allow', path: target, status: 'completed' }), - expect.objectContaining({ kind: 'verification', operation: 'fix', path: target, status: 'verified' }), + expect(reports(repair)).toEqual(containingArray([ + containingObject({ kind: 'action', operation: 'remove_package_allow', path: target, status: 'started' }), + containingObject({ kind: 'action', operation: 'remove_package_allow', path: target, status: 'completed' }), + containingObject({ kind: 'verification', operation: 'fix', path: target, status: 'verified' }), ])) // Exactly the foreign ACE disappears; every other line survives unchanged. @@ -163,11 +170,11 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri const diagnosis = runScript(['-Path', target]) expect(diagnosis.output, diagnosis.output).toContain('VERDICT=NOT_THIS_CLASS') expect(diagnosis.output, diagnosis.output).not.toContain('PACKAGE_ACE') - expect(reports(diagnosis)).toContainEqual(expect.objectContaining({ kind: 'decision', operation: 'diagnose', path: target, status: 'skipped' })) + expect(reports(diagnosis)).toContainEqual(containingObject({ kind: 'decision', operation: 'diagnose', path: target, status: 'skipped' })) const repair = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix']) expect(repair.output, repair.output).toContain('SUMMARY FIXED=0 GRANTED=0 REFUSED=0') - expect(reports(repair)).toContainEqual(expect.objectContaining({ kind: 'decision', operation: 'fix', path: target, status: 'skipped' })) + expect(reports(repair)).toContainEqual(containingObject({ kind: 'decision', operation: 'fix', path: target, status: 'skipped' })) expect(normalized(target, aclLines(target))).toEqual(before) }, 60_000) @@ -190,15 +197,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri expect(inspected.map(entry => entry.path)).toEqual(expectedPaths) expect(inspected[0]).toMatchObject({ path: target, status: 'read', - details: { aces: expect.not.arrayContaining([expect.objectContaining({ sid: PACKAGE_SID })]) }, + details: { aces: excludingArray([containingObject({ sid: PACKAGE_SID })]) }, }) expect(inspected[1]).toMatchObject({ path: parent, status: 'read', - details: { aces: expect.arrayContaining([expect.objectContaining({ sid: PACKAGE_SID, type: 'Allow', inherited: false })]) }, + details: { aces: containingArray([containingObject({ sid: PACKAGE_SID, type: 'Allow', inherited: false })]) }, }) - expect(entries).toContainEqual(expect.objectContaining({ + expect(entries).toContainEqual(containingObject({ kind: 'decision', operation: 'classify', path: target, status: 'CULPRIT', - details: expect.objectContaining({ packageObjects: [parent] }), + details: containingObject({ packageObjects: [parent] }), })) expect(entries.filter(entry => entry.kind === 'action' && entry.details.effect !== 'none')).toEqual([]) expect([sddlOf(parent), sddlOf(target)]).toEqual(before) @@ -239,9 +246,9 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri const denyDiagnosis = runScript(['-Path', deny]) expect(denyDiagnosis.output, denyDiagnosis.output).not.toContain('PACKAGE_ACE') expect(denyDiagnosis.output, denyDiagnosis.output).not.toContain('VERDICT=CULPRIT') - expect(reports(denyDiagnosis)).toContainEqual(expect.objectContaining({ + expect(reports(denyDiagnosis)).toContainEqual(containingObject({ kind: 'observation', operation: 'inspect_acl', path: deny, - details: expect.objectContaining({ aces: expect.arrayContaining([expect.objectContaining({ sid: PACKAGE_SID, type: 'Deny', inherited: false })]) }), + details: containingObject({ aces: containingArray([containingObject({ sid: PACKAGE_SID, type: 'Deny', inherited: false })]) }), })) // `pwsh -File` passes `-Path a,b` literally, so each object is diagnosed on its own. @@ -280,7 +287,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri const contradictory = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix', '-GrantFullControl']) expect(contradictory.code).toBe(2) expect(contradictory.output, contradictory.output).toContain('run one at a time') - expect(reports(contradictory)).toContainEqual(expect.objectContaining({ kind: 'error', status: 'stopped' })) + expect(reports(contradictory)).toContainEqual(containingObject({ kind: 'error', status: 'stopped' })) }, 60_000) it.each(['-Fix', '-GrantFullControl'])('refuses ancestor junctions for %s without changing their destination', (repairSwitch) => { @@ -298,7 +305,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri const repair = runScript(['-Path', join(link, 'target'), '-AllowRoot', allowRoot, '-Out', outDir, repairSwitch]) expect(repair.output, repair.output).toContain('reparse point') expect(repair.code).not.toBe(0) - expect(reports(repair)).toContainEqual(expect.objectContaining({ kind: 'decision', status: 'refused', reason: expect.stringContaining('reparse point') })) + expect(reports(repair)).toContainEqual(containingObject({ kind: 'decision', status: 'refused', reason: containingString('reparse point') })) expect(sddlOf(target)).toBe(before) } } finally { @@ -320,7 +327,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri const rollback = repair.output.split(/\r?\n/u).find(line => line.startsWith('ROLLBACK ')) expect(rollback, repair.output).toBeDefined() const restored = pwsh(rollback!.slice('ROLLBACK '.length)) - expect(reports({ code: 0, output: restored })).toContainEqual(expect.objectContaining({ kind: 'verification', operation: 'restore', status: 'verified' })) + expect(reports({ code: 0, output: restored })).toContainEqual(containingObject({ kind: 'verification', operation: 'restore', status: 'verified' })) expect(sddlOf(target)).toBe(before) expect(normalized(target, aclLines(target)).sort()).toEqual(linesBefore) }, 60_000) @@ -369,14 +376,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri it('does not report a successful grant when a deny ACE blocks WRITE_OWNER', async () => { const target = makeDir('denied-write-owner') icacls(target, '/deny', `*${meSid}:(WO)`) + const before = sddlOf(target) try { const diagnosis = runScript(['-Path', target]) expect(diagnosis.output, diagnosis.output).toContain('VERDICT=PRECONDITION') - expect(reports(diagnosis)).toContainEqual(expect.objectContaining({ + expect(reports(diagnosis)).toContainEqual(containingObject({ kind: 'observation', operation: 'inspect_acl', path: target, - details: expect.objectContaining({ + details: containingObject({ writeOwner: false, - aces: expect.arrayContaining([expect.objectContaining({ sid: meSid, type: 'Deny', rights: 'TakeOwnership', inherited: false })]), + aces: containingArray([containingObject({ sid: meSid, type: 'Deny', rights: 'TakeOwnership', inherited: false })]), }), })) const grant = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-GrantFullControl']) @@ -384,11 +392,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri expect(grant.output, grant.output).toContain('GRANT_FAILED') expect(grant.output).toContain('GRANTED=0 REFUSED=1') expect(aclLines(target).join('\n')).toContain('(DENY)(WO)') - expect(reports(grant)).toEqual(expect.arrayContaining([ - expect.objectContaining({ kind: 'action', operation: 'grant_dacl', status: 'completed' }), - expect.objectContaining({ kind: 'verification', operation: 'grant', status: 'failed', details: expect.objectContaining({ recovery: expect.stringContaining('-Restore') }) }), - expect.objectContaining({ kind: 'summary', details: expect.objectContaining({ automaticRollback: false, granted: 0 }) }), + expect(reports(grant)).toEqual(containingArray([ + containingObject({ kind: 'action', operation: 'grant_dacl', status: 'completed' }), + containingObject({ kind: 'verification', operation: 'grant', status: 'failed', details: containingObject({ recovery: containingString('-Restore') }) }), + containingObject({ kind: 'verification', operation: 'restore', status: 'verified' }), + containingObject({ kind: 'summary', details: containingObject({ automaticRollback: true, granted: 0, restored: 1, rollback: 'verified', rollbackCommands: [], nextAction: 'stop' }) }), ])) + expect(sddlOf(target)).toBe(before) const transcript = reports(grant) .filter(entry => entry.path === target && entry.kind !== 'observation') .map(entry => `${entry.kind} ${entry.operation} ${entry.status} path={{target}}: ${entry.reason.replaceAll(meSid, '{{caller_sid}}')}`) @@ -408,7 +418,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri const run = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outputFile, '-GrantFullControl']) expect(run.code).toBe(1) const entries = reports(run) - expect(entries).toContainEqual(expect.objectContaining({ kind: 'action', operation: 'backup', status: 'failed', details: expect.objectContaining({ error: expect.any(String) }) })) + expect(entries).toContainEqual(containingObject({ kind: 'action', operation: 'backup', status: 'failed', details: containingObject({ error: anyValue(String) }) })) expect(entries.filter(entry => entry.kind === 'action' && entry.details.effect === 'acl')).toEqual([]) expect(sddlOf(target)).toBe(before) }) @@ -424,17 +434,17 @@ exit $LASTEXITCODE expect(run.code).toBe(0) expect(run.output).toContain('VERDICT=UNREADABLE') const entries = reports(run) - expect(entries).toContainEqual(expect.objectContaining({ + expect(entries).toContainEqual(containingObject({ kind: 'observation', path: target, status: 'unreadable', - details: { error: expect.stringContaining('ACL observation unavailable') }, + details: { error: containingString('ACL observation unavailable') }, })) - expect(entries.at(-1)).toMatchObject({ status: 'partial', details: { observationFailures: expect.any(Number) } }) + expect(entries.at(-1)).toMatchObject({ status: 'partial', details: { observationFailures: anyValue(Number) } }) expect(entries.filter(entry => entry.kind === 'action' && entry.details.effect !== 'none')).toEqual([]) expect(sddlOf(target)).toBe(before) }) - it('reports the completed write and recovery command when the verification ACL read throws', () => { - const target = makeDir('post-write-read-failure') + it.each([false, true])('restores after a failed verification read and reports whether recovery was observed (recovery read fails: %s)', (recoveryReadFails) => { + const target = makeDir(`post-write-read-failure-${recoveryReadFails}`) icacls(target, '/inheritance:r', '/grant:r', `*${meSid}:(M)`) const before = sddlOf(target) const quote = (value: string): string => `'${value.replaceAll("'", "''")}'` @@ -446,7 +456,7 @@ function Get-Acl { param([string]$LiteralPath) if ($LiteralPath -eq ${quote(target)}) { $global:targetReads++ - if ($global:targetReads -eq 4) { throw [System.IO.IOException]::new('verification read unavailable') } + if ($global:targetReads ${recoveryReadFails ? '-ge' : '-eq'} 4) { throw [System.IO.IOException]::new('verification read unavailable') } } Microsoft.PowerShell.Security\\Get-Acl -LiteralPath $LiteralPath } @@ -456,27 +466,35 @@ exit $LASTEXITCODE const run = runPowerShell(['-File', wrapper]) expect(run.code).toBe(2) const entries = reports(run) - expect(entries).toEqual(expect.arrayContaining([ - expect.objectContaining({ kind: 'action', operation: 'grant_dacl', status: 'completed' }), - expect.objectContaining({ kind: 'observation', operation: 'inspect_acl', path: target, status: 'unreadable', details: expect.objectContaining({ error: expect.stringContaining('verification read unavailable') }) }), - expect.objectContaining({ kind: 'verification', operation: 'grant', status: 'failed', details: expect.objectContaining({ recovery: expect.stringContaining('-Restore') }) }), + expect(entries).toEqual(containingArray([ + containingObject({ kind: 'action', operation: 'grant_dacl', status: 'completed' }), + containingObject({ kind: 'observation', operation: 'inspect_acl', path: target, status: 'unreadable', details: containingObject({ error: containingString('verification read unavailable') }) }), + containingObject({ kind: 'verification', operation: 'grant', status: 'failed', details: containingObject({ recovery: containingString('-Restore') }) }), ])) - expect(sddlOf(target)).not.toBe(before) - const rollback = run.output.split(/\r?\n/u).find(line => line.startsWith('ROLLBACK '))! - pwsh(rollback.slice('ROLLBACK '.length)) expect(sddlOf(target)).toBe(before) + expect(entries.at(-1)).toMatchObject({ details: { + rollback: recoveryReadFails ? 'failed' : 'verified', + nextAction: recoveryReadFails ? 'restore_pending_then_stop' : 'stop', + rollbackCommands: recoveryReadFails ? [containingString('-Restore')] : [], + } }) + if (recoveryReadFails) { + const commands = entries.at(-1)!.details.rollbackCommands as string[] + const recovery = runPowerShell(['-Command', commands[0]!]) + expect(recovery.code, recovery.output).toBe(0) + expect(reports(recovery)).toContainEqual(containingObject({ kind: 'verification', operation: 'restore', status: 'verified' })) + } }, 60_000) it('reports a missing path and an already-satisfied grant without mutating either', () => { const missing = join(scratch, 'missing') const run = runScript(['-Path', missing, '-AllowRoot', scratch, '-Out', outDir, '-GrantFullControl']) expect(run.code).toBe(2) - expect(reports(run)).toContainEqual(expect.objectContaining({ kind: 'decision', path: missing, status: 'skipped', reason: expect.stringContaining('does not exist') })) + expect(reports(run)).toContainEqual(containingObject({ kind: 'decision', path: missing, status: 'skipped', reason: containingString('does not exist') })) const healthy = makeDir('already-satisfied') const before = sddlOf(healthy) const grant = runScript(['-Path', healthy, '-AllowRoot', scratch, '-Out', outDir, '-GrantFullControl']) expect(grant.code, grant.output).toBe(0) - expect(reports(grant)).toContainEqual(expect.objectContaining({ kind: 'decision', operation: 'grant', status: 'skipped', reason: expect.stringContaining('already available') })) + expect(reports(grant)).toContainEqual(containingObject({ kind: 'decision', operation: 'grant', status: 'skipped', reason: containingString('already available') })) expect(sddlOf(healthy)).toBe(before) }) @@ -487,11 +505,11 @@ exit $LASTEXITCODE const run = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix']) expect(run.code, run.output).toBe(0) expect(run.output).toContain('VERDICT=NOT_THIS_CLASS') - expect(reports(run)).toContainEqual(expect.objectContaining({ + expect(reports(run)).toContainEqual(containingObject({ kind: 'observation', operation: 'inspect_acl', path: target, - details: expect.objectContaining({ aces: expect.arrayContaining([ - expect.objectContaining({ sid: 'S-1-15-2-1', type: 'Allow' }), - expect.objectContaining({ sid: 'S-1-15-2-2', type: 'Allow' }), + details: containingObject({ aces: containingArray([ + containingObject({ sid: 'S-1-15-2-1', type: 'Allow' }), + containingObject({ sid: 'S-1-15-2-2', type: 'Allow' }), ]) }), })) expect(sddlOf(target)).toBe(before) @@ -512,4 +530,93 @@ exit $LASTEXITCODE expect(aclLines(target).join('\n')).not.toContain(PACKAGE_SID) expect(ownerOf(target)).toBe(ownerBefore) }) + + it('refuses package removal when a deny blocks the prerequisite and restores a failed grant', () => { + const target = makeDir('both-with-deny') + stamp(target, PACKAGE_SID) + icacls(target, '/deny', `*${meSid}:(WO)`) + const before = sddlOf(target) + try { + for (const mode of ['-Fix', '-GrantFullControl', '-Fix']) { + const run = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, mode, '-Compact']) + expect(run.code, run.output).toBe(2) + expect(reports(run).at(-1)).toMatchObject({ status: 'failed', details: { nextAction: 'stop', rollbackCommands: [] } }) + expect(sddlOf(target)).toBe(before) + } + } finally { + icacls(target, '/remove:d', `*${meSid}`) + } + }, 60_000) + + it('removes multiple package allow SIDs before verifying the resulting ACL', () => { + const target = makeDir('multiple-packages') + const otherSid = 'S-1-15-2-4-3-2-1' + for (const sid of [PACKAGE_SID, otherSid]) stamp(target, sid) + icacls(target, '/deny', `*${PACKAGE_SID}:(WO)`) + const before = normalized(target, aclLines(target)) + const run = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', outDir, '-Fix']) + expect(run.code, run.output).toBe(0) + expect(normalized(target, aclLines(target))).toEqual(before.filter(line => line.includes('(DENY)') || (!line.includes(PACKAGE_SID) && !line.includes(otherSid)))) + expect(reports(run).filter(entry => entry.kind === 'verification' && entry.operation === 'fix')).toHaveLength(1) + }) + + it('rolls back earlier paths too when a later grant fails in the same invocation', () => { + const first = makeDir('multi-grant-first') + const second = makeDir('multi-grant-second') + icacls(first, '/inheritance:r', '/grant:r', `*${meSid}:(M)`) + icacls(second, '/deny', `*${meSid}:(WO)`) + const before = [sddlOf(first), sddlOf(second)] + try { + const run = runPowerShell(['-Command', `& '${script}' -Path @('${first}', '${second}') -AllowRoot '${scratch}' -Out '${outDir}' -GrantFullControl; exit $LASTEXITCODE`]) + expect(run.code, run.output).toBe(2) + expect(reports(run).filter(entry => entry.operation === 'restore' && entry.kind === 'verification').map(entry => entry.path)).toEqual([second, first]) + expect([sddlOf(first), sddlOf(second)]).toEqual(before) + } finally { + icacls(second, '/remove:d', `*${meSid}`) + } + }, 60_000) + + it('keeps all observations in unique reports and all ancestor paths in the compact summary', () => { + const parent = makeDir('compact-parent') + const target = join(parent, 'child') + mkdirSync(target) + stamp(parent, PACKAGE_SID) + const before = [sddlOf(parent), sddlOf(target)] + const reportPaths = new Set() + for (let attempt = 0; attempt < 2; attempt++) { + const run = runScript(['-Path', target, '-Out', outDir, '-Compact']) + expect(run.code, run.output).toBe(0) + const compact = reports(run) + expect(compact).toHaveLength(1) + const summary = compact[0]! + expect(summary.details).toMatchObject({ + inspectedPaths: containingArray([target, parent, dirname(parent)]), + findings: containingArray([containingObject({ path: parent, packageAllowSids: [PACKAGE_SID] })]), + nextAction: 'review_findings', + }) + const reportPath = summary.details.report as string + reportPaths.add(reportPath) + const full = readFileSync(reportPath, 'utf8').trim().split(/\r?\n/u).map(line => JSON.parse(line) as ScriptReport) + expect(full.filter(entry => entry.operation === 'inspect_acl').map(entry => entry.path)).toEqual(summary.details.inspectedPaths) + expect(full.every(entry => entry.reason.length > 0)).toBe(true) + expect(full.at(-1)).toMatchObject({ kind: 'summary', status: 'completed' }) + expect(run.output.length).toBeLessThan(5_120) + } + expect(reportPaths.size).toBe(2) + expect([sddlOf(parent), sddlOf(target)]).toEqual(before) + }, 60_000) + + it('reports compact-output setup failures without changing the target or overwriting files', () => { + const target = makeDir('compact-failure') + const outputFile = join(scratch, 'existing-report-output') + writeFileSync(outputFile, 'preserve') + const before = sddlOf(target) + for (const args of [[], ['-Out', outputFile]]) { + const run = runScript(['-Path', target, '-Compact', ...args]) + expect(run.code, run.output).not.toBe(0) + expect(reports(run).at(-1)).toMatchObject({ status: 'failed', details: { nextAction: 'stop' } }) + } + expect(readFileSync(outputFile, 'utf8')).toBe('preserve') + expect(sddlOf(target)).toBe(before) + }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/expected/denied-grant-report.txt b/packages/sandbox/sandbox-windows-acl/tests/expected/denied-grant-report.txt index 1d0dbbb0f0..0e1ad3333b 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/expected/denied-grant-report.txt +++ b/packages/sandbox/sandbox-windows-acl/tests/expected/denied-grant-report.txt @@ -4,5 +4,11 @@ action backup started path={{target}}: Save the original DACL and an independent action backup completed path={{target}}: Save the original DACL and an independent recovery command before changing permissions. action grant_dacl started path={{target}}: WRITE_OWNER is missing and WRITE_DAC is available; add a FullControl allow ACE for {{caller_sid}} while preserving deny ACEs, owner and SACL. action grant_dacl completed path={{target}}: WRITE_OWNER is missing and WRITE_DAC is available; add a FullControl allow ACE for {{caller_sid}} while preserving deny ACEs, owner and SACL. -verification grant failed path={{target}}: The DACL write completed; recheck effective access before claiming that provisioning can succeed. No automatic rollback is performed. +verification grant failed path={{target}}: The DACL write completed; recheck effective access before claiming that provisioning can succeed. On failure, restore this invocation and stop. +decision rollback selected path={{target}}: The repair failed; restore every attempted DACL change from this invocation in reverse order before stopping. +action restore_dacl started path={{target}}: Restore the requested backup DACL and inheritance protection, preserving owner and SACL. +action restore_dacl completed path={{target}}: Restore the requested backup DACL and inheritance protection, preserving owner and SACL. +action read_restored_dacl started path={{target}}: Read the restored DACL to compare it with the saved record. +action read_restored_dacl completed path={{target}}: Read the restored DACL to compare it with the saved record. +verification restore verified path={{target}}: Compare the observed DACL with the recovery record after writing it. summary grant failed path={{target}}: Operation completion records API execution; verification records the observed result. Only rerunning the original confined operation can confirm its failure is resolved. diff --git a/packages/sandbox/sandbox-windows-acl/tests/expected/parent-package-report.jsonl b/packages/sandbox/sandbox-windows-acl/tests/expected/parent-package-report.jsonl index 5d692e3919..c7b7c47d95 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/expected/parent-package-report.jsonl +++ b/packages/sandbox/sandbox-windows-acl/tests/expected/parent-package-report.jsonl @@ -2,4 +2,4 @@ {"kind":"observation","operation":"inspect_acl","path":"{{parent}}","status":"read","reason":"Read the ACL and check effective WRITE_DAC and WRITE_OWNER; observed ACEs alone do not identify which rule caused a denial.","details":{"fixturePackageAces":[{"sid":"S-1-15-2-1-2-3-4","type":"Allow","rights":"ReadAndExecute, Synchronize","inherited":false,"inheritance":"None","propagation":"None"}]}} {"kind":"decision","operation":"classify","path":"{{parent}}\\child","status":"CULPRIT","reason":"Package allow ACEs were observed. Required access checks determine whether the caller can perform the requested repair; other causes remain possible.","details":{"packageObjects":["{{parent}}"]}} {"kind":"decision","operation":"diagnose","path":"{{parent}}\\child","status":"skipped","reason":"No mutation mode was requested; all reported ACLs were left unchanged.","details":{}} -{"kind":"summary","operation":"diagnose","path":"{{parent}}\\child","status":"completed","reason":"Operation completion records API execution; verification records the observed result. Only rerunning the original confined operation can confirm its failure is resolved.","details":{"exitCode":0,"operations":[{"id":1,"operation":"initialize","path":"","effect":"none","status":"completed"}],"automaticRollback":false}} +{"kind":"summary","operation":"diagnose","path":"{{parent}}\\child","status":"completed","reason":"Operation completion records API execution; verification records the observed result. Only rerunning the original confined operation can confirm its failure is resolved.","details":{"exitCode":0,"operations":[{"id":1,"operation":"initialize","path":"","effect":"none","status":"completed"}],"automaticRollback":true}} diff --git a/snapshots/session/windows-acl-skill/session.v4.jsonl b/snapshots/session/windows-acl-skill/session.v4.jsonl index ddf043ce64..339cb72186 100644 --- a/snapshots/session/windows-acl-skill/session.v4.jsonl +++ b/snapshots/session/windows-acl-skill/session.v4.jsonl @@ -9,13 +9,13 @@ {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Load the diagnose-windows-sandbox-acl skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `diagnose-windows-sandbox-acl`: Use on Windows when the DSH sandbox denies something it promised: cannot write or list in the workspace, or read a path the user should be able to read. Run the bundled script over the failing path and its ancestors; it only reads without a repair switch. It separates an AppContainer package-SID ACE, which it removes under an explicit root, from an unmet ownership or permission precondition, which it repairs with a full-control grant. Never change an owner or edit ACLs outside the script.\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"diagnose-windows-sandbox-acl","description":"Use on Windows when the DSH sandbox denies something it promised: cannot write or list in the workspace, or read a path the user should be able to read. Run the bundled script over the failing path and its ancestors; it only reads without a repair switch. It separates an AppContainer package-SID ACE, which it removes under an explicit root, from an unmet ownership or permission precondition, which it repairs with a full-control grant. Never change an owner or edit ACLs outside the script."},{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `diagnose-windows-sandbox-acl`: Use on Windows for unexpected DSH sandbox access denials: workspace writes or listing fail, or an ordinarily readable path cannot be read. The bundled script inspects the path and every ancestor, reports observations and action reasons, and supports scoped, backed-up ACL repairs. Expected confinement denials need no ACL repair.\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"diagnose-windows-sandbox-acl","description":"Use on Windows for unexpected DSH sandbox access denials: workspace writes or listing fail, or an ordinarily readable path cannot be read. The bundled script inspects the path and every ancestor, reports observations and action reasons, and supports scoped, backed-up ACL repairs. Expected confinement denials need no ACL repair."},{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Load the diagnose-windows-sandbox-acl sk","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5},"stream":[{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430688,"index":0,"dt":[],"texts":["Load the requested skill."]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699430688,"index":1,"dt":[],"id":"call_skill_load","name":"skill","args":["{\"name\":\"diagnose-windows-sandbox-acl\"}"]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/diagnose-windows-sandbox-acl\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Diagnose Windows sandbox ACL failures\n\nA denial is only worth diagnosing when it contradicts what the active mode promises. Establish that first: repairing a denial the sandbox is supposed to produce means weakening confinement for nothing.\n\n## Step 0 — Decide whether this denial is unexpected\n\nEnter the diagnosis only for these:\n\n| Symptom | Why it contradicts the promise |\n|---|---|\n| Cannot write inside the workspace (root or any subdirectory) | `workspace-write` promises writes there |\n| Cannot enumerate inside the workspace (`dir` prints nothing for a directory that has entries) | same |\n| Anything the signed-in user should plainly be able to read cannot be read | reads are never confined in either restricted mode |\n| Only the workspace **root** is denied while its subdirectories work normally | points at one object, not at the whole tree |\n\nStrong but not required corroboration: `Test-Path` returns true while reads fail; `dir` reports an empty directory that is not empty; `cmd` cannot read a file that .NET reads fine.\n\nStop and explain instead of repairing for these — they are the confinement working as documented:\n\n| Symptom | Why it is expected | What to do instead |\n|---|---|---|\n| Writing or deleting **outside** the workspace under `workspace-write` | the fence is doing its job | escalate that one call, or write inside the workspace |\n| Any write under `read-only` | the mode itself | escalate that one call, or switch mode |\n| `spawn EPERM` when a confined command captures a grandchild's output through piped stdio | named-pipe opens are denied in both restricted modes (a Win32 default-SD property, not a permission mistake); anonymous pipes and inherited stdio still work | escalate that one call, or restructure the command to avoid piping |\n| `Add-Type`, `[System.IO.*]::`, COM, or reflection failing with \"only core types\" | `read-only` pwsh runs in ConstrainedLanguage | not a permission problem; escalate or use `workspace-write` |\n\nThen split \"cannot write in the workspace\" by shape, because the two shapes have different causes:\n\n- **Uniformly denied** — the root and every subdirectory fail together: the sandbox could not provision its grant.\n- **Only the root denied** — subdirectories are fine: one object carries something that blocks it.\n\n## Step 1 — Diagnose read-only before changing anything\n\nRun the bundled script over the failing path. It reads ACLs with .NET and native `icacls`, changes nothing, and prints machine-parseable lines:\n\n```powershell\npwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path \n```\n\nIt reports every observed allow and deny ACE on the target and its ancestors, including SID, rights, inheritance and propagation flags, plus the native ACL listing, owner, effective `WRITE_DAC` and `WRITE_OWNER`, caller integrity and sandbox marker names. Well-known groups `S-1-15-2-1` and `S-1-15-2-2` are reported but are not individual package ACEs eligible for removal. Access checks open existing objects without changing contents; Windows accounts for group membership, deny ACEs and inheritance. An unreadable or unrecognized value is unknown, not absent.\n\nEvery `REPORT` line contains a JSON record with `kind`, `operation`, `path`, `status`, `reason` and `details`. Read observations and reasons alongside the verdict: a deny ACE's presence alone does not prove that it caused the failure. Decisions explain skips and refusals. Actions report `started` before execution and `completed` or `failed` afterward; completion means the operation ran, while a separate verification reports whether the intended result was observed. A final summary lists attempted operations, outcomes and available recovery commands, including after a caught exception. If execution ends before a completion or summary record, treat the result as unconfirmed.\n\nThe failing API names the family. DSH's fail-closed errors carry the API and the exact Win32 code, so read the error rather than guessing:\n\n| Error | Meaning |\n|---|---|\n| `GetNamedSecurityInfoW` … (Win32 5) | the object could not be **read** — either it carries a package SID and the caller is below Medium, or the caller has no access to it at all |\n| `SetNamedSecurityInfoW` … (Win32 5) | the object was read but not written — the caller lacks `WRITE_DAC`, `WRITE_OWNER`, or both |\n| `grantWrite` succeeded, yet the confined child still cannot use the directory | the grant landed but the child cannot open the object |\n\nThe script needs a caller that can open the object. `read-only` pwsh cannot run it at all, because ConstrainedLanguage blocks the .NET calls it needs; under `workspace-write` the confined child cannot open an object carrying a package SID, so the diagnosis comes back `READABLE=False` for the very object that caused the problem. In both modes the diagnosis needs an unconfined caller, so escalate once and run the diagnosis together with any repair in that same call (Step 4) — rather than concluding that the path is unreadable or that the failure is something else.\n\n## Step 2 — Read the verdict\n\nFor `UNREADABLE` or `INCOMPLETE`, report the failed observation and its error; do not treat missing evidence as missing permissions or proceed with a repair. `NOT_THIS_CLASS` only rules out the conditions the script inspected, not every cause of the original failure.\n\n| Package SID present | Effective permissions | Verdict | Action |\n|---|---|---|---|\n| yes | the target has both rights and every affected object has `WRITE_DAC` | package ACE found | remove the ACE (Step 4), then verify the original failure |\n| yes | either condition above is unmet | both problems at once | grant full control on the affected object first, then remove the ACE; if `WRITE_DAC` is missing, the user must run the grant from an elevated terminal |\n| no | `WRITE_DAC` present, `WRITE_OWNER` missing | a provisioning precondition is unmet | grant full control (Step 4) |\n| no | `WRITE_DAC` missing | this caller cannot repair the DACL | hand the user the single command to run from an elevated terminal (Step 4) |\n| no | both rights present | not this class | say so and stop; report what was actually found |\n\n## Step 3 — Explain, and apologize\n\nSay what the finding is and what it will cost before doing anything.\n\nApologize on the harness's behalf, in the language the user is writing in, in one or two sentences, then move to the technical explanation. The gap being owned is ours: the harness neither notices the conflict when it provisions the grant nor gives any direction when the denial surfaces, so the user only sees a bare \"Access is denied\" and has to guess. Do not apologize before the cause is confirmed, do not repeat it, do not blame another tool in the apology (the cause belongs in the explanation), and do not let it replace the explanation.\n\nTwo things must reach the user either way:\n\n- **Which shape this is** — one object carrying a foreign ACE, or a directory whose ownership/DACL does not satisfy DSH's documented precondition.\n- **That removing a foreign ACE may be undone** — if whatever tool placed it runs again with this directory as its root, it will place it again. The repair is not a permanent fix.\n\n## Step 4 — Repair, or hand over steps\n\nTwo repairs exist, and both run through the same script so that no ACL is edited by hand.\n\n**Remove a conflicting package-SID ACE.** This also removes that package's access, so explain which SID and permissions will be removed:\n\n```powershell\npwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path -AllowRoot -Out -Fix\n```\n\n**Grant the current user full control when the precondition is unmet:**\n\n```powershell\npwsh -File scripts/diagnose-windows-sandbox-acl.ps1 -Path -AllowRoot -Out -GrantFullControl\n```\n\nFull control carries both rights this needs: `WRITE_DAC` for the DACL and `WRITE_OWNER` for the mandatory label DSH writes in the same call. **Taking ownership alone does not work** — it yields only `WRITE_DAC`, so the label write still fails. Never change an object's owner, and never \"replace permissions on all child objects\": that erases deny ACEs somebody set deliberately.\n\nBoth modes require `-AllowRoot` to be a containing directory and reject the target or any ancestor if it is a reparse point, including a junction used as `-AllowRoot`. Before mutation they save the native `icacls` listing format, a DACL recovery record, a standalone recovery script and its exact command under `-Out`. The recovery command uses `-Restore` to restore only the original DACL and inheritance protection; it never changes ownership or the mandatory label and remains usable after the session ends. It requires `WRITE_DAC` and enforces the same path restrictions. Run multiple rollback commands in reverse repair order.\n\nAfter a grant, the script rechecks effective `WRITE_DAC` and `WRITE_OWNER`; after removal it checks that package ACEs disappeared and other `icacls` lines stayed unchanged. Refused or failed repairs exit nonzero. A failed verification does not undo a completed DACL write. Report which operation ran, its reason, what verification observed and the recovery command; the script never rolls back automatically. If the requested repair failed after a write, use its emitted recovery command before trying another repair, and report the recovery result. Both repairs write a DACL, which the confined child cannot do, so run the diagnosis and the repair inside one escalated call rather than spending two approvals on the same directory.\n\nThe caller needs effective `WRITE_DAC` to repair the DACL. Ownership often supplies it, but an explicit grant or group grant can also supply it to a non-owner. Follow the script's access result rather than inferring permissions from the owner's name. If the caller lacks `WRITE_DAC`, ask the user to run the exact grant command from an elevated terminal. Elevation does not override every deny or system policy; verify the result.\n\nDo not reach for `sandbox_permissions` to solve a Windows-token permission failure. Escalation gives the script an unconfined caller (Step 1); it does not elevate the Windows token. If the unconfined caller still lacks `WRITE_DAC`, another sandbox escalation cannot supply it.\n\nDo not make the harness trigger UAC either. An agent-initiated `runas` is a privilege-escalation path, the elevated child would run outside both the sandbox and the approval ledger the session log is built from, and a UAC prompt cannot be routed through this harness's approval channel. Hand the user the single exact command to run from an elevated terminal, and say plainly what it changes.\n\nNever recommend running the whole agent elevated instead: that is a downgrade, and the community habit of \"just run it as administrator\" trades the isolation away for a working prompt.\n\nAlso stop short when the directory cannot be repaired this way at all, and say which applies: the owner is a system principal such as `TrustedInstaller`, the path is one the user has no business owning, or the volume is not ACL-capable. Moving the workspace is then the honest answer.\n\n## Step 5 — Verify against the original failure\n\nRe-run the operation that failed, from the same confined context. The repair is only done when that operation succeeds. Whether diagnosis, repair or verification succeeds or fails, tell the user what the script observed, what it changed or left untouched, why it took those actions, and what remains unverified. Keep observations separate from suspected causes. If the original operation still fails, report the new error verbatim rather than starting another round of changes.\n\n## Step 6 — Report recurrence\n\nTell the user the condition that brings it back, so they can recognize it instead of re-diagnosing it.\n\n## Never\n\n- Never write, create, or delete a file to *test* permissions. Writing to probe is how an existing file gets clobbered, and it answers nothing the read-only diagnosis does not. (This has already happened once in this repository: a probe overwrote a workspace-root `AGENTS.md` that no version control could restore.)\n- Never edit ACLs by hand — use the bundled repairs or their generated recovery command. The recovery script writes only the saved DACL; do not replace it with a security-descriptor write that also attempts to change the SACL.\n- Never repair anything outside the directory being diagnosed, and never widen `-AllowRoot` beyond the tree that legitimately contains the failing path — its purpose is to keep a repair from reaching a neighbour of the thing that failed.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/diagnose-windows-sandbox-acl\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Diagnose Windows sandbox ACL failures\n\n**Stop after any failed/refused repair or failed verification.** A completed DACL write is not a successful repair. Never continue from a failed grant to `-Fix`, repeat the grant, or remove a deny ACE. The script automatically restores attempted changes from the failed invocation; verify its rollback result. Restore earlier successful invocations in reverse order using their exact recovery commands, then stop. A later backup alone cannot undo earlier repairs.\n\n## Decide whether diagnosis applies\n\nDiagnose unexpected writes/listing denied inside a `workspace-write` workspace, or reads the signed-in user should plainly have. Root-only failure and uniform failure are both eligible.\n\nStop and explain expected denials: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, or ConstrainedLanguage errors for .NET/COM/reflection. Use an authorized unconfined call or another permitted operation; do not repair ACLs for these cases.\n\n## Read the report before choosing a repair\n\nResolve the script from this skill's resource directory. Run it **unconfined from the first diagnostic call**: the confined token cannot inspect the conflicting object. This does not elevate the Windows token. Use one path per invocation; do not repeat `-Path` or pass a comma-separated string to `pwsh -File`.\n\n```powershell\n& '\\scripts\\diagnose-windows-sandbox-acl.ps1' -Path '' -Out '' -Compact\nexit $LASTEXITCODE\n```\n\nSubstitute full, quoted paths. Choose a persistent, user-owned `-Out` directory, preferably beside the failing workspace within the authorized tree. Never put reports or backups in the skill resource directory: it is deleted when the skill unloads. `-Compact` saves a unique full JSONL report and prints one `REPORT` summary. Diagnosis changes no ACL or existing content; it only writes this requested report. The summary includes all `inspectedPaths`, findings, decisions, ACL actions, verification, rollback state and `nextAction`. Use the summary to decide; read only specific records from the saved report when evidence is missing, without dumping the whole report or reading the implementation to rediscover these instructions.\n\nEach full record has `kind`, `operation`, `path`, `status`, `reason`, `details`. Unknown observations remain unknown. A deny ACE's presence alone does not establish causation. DSH provisions `S-1-4-*` workspace grants and an Everyone `DeleteSubdirectoriesAndFiles` deny; these are expected confinement entries, not package conflicts or automatically unresolved faults. Coexisting package ACEs may still block the confined child even when provisioning fails first; do not dismiss either finding. `completed` actions only confirm API execution. Check verification and the final summary. Missing summary means unconfirmed completion: inspect the report and recovery artifacts before doing anything else.\n\n| Verdict | Next step, only with complete observations |\n|---|---|\n| `CULPRIT` | Explain the affected paths and individual package allow SIDs; select `-Fix`. |\n| `PRECONDITION` | Explain missing effective `WRITE_DAC`/`WRITE_OWNER`; select `-GrantFullControl` if the caller has `WRITE_DAC`. |\n| `BOTH` | Grant on the affected object first. Only after verified success may a separate invocation use `-Fix`. Failure ends this sequence. |\n| `UNREADABLE`, `INCOMPLETE`, `NOT_THIS_CLASS` | Report observations and stop; do not infer a safe repair. |\n\n## Run the selected repair\n\nExplain the finding and cost first, and briefly apologize for the harness's unhelpful denial after confirming the conflict. `-Fix` removes individual `S-1-15-2-*` allow ACEs and their access; well-known groups ending in 1 or 2 are preserved. `-GrantFullControl` adds a current-user allow ACE; it cannot cancel an explicit deny. Both preserve owner and SACL. Full control supplies `WRITE_DAC` and `WRITE_OWNER`; taking ownership alone does not.\n\nAppend `-AllowRoot ''` and **exactly one** of `-Fix` or `-GrantFullControl` to the diagnostic command, before `exit`. Keep `-Compact -Out`. Targets must be strictly inside that root; reparse paths are refused. Never widen the authorized root to reach an ancestor. Missing effective `WRITE_DAC` in the unconfined caller requires handing the user an exact command for their elevated terminal, not another sandbox escalation. Never initiate UAC/`runas` or run the whole agent elevated.\n\nFollow `nextAction`: `stop` ends repairs; `restore_pending_then_stop` requires the reported recovery commands in their listed order, then stop. Verified rollback does not turn a failed repair into success. Keep full report/backup paths and exact recovery commands for every invocation; never abbreviate executable paths. On recovery failure, stop and report the remaining commands without more repairs.\n\nFor `verify_original_confined_operation`, repeat the original failed operation in the original confined context. If it still fails, restore this workflow's successful repairs in reverse order, report the error and stop. Do not start another repair cycle.\n\nUse the user's language for progress and final reporting. Concisely report observations and analyzed paths (including relevant ancestors), actions and reasons, verification, and the next step; link full reports. Label any authorized unconfined fallback as such, not as successful repair. Explain that another tool may recreate a removed package ACE. Never write probe files, edit ACLs by hand, change owners, erase denies, or replace child permissions recursively. Do not provide manual ACL-edit commands for the user either: only bundled repairs and generated recovery commands are supported; unresolved denies require permission-policy review or moving the workspace.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4},"stream":[{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430709,"index":0,"dt":[],"texts":["The skill is loaded."]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699430709,"index":1,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} From 7c3c149187a52002eebed0a1c57cd1032b6e9118 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Fri, 25 Sep 2026 21:39:24 +0800 Subject: [PATCH 04/86] fix(ci): normalize ACL fixture paths and refresh module graph --- docs/module-graph.md | 5 +++-- docs/module-graph.zh.md | 5 +++-- .../sandbox-windows-acl/tests/diagnose-script.spec.ts | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/module-graph.md b/docs/module-graph.md index b005413ba3..367bb547a6 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -478,7 +478,6 @@ flowchart TD pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_launch_environment pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use - pkg_sandbox_windows_acl --> pkg_subprocess pkg_subprocess_local --> pkg_subprocess pkg_subprocess_local --> pkg_timeout pkg_skill_badge --> pkg_skill @@ -498,6 +497,8 @@ flowchart TD pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session + pkg_sandbox_windows_acl --> pkg_skill + pkg_sandbox_windows_acl --> pkg_subprocess pkg_session_format_catalog --> pkg_session pkg_session_log_deepseek --> pkg_deepseek_llm_api_extensions pkg_session_log_deepseek --> pkg_invariants @@ -1504,7 +1505,6 @@ flowchart TD | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | -| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`subprocess`](../packages/subprocess/subprocess) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | @@ -1515,6 +1515,7 @@ flowchart TD | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index fe41849447..0c159f87f1 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -480,7 +480,6 @@ flowchart TD pkg_credentials_local --> pkg_home_paths pkg_credentials_local --> pkg_launch_environment pkg_experimental_computer_use_cua_driver_mcp --> pkg_computer_use - pkg_sandbox_windows_acl --> pkg_subprocess pkg_subprocess_local --> pkg_subprocess pkg_subprocess_local --> pkg_timeout pkg_skill_badge --> pkg_skill @@ -500,6 +499,8 @@ flowchart TD pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_llm pkg_sandbox --> pkg_session + pkg_sandbox_windows_acl --> pkg_skill + pkg_sandbox_windows_acl --> pkg_subprocess pkg_session_format_catalog --> pkg_session pkg_session_log_deepseek --> pkg_deepseek_llm_api_extensions pkg_session_log_deepseek --> pkg_invariants @@ -1506,7 +1507,6 @@ flowchart TD | [`authorization`](../packages/credentials/authorization) | `credentials` | [`credentials`](../packages/credentials/credentials), [`invariants`](../packages/runtime-diagnostics/invariants), [`llm`](../packages/llm/llm) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`home-paths`](../packages/util/home-paths), [`launch-environment`](../packages/util/launch-environment) | | [`experimental-computer-use-cua-driver-mcp`](../packages/experimental/computer-use-cua-driver-mcp) | `experimental` | [`computer-use`](../packages/computer-use/computer-use) | -| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`subprocess`](../packages/subprocess/subprocess) | | [`subprocess-local`](../packages/subprocess/subprocess-local) | `subprocess` | [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`skill-badge`](../packages/skill/skill-badge) | `skill` | [`skill`](../packages/skill/skill) | | [`skill-office`](../packages/skill/skill-office) | `skill` | [`skill`](../packages/skill/skill) | @@ -1517,6 +1517,7 @@ flowchart TD | [`host-frontend-static`](../packages/host/frontend-static) | `host` | [`client-connection`](../packages/client/connection), [`host-webserver`](../packages/host/webserver) | | [`persona`](../packages/preset/persona) | `preset` | [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl) | `sandbox` | [`skill`](../packages/skill/skill), [`subprocess`](../packages/subprocess/subprocess) | | [`session-format-catalog`](../packages/session/session-format-catalog) | `session` | [`session`](../packages/core/session) | | [`session-log-deepseek`](../packages/session/session-log-deepseek) | `session` | [`deepseek-llm-api-extensions`](../packages/llm/deepseek-llm-api-extensions), [`invariants`](../packages/runtime-diagnostics/invariants), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | diff --git a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts index ff1dc37c25..f6b7593826 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts @@ -14,7 +14,7 @@ */ import { execFileSync } from 'node:child_process' -import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, realpathSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -125,7 +125,8 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri } beforeAll(() => { - scratch = mkdtempSync(join(tmpdir(), 'dsh-acl-diagnose-')) + // Windows runners may expose TEMP through an 8.3 alias; PowerShell reports long paths. + scratch = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-diagnose-'))) outDir = makeDir('out') meSid = pwsh('[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value').trim() }) From 7152b91e7ab6d805d1885d60b11c8658d5c438df Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:47:59 +0800 Subject: [PATCH 05/86] test(web): keep plugin install routes active until page closes --- ...gin-install-interceptor-lifetime.i18n.yaml | 22 ++++++++++ ...-27-plugin-install-interceptor-lifetime.md | 33 ++++++++++++++ ...-plugin-install-interceptor-lifetime.zh.md | 33 ++++++++++++++ ...ssertions-name-their-input-state.i18n.yaml | 4 +- ...-lane-assertions-name-their-input-state.md | 2 +- ...ne-assertions-name-their-input-state.zh.md | 2 +- apps/web/tests/plugin-install-cancel.e2e.ts | 44 ++++++++++--------- 7 files changed, 116 insertions(+), 24 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.md create mode 100644 .agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.zh.md diff --git a/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.i18n.yaml new file mode 100644 index 0000000000..7254ae9ab6 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-27-plugin-install-interceptor-lifetime.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.md +/agent-note-keep-plugin-install-interception-for-the-page-lifetime: + en: 2f732d10bfaf9f5b + zh: fff9f6cf8bcd86a7 +/agent-note-keep-plugin-install-interception-for-the-page-lifetime/problem: + en: 71b72bb5b13530bb + zh: b78170e3ac09cccf +/agent-note-keep-plugin-install-interception-for-the-page-lifetime/decision: + en: 45d689dced9dad9f + zh: cb89fbbb7937ca8e +/agent-note-keep-plugin-install-interception-for-the-page-lifetime/alternatives-considered: + en: 1027b59d76cd3ff7 + zh: 89911c72312472a4 +/agent-note-keep-plugin-install-interception-for-the-page-lifetime/verification: + en: 4f462a888328316a + zh: a3b5ebe7ca64a663 +/agent-note-keep-plugin-install-interception-for-the-page-lifetime/consequences: + en: 4fefad98c1cc3460 + zh: d6479322d0986354 diff --git a/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.md b/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.md new file mode 100644 index 0000000000..8d9caddcd8 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.md @@ -0,0 +1,33 @@ +# Agent Note: Keep plugin-install interception for the page lifetime + +Status: implemented + +English | [中文](2026-09-27-plugin-install-interceptor-lifetime.zh.md) + +## Problem + +The [plugin-install scenario](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) can leave a directory read pending after cancelling an installation. The Host emits invalidations that trigger background reads while the scenario removes its Playwright routes between installation phases. A stalled read keeps the installed package out of the list, so enabling it closes the dialog but never exposes the highlighted card. + +## Decision + +The scenario registers its `installBundle` and `cancelInstall` interceptors before navigation and retains them until the browser closes. Each interceptor calls a typed handler that the scenario replaces when it changes phases. The delivery and cancellation barriers retain their ordering, and `activeReplySettled` joins the lost-response handler before the next installation starts. Ordinary requests still reach the real Host. + +The [input-state decision](2026-09-27-web-lane-assertions-name-their-input-state.md) owns the separate wait for the highlighted card's status to become `running`. Keeping interception active lets the directory read complete; polling its result still establishes the state the golden records. + +## Alternatives considered + +**Remove routes with `unrouteAll({ behavior: 'wait' })`.** Waiting for matched handlers does not settle unrelated browser requests. A captured Chromium protocol trace shows `Fetch.disable` immediately followed by a `pluginManager/listPlugins` request with no response or failure before teardown. The Host's inventory and plugin methods still answer direct calls. + +**Advance the browser clock or extend the locator timeout.** Advancing the paused clock does not release the stalled request. The 2.4-second highlight expiry is not responsible for a package that never reaches the directory. + +**Add a general route-switching helper.** Two endpoint handlers in one scenario do not need another fixture API. Their local delegates preserve the existing cancellation and lost-response cases without changing the browser's interception configuration. + +## Verification + +The unchanged scenario passes alone and fails at the same highlighted-card locator in one of four concurrent independent processes. Instrumented failures place the unanswered directory request inside the second `unrouteAll` interval; advancing the page clock leaves it unanswered, while direct Host reads complete. The protocol trace identifies the interception transition independently of the DOM assertion. + +The focused replay runs the cancellation-before-delivery barrier, cancellation of the running child, manifest and lockfile restoration, retry, enabled-card status, both themes and motion preferences, highlight expiry, and lost-result recovery against the existing goldens. + +## Consequences + +The browser retains two narrowly matched interceptors for its lifetime. The scenario controls their behavior without interrupting concurrent directory reads, and browser closure owns their cleanup. Product code, goldens, test deadlines, and CI worker counts are unchanged. diff --git a/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.zh.md b/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.zh.md new file mode 100644 index 0000000000..fec6faadad --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 让插件安装拦截器持续到页面关闭 + +Status: implemented + +[English](2026-09-27-plugin-install-interceptor-lifetime.md) | 中文 + +## Problem + +[插件安装场景](../../../../apps/web/tests/plugin-install-cancel.e2e.ts) 在取消安装后,可能留下一次未结束的目录读取。场景在安装阶段之间移除 Playwright 路由时,Host 发出的失效通知仍会触发后台读取。停滞的读取使已安装的包无法进入列表,因此启用操作虽然关闭了对话框,却始终不显示高亮卡片。 + +## Decision + +场景在导航前注册 `installBundle` 和 `cancelInstall` 拦截器,并保留到浏览器关闭。每个拦截器调用一个带类型的处理函数,场景切换阶段时替换该函数。请求交付和取消屏障保持原有顺序,`activeReplySettled` 在下一次安装开始前等待丢失响应的处理函数结束。普通请求仍然到达真实 Host。 + +[输入状态决策](2026-09-27-web-lane-assertions-name-their-input-state.zh.md) 负责另一项等待:高亮卡片的状态变为 `running`。持续启用拦截使目录读取能够完成;轮询其结果仍负责确立预期输出记录的状态。 + +## Alternatives considered + +**用 `unrouteAll({ behavior: 'wait' })` 移除路由。** 等待匹配的处理函数结束并不意味着其他浏览器请求已结束。捕获的 Chromium 协议记录显示,`Fetch.disable` 后紧接着发出 `pluginManager/listPlugins` 请求,直到清理前都没有响应或失败事件。直接调用 Host 的目录和插件方法仍能得到答复。 + +**推进浏览器时钟或延长 locator 超时。** 推进已暂停的时钟不能释放停滞的请求。包始终未进入目录,不是 2.4 秒的高亮到期计时器造成的。 + +**增加通用路由切换辅助函数。** 一个场景中的两个端点处理函数不需要另一层测试装置 API。局部委托函数保留了现有取消和丢失响应场景,也不改变浏览器的拦截配置。 + +## Verification + +未修改的场景单独运行通过,四个并发独立进程中有一个在同一高亮卡片 locator 处失败。插桩失败将未获响应的目录请求定位到第二次 `unrouteAll` 的执行区间内;推进页面时钟后请求仍未获响应,而直接读取 Host 可以完成。协议记录独立于 DOM 断言确认了拦截状态的切换。 + +定向重放使用现有预期输出,覆盖取消先于请求交付的屏障、取消运行中的子进程、恢复 manifest 和 lockfile、重试、已启用卡片状态、两种主题与动画偏好、高亮到期,以及丢失结果恢复。 + +## Consequences + +浏览器在整个生命周期内保留两个精确匹配的拦截器。场景控制其行为时不会中断并发目录读取,浏览器关闭负责清理它们。产品代码、预期输出、测试超时和 CI worker 数保持不变。 diff --git a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml index 76b9ec6213..79196fb74a 100644 --- a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml @@ -15,8 +15,8 @@ en: 6cd5940f55500c3d zh: ded68b931ed81e10 /agent-note-web-lane-assertions-name-their-input-state/verification: - en: 6d11abb7a60f81ae - zh: 42bc43777a3b02b7 + en: 9a35f9dbc4285674 + zh: 5d78a89141ce8f62 /agent-note-web-lane-assertions-name-their-input-state/consequences: en: 2ecf084ca4df49c4 zh: bbd8823fd0910609 diff --git a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md index acee58b51a..9a7ea05f68 100644 --- a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md +++ b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.md @@ -40,7 +40,7 @@ Two negative controls reproduce the recorded signatures with the shipped asserti Measured locally, one file at a time: `DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/.e2e.ts` → workspace-new-session-folding 15/15 before the change and 20/20 after, turn-tail-actions 8/8 before and 20/20 after, plugin-install-cancel 12/12 before and 20/20 after. The lane configuration `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` passes all three files in three consecutive local runs. -The install-cancel scenario carries a residual exposure this change does not remove and whose cause is not diagnosed: twice on `node 24 / snapshots and artifacts`, and once locally with the pre-change file on the same lane command, the highlighted card locator at `plugin-install-cancel.e2e.ts:217` stayed unmatched for 30 s after the enable click closed the dialog. The paused page clock rules out the highlight expiry timer, so the wait is not racing that; no local focused run or the three lane runs above reproduced it, and nothing here is known to hang the store's `load()`. The poll added by this change covers the status read after the card appears, not this locator. +The install-cancel scenario also recorded a separate 30 s highlighted-card wait after the enable click closed the dialog: twice on `node 24 / snapshots and artifacts`, and once locally with the pre-change file on the same lane command. The page clock was paused, excluding highlight expiry. The [interceptor-lifetime decision](2026-09-27-plugin-install-interceptor-lifetime.md) owns the diagnosed directory-request stall and its fixture fix. The status poll still covers the read after the card appears. The WebKit case the serial master lane also failed is not a scenario race and stays out of this change: `declared-reasoning.e2e.ts` launches Playwright's WebKit, the lane's step fetched that browser without its host libraries, and `browserType.launch` failed with "Host system is missing dependencies to run browsers". [ci.yml](../../../../.github/workflows/ci.yml) states the persistent VM image owns Playwright's Linux system packages and installs the dependency set only off the self-hosted pool, so repairing the image or the pool's ownership is a runner change, not a scenario one. Installing WebKit's libraries on a developer machine makes the file's 8 cases pass. diff --git a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md index 7b7d3bc603..cb8ec4da2f 100644 --- a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md +++ b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md @@ -40,7 +40,7 @@ install-cancel 场景改为轮询卡片状态,而不再单次采样,因此 本地按文件实测:`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/.e2e.ts` → workspace-new-session-folding 改动前 15/15、改动后 20/20,turn-tail-actions 改动前 8/8、改动后 20/20,plugin-install-cancel 改动前 12/12、改动后 20/20。通道配置 `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` 连续三次运行中三个文件全部通过。 -install-cancel 场景仍带有本次改动未消除、且原因尚未定位的残余暴露:`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都在启用点击关闭对话框后让 `plugin-install-cancel.e2e.ts:217` 的高亮卡片 locator 持续 30 s 未匹配。页面时钟已暂停,因此可排除高亮到期计时器,该等待并非与它竞争;上述三次通道运行与所有单文件运行都未复现,且尚无证据表明 store 的 `load()` 会挂起。本次改动新增的轮询覆盖的是卡片出现之后的状态读取,而不是这个 locator。 +install-cancel 场景还记录过独立的 30 s 高亮卡片等待:启用点击关闭对话框后,`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都未能找到卡片。当时页面时钟已暂停,因此可排除高亮到期。[拦截器生命周期决策](2026-09-27-plugin-install-interceptor-lifetime.zh.md) 负责已定位的目录请求停滞及其测试装置修复。状态轮询仍覆盖卡片出现之后的读取。 串行 master 通道同时失败的 WebKit 用例不是场景竞态,也不属于本次改动:`declared-reasoning.e2e.ts` 启动 Playwright 的 WebKit,通道的步骤只取浏览器本体而未安装宿主库,于是 `browserType.launch` 以 “Host system is missing dependencies to run browsers” 失败。[ci.yml](../../../../.github/workflows/ci.yml) 明确说明持久 VM 镜像拥有 Playwright 的 Linux 系统包,并且只在非自托管池随浏览器安装依赖集,因此修复镜像或该池的归属是 runner 侧的改动,而非场景改动。在开发者机器上安装 WebKit 依赖后该文件的 8 个用例全部通过。 diff --git a/apps/web/tests/plugin-install-cancel.e2e.ts b/apps/web/tests/plugin-install-cancel.e2e.ts index 1ab1e17dd0..be0a7332d2 100644 --- a/apps/web/tests/plugin-install-cancel.e2e.ts +++ b/apps/web/tests/plugin-install-cancel.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, readFile, writeFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { chromium, type Locator, type Page } from 'playwright' +import { chromium, type Locator, type Page, type Route } from 'playwright' import { expect, it } from 'vitest' import { launchWebScaffold, captureStableAria, compareOrRefreshGolden, webSnapshotMode, watchConsole, type WebScaffold } from './scaffold.ts' import { ZH_BROWSER_LOCALE } from './support.ts' @@ -87,6 +87,23 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha `) const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: ZH_BROWSER_LOCALE }) const tripwire = watchConsole(page) + // Hold request delivery and the real Host's cancellation reply independently. + // Cancellation reaches the Host before the install it names. + const cancellationArrived = Promise.withResolvers() + let installBundleRoute = async (route: Route): Promise => { + await delivery.promise + await route.continue() + } + let cancelInstallRoute = async (route: Route): Promise => { + const response = await route.fetch() + cancellationArrived.resolve(undefined) + await cancellationReply.promise + await route.fulfill({ response }) + } + // Host invalidations keep reading the directory between install phases. + // Keep interception enabled from navigation to page closure so phase changes cannot strand those requests. + await page.route('**/api/pluginManager/installBundle', route => installBundleRoute(route)) + await page.route('**/api/pluginManager/cancelInstall', route => cancelInstallRoute(route)) await page.clock.install() await page.goto(scaffold.authenticatedUrl) await page.waitForSelector('[class*="frame"]') @@ -120,19 +137,6 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/invalid-registry.expected.md', import.meta.url)), `${invalidRegistryAria}\n\n${JSON.stringify({ registry: registryStyles, packageName: packageStyles }, null, 2)}`, webSnapshotMode()) await packageName.fill('slow-package') - // Hold request delivery and the real Host's cancellation reply independently. - // Cancellation reaches the Host before the install it names. - const cancellationArrived = Promise.withResolvers() - await page.route('**/api/pluginManager/installBundle', async (route) => { - await delivery.promise - await route.continue() - }) - await page.route('**/api/pluginManager/cancelInstall', async (route) => { - const response = await route.fetch() - cancellationArrived.resolve(undefined) - await cancellationReply.promise - await route.fulfill({ response }) - }) await dialog.getByRole('button', { name: '安装', exact: true }).click() await dialog.getByText('正在准备安装…', { exact: true }).waitFor() await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/preparing.expected.md', import.meta.url)), @@ -156,10 +160,10 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha await page.getByText('已取消安装,插件未启用,下载的文件可能保留', { exact: true }).waitFor() expect(await readFile(manifestPath, 'utf8')).toBe(manifest) expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n') - await page.unrouteAll({ behavior: 'wait' }) + cancelInstallRoute = route => route.continue() // Drop the browser's response while the real Host still owns the child process. const activeReplySettled = Promise.withResolvers() - await page.route('**/api/pluginManager/installBundle', async (route) => { + installBundleRoute = async (route) => { const response = route.fetch().then(() => undefined, (error: unknown) => error) try { await loseActiveReply.promise @@ -168,7 +172,7 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha } finally { activeReplySettled.resolve(undefined) } - }) + } await panel.getByRole('button', { name: '添加插件', exact: true }).click() await dialog.getByRole('textbox').fill('slow-package') await dialog.getByRole('button', { name: '安装', exact: true }).click() @@ -186,7 +190,7 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha expect(await readFile(lockPath, 'utf8')).toBe('original lockfile\n') expect(await dialog.getByRole('textbox').inputValue()).toBe('slow-package') await activeReplySettled.promise - await page.unrouteAll({ behavior: 'wait' }) + installBundleRoute = route => route.continue() const snapshot = (await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) .split(process.execPath).join('{{node}}') .split(scaffold.harnessHome).join('{{harnessHome}}') @@ -257,10 +261,10 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha } await panel.getByRole('button', { name: '添加插件', exact: true }).click() await dialog.getByRole('textbox').fill('recovered-package') - await page.route('**/api/pluginManager/installBundle', async (route) => { + installBundleRoute = async (route) => { await route.fetch() await route.abort('failed') - }) + } await dialog.getByRole('button', { name: '安装', exact: true }).click() await dialog.getByText('未能获取安装结果', { exact: true }).waitFor() await compareOrRefreshGolden(fileURLToPath(new URL('./expected/plugin-install-cancel/unknown.expected.md', import.meta.url)), From 4b19616206b88778caca9631c0c0be86fbcf931e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Sun, 27 Sep 2026 23:03:40 +0800 Subject: [PATCH 06/86] docs(testing): clarify plugin interception evidence and terminology --- ...09-27-plugin-install-interceptor-lifetime.i18n.yaml | 10 +++++----- .../2026-09-27-plugin-install-interceptor-lifetime.md | 4 +++- ...026-09-27-plugin-install-interceptor-lifetime.zh.md | 6 ++++-- ...eb-lane-assertions-name-their-input-state.i18n.yaml | 4 ++-- ...27-web-lane-assertions-name-their-input-state.zh.md | 4 ++-- apps/web/tests/plugin-install-cancel.e2e.ts | 3 ++- 6 files changed, 18 insertions(+), 13 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.i18n.yaml index 7254ae9ab6..f39a22bba4 100644 --- a/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.i18n.yaml @@ -13,10 +13,10 @@ zh: cb89fbbb7937ca8e /agent-note-keep-plugin-install-interception-for-the-page-lifetime/alternatives-considered: en: 1027b59d76cd3ff7 - zh: 89911c72312472a4 + zh: c4c60fb406acb1db /agent-note-keep-plugin-install-interception-for-the-page-lifetime/verification: - en: 4f462a888328316a - zh: a3b5ebe7ca64a663 + en: 0e7fbf7fbb47b07c + zh: 9b52991ad8b355ca /agent-note-keep-plugin-install-interception-for-the-page-lifetime/consequences: - en: 4fefad98c1cc3460 - zh: d6479322d0986354 + en: 3583e26c8ec33e03 + zh: 6080722751cf15e6 diff --git a/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.md b/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.md index 8d9caddcd8..1db162d210 100644 --- a/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.md +++ b/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.md @@ -26,8 +26,10 @@ The [input-state decision](2026-09-27-web-lane-assertions-name-their-input-state The unchanged scenario passes alone and fails at the same highlighted-card locator in one of four concurrent independent processes. Instrumented failures place the unanswered directory request inside the second `unrouteAll` interval; advancing the page clock leaves it unanswered, while direct Host reads complete. The protocol trace identifies the interception transition independently of the DOM assertion. -The focused replay runs the cancellation-before-delivery barrier, cancellation of the running child, manifest and lockfile restoration, retry, enabled-card status, both themes and motion preferences, highlight expiry, and lost-result recovery against the existing goldens. +`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/plugin-install-cancel.e2e.ts` passes in four concurrent independent processes (1/1 each). One protocol capture observes one `Fetch.enable`, no `Fetch.disable`, and responses for all 33 directory requests. The existing goldens cover cancellation before delivery, cancellation of the running child, manifest and lockfile restoration, retry, enabled-card status, both themes and motion preferences, highlight expiry, and lost-result recovery. ## Consequences The browser retains two narrowly matched interceptors for its lifetime. The scenario controls their behavior without interrupting concurrent directory reads, and browser closure owns their cleanup. Product code, goldens, test deadlines, and CI worker counts are unchanged. + +Live-page `unrouteAll` calls also remain in [sidebar-terminal](../../../../apps/web/tests/sidebar-terminal.e2e.ts), [queue-image](../../../../apps/web/tests/queue-image.e2e.ts), [subagent-conversation](../../../../apps/web/tests/subagent-conversation.e2e.ts), and [subagent-interrupt-ui](../../../../apps/web/tests/subagent-interrupt-ui.e2e.ts). These are candidates for separate overlap investigations; this scenario's reproduction does not establish the cause of failures in those tests. diff --git a/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.zh.md b/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.zh.md index fec6faadad..942a201fc8 100644 --- a/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.zh.md +++ b/.agents/notes/implemented/testing/2026-09-27-plugin-install-interceptor-lifetime.zh.md @@ -20,14 +20,16 @@ Status: implemented **推进浏览器时钟或延长 locator 超时。** 推进已暂停的时钟不能释放停滞的请求。包始终未进入目录,不是 2.4 秒的高亮到期计时器造成的。 -**增加通用路由切换辅助函数。** 一个场景中的两个端点处理函数不需要另一层测试装置 API。局部委托函数保留了现有取消和丢失响应场景,也不改变浏览器的拦截配置。 +**增加通用路由切换辅助函数。** 一个场景中的两个端点处理函数不需要另一层 fixture(测试前置数据)API。局部委托函数保留了现有取消和丢失响应场景,也不改变浏览器的拦截配置。 ## Verification 未修改的场景单独运行通过,四个并发独立进程中有一个在同一高亮卡片 locator 处失败。插桩失败将未获响应的目录请求定位到第二次 `unrouteAll` 的执行区间内;推进页面时钟后请求仍未获响应,而直接读取 Host 可以完成。协议记录独立于 DOM 断言确认了拦截状态的切换。 -定向重放使用现有预期输出,覆盖取消先于请求交付的屏障、取消运行中的子进程、恢复 manifest 和 lockfile、重试、已启用卡片状态、两种主题与动画偏好、高亮到期,以及丢失结果恢复。 +`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/plugin-install-cancel.e2e.ts` 在四个并发独立进程中全部通过,每个进程均为 1/1。一次协议记录观察到一次 `Fetch.enable`、零次 `Fetch.disable`,且全部 33 次目录请求均收到响应。现有预期输出覆盖取消先于请求交付、取消运行中的子进程、恢复 manifest(元数据清单)与 lockfile、重试、已启用卡片状态、两种主题与动画偏好、高亮到期,以及丢失结果恢复。 ## Consequences 浏览器在整个生命周期内保留两个精确匹配的拦截器。场景控制其行为时不会中断并发目录读取,浏览器关闭负责清理它们。产品代码、预期输出、测试超时和 CI worker 数保持不变。 + +[sidebar-terminal](../../../../apps/web/tests/sidebar-terminal.e2e.ts)、[queue-image](../../../../apps/web/tests/queue-image.e2e.ts)、[subagent-conversation](../../../../apps/web/tests/subagent-conversation.e2e.ts) 和 [subagent-interrupt-ui](../../../../apps/web/tests/subagent-interrupt-ui.e2e.ts) 中也仍有页面存续期间的 `unrouteAll` 调用。这些位置可分别调查请求重叠;本场景的复现并不能确定那些测试失败的原因。 diff --git a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml index 79196fb74a..efc9b517a3 100644 --- a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.i18n.yaml @@ -13,10 +13,10 @@ zh: 4fe1f6abd6fd4c1e /agent-note-web-lane-assertions-name-their-input-state/alternatives-considered: en: 6cd5940f55500c3d - zh: ded68b931ed81e10 + zh: 4a2e13717e87b783 /agent-note-web-lane-assertions-name-their-input-state/verification: en: 9a35f9dbc4285674 - zh: 5d78a89141ce8f62 + zh: 0995a8b137912b6b /agent-note-web-lane-assertions-name-their-input-state/consequences: en: 2ecf084ca4df49c4 zh: bbd8823fd0910609 diff --git a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md index cb8ec4da2f..a6fc717c7e 100644 --- a/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md +++ b/.agents/notes/implemented/testing/2026-09-27-web-lane-assertions-name-their-input-state.zh.md @@ -32,7 +32,7 @@ install-cancel 场景改为轮询卡片状态,而不再单次采样,因此 **在场景内禁用悬停样式或 tooltip 的模态规则。** 两者都是 golden 所证明的已发布渲染与交互行为;场景级覆盖将测试一个产品并不提供的页面。 -**提高捕获超时或重试比较。** 两者都不能确立实际捕获到的是哪个状态;`captureStableAria` 本已等待连续两次快照相等,而错误的状态同样能满足它——这一限度已由 [CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 与 [连接与压缩 fixture 前置条件](2026-09-12-connection-and-compaction-fixture-preconditions.zh.md) 记录。 +**提高捕获超时或重试比较。** 两者都不能确立实际捕获到的是哪个状态;`captureStableAria` 本已等待连续两次快照相等,而错误的状态同样能满足它——这一限度已由 [CI 就绪与完成决策](2026-09-08-ci-readiness-and-completion.zh.md) 与 [连接与压缩 fixture(测试前置数据)前置条件](2026-09-12-connection-and-compaction-fixture-preconditions.zh.md) 记录。 ## Verification @@ -40,7 +40,7 @@ install-cancel 场景改为轮询卡片状态,而不再单次采样,因此 本地按文件实测:`DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/.e2e.ts` → workspace-new-session-folding 改动前 15/15、改动后 20/20,turn-tail-actions 改动前 8/8、改动后 20/20,plugin-install-cancel 改动前 12/12、改动后 20/20。通道配置 `DSH_SNAPSHOT=replay DSH_WEB_SNAPSHOT_WORKERS=16 pnpm run test:web:ci` 连续三次运行中三个文件全部通过。 -install-cancel 场景还记录过独立的 30 s 高亮卡片等待:启用点击关闭对话框后,`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都未能找到卡片。当时页面时钟已暂停,因此可排除高亮到期。[拦截器生命周期决策](2026-09-27-plugin-install-interceptor-lifetime.zh.md) 负责已定位的目录请求停滞及其测试装置修复。状态轮询仍覆盖卡片出现之后的读取。 +install-cancel 场景还记录过独立的 30 s 高亮卡片等待:启用点击关闭对话框后,`node 24 / snapshots and artifacts` 上有两次、把该文件恢复到改动前版本后用同一通道命令运行的本地一次,都未能找到卡片。当时页面时钟已暂停,因此可排除高亮到期。[拦截器生命周期决策](2026-09-27-plugin-install-interceptor-lifetime.zh.md) 负责已定位的目录请求停滞及其 fixture 修复。状态轮询仍覆盖卡片出现之后的读取。 串行 master 通道同时失败的 WebKit 用例不是场景竞态,也不属于本次改动:`declared-reasoning.e2e.ts` 启动 Playwright 的 WebKit,通道的步骤只取浏览器本体而未安装宿主库,于是 `browserType.launch` 以 “Host system is missing dependencies to run browsers” 失败。[ci.yml](../../../../.github/workflows/ci.yml) 明确说明持久 VM 镜像拥有 Playwright 的 Linux 系统包,并且只在非自托管池随浏览器安装依赖集,因此修复镜像或该池的归属是 runner 侧的改动,而非场景改动。在开发者机器上安装 WebKit 依赖后该文件的 8 个用例全部通过。 diff --git a/apps/web/tests/plugin-install-cancel.e2e.ts b/apps/web/tests/plugin-install-cancel.e2e.ts index be0a7332d2..aca59dcc78 100644 --- a/apps/web/tests/plugin-install-cancel.e2e.ts +++ b/apps/web/tests/plugin-install-cancel.e2e.ts @@ -101,7 +101,8 @@ it('cancels installation, retries and highlights the enabled plugin at 40% alpha await route.fulfill({ response }) } // Host invalidations keep reading the directory between install phases. - // Keep interception enabled from navigation to page closure so phase changes cannot strand those requests. + // Interception stays enabled from before navigation until page closure, + // so phase changes cannot strand those requests. await page.route('**/api/pluginManager/installBundle', route => installBundleRoute(route)) await page.route('**/api/pluginManager/cancelInstall', route => cancelInstallRoute(route)) await page.clock.install() From 5bab07157f3131ee60c222f25e6a1ea8e92830d7 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 28 Sep 2026 00:18:06 +0800 Subject: [PATCH 07/86] fix(sandbox): repair inherited ACL entries within normalized roots --- ...ows-acl-restricted-token-sandbox.i18n.yaml | 5 +- ...08-windows-acl-restricted-token-sandbox.md | 8 + ...windows-acl-restricted-token-sandbox.zh.md | 9 + apps/desktop/tests/acl-skill.built.e2e.ts | 1 + docs/capability-seams.i18n.yaml | 4 +- docs/capability-seams.md | 4 +- docs/capability-seams.zh.md | 4 +- docs/subsystems/skills.i18n.yaml | 4 +- docs/subsystems/skills.md | 4 +- docs/subsystems/skills.zh.md | 4 +- .../sandbox/sandbox-local/README.i18n.yaml | 4 +- packages/sandbox/sandbox-local/README.md | 2 + packages/sandbox/sandbox-local/README.zh.md | 2 + packages/sandbox/sandbox-local/src/index.ts | 7 +- .../sandbox-local/tests/acl-grants.spec.ts | 1 + .../sandbox-windows-acl/README.i18n.yaml | 10 +- .../sandbox/sandbox-windows-acl/README.md | 8 +- .../sandbox/sandbox-windows-acl/README.zh.md | 10 +- .../diagnose-windows-sandbox-acl/SKILL.md | 4 +- .../scripts/diagnose-windows-sandbox-acl.ps1 | 74 +++++--- .../sandbox/sandbox-windows-acl/package.json | 2 + .../tests/diagnose-script.spec.ts | 161 ++++++++++++++++-- .../fixtures/skill-composition/cordis.yml | 4 + .../tests/skill-composition.spec.ts | 63 +++++++ pnpm-lock.yaml | 6 + scripts/gen-doc-graphs.ts | 2 +- .../windows-acl-skill/session.v4.jsonl | 4 +- .../.dsh/skills/model-only-skill/SKILL.md | 7 - .../.dsh/skills/snapshot-skill/SKILL.md | 7 - .../.dsh/skills/user-only-skill/SKILL.md | 7 - 30 files changed, 333 insertions(+), 99 deletions(-) create mode 100644 packages/sandbox/sandbox-windows-acl/tests/fixtures/skill-composition/cordis.yml create mode 100644 packages/sandbox/sandbox-windows-acl/tests/skill-composition.spec.ts delete mode 100644 snapshots/session/windows-acl-skill/workspace/.dsh/skills/model-only-skill/SKILL.md delete mode 100644 snapshots/session/windows-acl-skill/workspace/.dsh/skills/snapshot-skill/SKILL.md delete mode 100644 snapshots/session/windows-acl-skill/workspace/.dsh/skills/user-only-skill/SKILL.md 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 cfa5b6c2ad..7c1c12f8bd 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 @@ -10,7 +10,10 @@ zh: 91844972d41dd6f9 /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/decision: en: 125dea7427047966 - zh: 5d0bfd3921b38714 + zh: 1e459b1106597695 +/agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/acl-diagnosis-and-recovery: + en: 8ea2f91a914d540b + zh: cafada631044e43a /agent-note-windows-sandbox-rung-raw-acl-restricted-tokens-over-mxc-and-appcontainer/how-the-restriction-works-why-no-new-identity: en: ac9b3d800889ea39 zh: 9db1065b6027f65f 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 65780b9bba..ecb2125d33 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 @@ -12,6 +12,14 @@ The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win 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. +## ACL diagnosis and recovery + +The bundled diagnosis skill runs its preserving ACL repairs outside the confined token because that token cannot inspect the conflicting objects. External PowerShell requires physical script files, so registration extracts resources from ASAR or SEA instead of exposing archive paths. The fiber owns that temporary copy. Recovery files live in the caller's persistent output directory so disposal of the skill does not remove the means to restore a DACL. + +A repair backs up before mutation and verifies effective access or the resulting ACEs. Failed invocations restore attempted changes in reverse order and remain failures: continuing after a failed grant would remove access without establishing the prerequisite. Inherited package entries are repaired at their explicit sources, preserving inheritance; incomplete or out-of-scope sources prevent mutation. Labels and denies remain untouched because removing them could defeat the backend's confinement or an intentional user policy. + +Both extracted and recovery scripts are user-writable, so this workflow never recommends elevating them. Missing unconfined WRITE_DAC stops the workflow for permission-policy review. Automatic recovery can itself fail and cannot survive process termination; reports retain pending recovery commands rather than promising an atomic transaction. The operator keeps those artifacts until verification of the original confined operation succeeds. + ## How the restriction works (why no new identity) The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so 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. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the capability-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement. 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 fe9aadb97b..4c1e24bb6d 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 @@ -12,6 +12,15 @@ Status: implemented 直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该交集只覆盖对象自身的那次访问检查,而 Windows 也会依据父目录的 `FILE_DELETE_CHILD` 权限批准写入或删除——这项权限不需要任何 restricting SID 副署,因此只要调用者的环境 SID 持有 Modify,`cmd /c del` 就能删除工作区之外的文件。所以令牌还会被降级为 Low 完整性,每个被授权目录都在添加 ACE 的同一次调用中收到 Low 禁止上调强制标签;无论权限来自哪一项,内核都会在访问检查内部执行该策略,于是被授权根目录保持可写,而其他一切不仅防写、也防删。由于每个授权根目录都被标记为 Low,仅靠完整性检查仍会让一个受限子进程删除**另一个授权根目录**(别的工作区、同级的活跃临时目录)内的文件,因此每次授权还会向 world SID 拒绝 `FILE_DELETE_CHILD`:能力 ACE 的 DELETE 位由此成为授权根目录内唯一的删除授权来源——对受限子进程如此,对用户自身进程亦如此,而后者的日常删除仍然可用,因为同一份 DACL 直接向其授予 DELETE。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Everyone 仍留在列表中,但它带来的写权限已被标签层否定;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接仍会使同一对象的外部别名获得授权,而 DACL 中携带其他沙箱 AppContainer 包 SID 的文件对 Low 完整性子进程不可读。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这些缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.zh.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.zh.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/shell/pwsh-sandbox/README.zh.md) 交付。 + +## ACL 诊断与恢复 + +内置诊断技能在受限令牌之外执行保留原有权限的 ACL 修复,因为受限令牌无法检查发生冲突的对象。外部 PowerShell 需要实际脚本文件,因此注册时从 ASAR 或 SEA 提取资源,而不提供归档内路径。fiber 拥有该临时副本。恢复文件保存在调用者指定的持久输出目录中,确保技能释放后仍能恢复 DACL。 + +修复在修改前备份,并验证有效权限或生成的 ACE。失败调用按相反顺序恢复尝试修改的内容,且仍视为失败:授权失败后继续操作,会在前置条件尚未满足时移除访问权限。继承的包条目从显式来源处修复,保持继承不变;来源信息不完整或超出范围时不做修改。标签与拒绝条目保持不变,因为移除它们可能破坏后端隔离或用户有意设置的策略。 + +提取的脚本和恢复脚本均可由用户写入,因此此流程绝不建议提权运行它们。不受限调用者缺少 WRITE_DAC 时,流程停止并交由权限策略审查处理。自动恢复本身也可能失败,且无法跨进程终止继续执行;报告保留待执行的恢复命令,不承诺原子事务。操作者应保留这些文件,直到原始受限操作验证成功。 + ## How the restriction works (why no new identity) 身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。 diff --git a/apps/desktop/tests/acl-skill.built.e2e.ts b/apps/desktop/tests/acl-skill.built.e2e.ts index 8b7b566f6d..b0d84d3a41 100644 --- a/apps/desktop/tests/acl-skill.built.e2e.ts +++ b/apps/desktop/tests/acl-skill.built.e2e.ts @@ -20,6 +20,7 @@ it.skipIf(process.platform !== 'win32' || process.env.DSH_EXAMPLE_MODE !== 'lib' const root = await mkdtemp(join(tmpdir(), 'dsh-acl-asar-')) try { const source = join(root, 'package') + // Electron reads the archived package here; full installer layout and SEA execution have separate coverage needs. await mkdir(source) for (const path of ['lib', 'assets', 'package.json']) await cp(join(packageDirectory, path), join(source, path), { recursive: true }) const archive = join(root, 'app.asar') diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index a20cbf9cde..5c734654ae 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -6,5 +6,5 @@ en: 41d772192075e133 zh: 5ef3da69b4de1d8a /capability-seams-and-core-services: - en: 0d14d210d41c6770 - zh: 964e92962b7cfa88 + en: 10b96841c6b989d3 + zh: c9c56230c90199bf diff --git a/docs/capability-seams.md b/docs/capability-seams.md index 21b4a8205b..99b4417eb7 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -163,6 +163,7 @@ flowchart LR svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] + pkg_sandbox_windows_acl["sandbox-windows-acl"] pkg_skill_badge["skill-badge"] pkg_skill_filesystem["skill-filesystem"] pkg_skill_office["skill-office"] @@ -359,6 +360,7 @@ flowchart LR pkg_sandbox_local --> svc_sandbox pkg_sandbox_policy --> svc_sandboxPolicy pkg_sandbox_ssh --> svc_sandbox + pkg_sandbox_windows_acl --> svc_skills pkg_schedule --> svc_schedule pkg_session --> svc_sessions pkg_session_log_deepseek --> svc_deepseekLlmApiExtensions @@ -624,7 +626,7 @@ flowchart LR | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | Plugins register direct human commands without sending invocations to the model. | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | Domains register state-driven fold units; the eager drive keeps per-session watermark states and the Session controller serves baselines and pushes changed values. | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | Durably checkpoints projection unit states per session (throttled + turn/end/detach mandatory points), serves cached projection views, and accelerates prepared-Session projection hydration. | -| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | +| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies. | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation. | | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | The one concrete loop plugin; extension packages depend on dsh-agent events and services, not on this package. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 996fc1b863..6f54c469be 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -165,6 +165,7 @@ flowchart LR svc_sessionProjectionCache["ctx.sessionProjectionCache
Persisted projection cache"] pkg_skill["skill"] svc_skills["ctx.skills
Skill provider registry"] + pkg_sandbox_windows_acl["sandbox-windows-acl"] pkg_skill_badge["skill-badge"] pkg_skill_filesystem["skill-filesystem"] pkg_skill_office["skill-office"] @@ -361,6 +362,7 @@ flowchart LR pkg_sandbox_local --> svc_sandbox pkg_sandbox_policy --> svc_sandboxPolicy pkg_sandbox_ssh --> svc_sandbox + pkg_sandbox_windows_acl --> svc_skills pkg_schedule --> svc_schedule pkg_session --> svc_sessions pkg_session_log_deepseek --> svc_deepseekLlmApiExtensions @@ -626,7 +628,7 @@ flowchart LR | `ctx.commands` | `core` | [`commands`](../packages/interaction/commands) | - | - | - | 插件注册直接面向人的命令,而不会把调用发送给模型。 | | `ctx.sessionProjections` | `core` | [`session-projection`](../packages/session/session-projection) | - | [`api-session-controller`](../packages/api/session-controller), [`tool-todo`](../packages/todo/tool-todo), [`session-title`](../packages/session/session-title) | - | 各领域注册由状态驱动的折叠单元;主动驱动过程维护每个会话的水位状态,Session controller 提供 baseline 并推送发生变化的值。 | | `ctx.sessionProjectionCache` | `core` | [`session-projection-cache`](../packages/session/session-projection-cache) | - | [`api-session-controller`](../packages/api/session-controller), [`session-query`](../packages/session-query/session-query), [`session-reference`](../packages/context/session-reference) | - | 按会话持久保存投影单元状态的检查点(节流检查点,以及轮次/结束/分离时的必选检查点),提供缓存投影视图,并加速 prepared Session 的投影恢复。 | -| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | +| `ctx.skills` | `seam` | [`skill`](../packages/skill/skill) | [`sandbox-windows-acl`](../packages/sandbox/sandbox-windows-acl), [`skill-badge`](../packages/skill/skill-badge), [`skill-filesystem`](../packages/skill/skill-filesystem), [`skill-office`](../packages/skill/skill-office) | [`tool-skill`](../packages/skill/tool-skill) | - | 合并提供方的 skill(技能)目录;tool-skill 渲染会话前缀目录,并加载完整的 skill 正文。 | | `ctx.agents` | `core` | [`agent`](../packages/core/agent) | - | [`agent-loop`](../packages/core/agent-loop), [`acp`](../packages/acp/acp), [`subagent-in-process-driver`](../packages/subagent/subagent-in-process-driver) | - | 拥有实时 Agent 句柄、创建/恢复工厂 seam,以及进程本地的发起方传播。 | | `ctx.agentDefaultModel` | `core` | [`agent-default-model`](../packages/core/agent-default-model) | - | [`api-session-controller`](../packages/api/session-controller), [`headless`](../packages/bundle/headless) | - | Reads the default ModelSelection from volatile Config and saves selections through the profile editor. | | `ctx.agentLoop` | `bundle` | [`agent-loop`](../packages/core/agent-loop) | - | [`base`](../packages/bundle/base), [`sdk-minimal`](../packages/bundle/sdk-minimal) | - | 唯一的具体循环插件;扩展包依赖 dsh-agent 的事件和服务,而不依赖此包。 | diff --git a/docs/subsystems/skills.i18n.yaml b/docs/subsystems/skills.i18n.yaml index 503112592f..d95e69dd63 100644 --- a/docs/subsystems/skills.i18n.yaml +++ b/docs/subsystems/skills.i18n.yaml @@ -3,8 +3,8 @@ # After editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/skills.md /skills: - en: dfadaef222efffb5 - zh: 309c88debfe92ce6 + en: 047c73a023dcdcd1 + zh: 052f42fbfd202f32 /skills/provider-registry: en: abe338b6c6d1ebd1 zh: c604db2de10a23c4 diff --git a/docs/subsystems/skills.md b/docs/subsystems/skills.md index 7e45418b8b..e330edd2c5 100644 --- a/docs/subsystems/skills.md +++ b/docs/subsystems/skills.md @@ -2,9 +2,9 @@ English | [中文](skills.zh.md) -The [skill capability family](../../packages/skill) includes the Service Definition ([dsh-skill](../../packages/skill/skill), `ctx.skills`), the local Service Provider ([dsh-skill-filesystem](../../packages/skill/skill-filesystem)), optional packaged providers ([dsh-skill-badge](../../packages/skill/skill-badge) and [dsh-skill-office](../../packages/skill/skill-office)), and the Consumer ([dsh-tool-skill](../../packages/skill/tool-skill)). The registry merges provider catalogs across its host and per-scope layers; providers contribute local or packaged skills; the Consumer owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). +The [skill capability family](../../packages/skill) includes the Service Definition ([dsh-skill](../../packages/skill/skill), `ctx.skills`), the local Service Provider ([dsh-skill-filesystem](../../packages/skill/skill-filesystem)), optional packaged providers ([dsh-skill-badge](../../packages/skill/skill-badge), [dsh-skill-office](../../packages/skill/skill-office), and the Windows ACL diagnosis provider in [dsh-sandbox-windows-acl](../../packages/sandbox/sandbox-windows-acl)), and the Consumer ([dsh-tool-skill](../../packages/skill/tool-skill)). The registry merges provider catalogs across its host and per-scope layers; providers contribute local or packaged skills; the Consumer owns the initial and replacement catalogs plus the model-facing `skill` tool. Skills are optional instructions, not session events, so their vocabulary lives here rather than in [core.md](core.md). -Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-filesystem/src/index.ts`](../../packages/skill/skill-filesystem/src/index.ts), [`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts), [`packages/skill/skill-office/src/index.ts`](../../packages/skill/skill-office/src/index.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts). +Source: [`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts), [`packages/skill/skill-filesystem/src/index.ts`](../../packages/skill/skill-filesystem/src/index.ts), [`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts), [`packages/skill/skill-office/src/index.ts`](../../packages/skill/skill-office/src/index.ts), [`packages/sandbox/sandbox-windows-acl/src/acl-skill.ts`](../../packages/sandbox/sandbox-windows-acl/src/acl-skill.ts), and [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts). ## Provider registry diff --git a/docs/subsystems/skills.zh.md b/docs/subsystems/skills.zh.md index ead07c09fe..03a6a06f4e 100644 --- a/docs/subsystems/skills.zh.md +++ b/docs/subsystems/skills.zh.md @@ -2,9 +2,9 @@ [English](skills.md) | 中文 -[skill(技能)能力族](../../packages/skill) 包含 Service Definition([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地 Service Provider([dsh-skill-filesystem](../../packages/skill/skill-filesystem))、可选的随包提供方([dsh-skill-badge](../../packages/skill/skill-badge) 与 [dsh-skill-office](../../packages/skill/skill-office))和 Consumer([dsh-tool-skill](../../packages/skill/tool-skill))。注册表在其宿主层与各 scope 层之间合并各提供方的目录;提供方贡献本地或随包 skill;Consumer 拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.zh.md)。 +[skill(技能)能力族](../../packages/skill) 包含 Service Definition([dsh-skill](../../packages/skill/skill),`ctx.skills`)、本地 Service Provider([dsh-skill-filesystem](../../packages/skill/skill-filesystem))、可选的随包提供方([dsh-skill-badge](../../packages/skill/skill-badge)、[dsh-skill-office](../../packages/skill/skill-office) 及 [dsh-sandbox-windows-acl](../../packages/sandbox/sandbox-windows-acl) 中的 Windows ACL 诊断提供方)和 Consumer([dsh-tool-skill](../../packages/skill/tool-skill))。注册表在其宿主层与各 scope 层之间合并各提供方的目录;提供方贡献本地或随包 skill;Consumer 拥有初始目录和替换目录,以及面向模型的 `skill` 工具。skill 是可选的指令而非会话事件,因此其词汇定义在此处而非 [core.md](core.zh.md)。 -源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-filesystem/src/index.ts`](../../packages/skill/skill-filesystem/src/index.ts)、[`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts)、[`packages/skill/skill-office/src/index.ts`](../../packages/skill/skill-office/src/index.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 +源码:[`packages/skill/skill/src/index.ts`](../../packages/skill/skill/src/index.ts)、[`packages/skill/skill-filesystem/src/index.ts`](../../packages/skill/skill-filesystem/src/index.ts)、[`packages/skill/skill-badge/src/index.ts`](../../packages/skill/skill-badge/src/index.ts)、[`packages/skill/skill-office/src/index.ts`](../../packages/skill/skill-office/src/index.ts)、[`packages/sandbox/sandbox-windows-acl/src/acl-skill.ts`](../../packages/sandbox/sandbox-windows-acl/src/acl-skill.ts) 与 [`packages/skill/tool-skill/src/index.ts`](../../packages/skill/tool-skill/src/index.ts)。 ## 提供方注册表 diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index 7e7b0ba744..ea1d108692 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-local/README.i18n.yaml @@ -36,8 +36,8 @@ en: f0c44248c2ca6acc zh: 554e9f706671d222 /deepseek-ai-dsh-sandbox-local/understand-the-implementation/platform-profiles: - en: e87ee1858b20e643 - zh: fabdc6db8264dc11 + en: eae0f9047f297f42 + zh: ad76290c0e4d7722 /deepseek-ai-dsh-sandbox-local/understand-the-implementation/denial-and-runner-failure-dialects: en: 62adfc9229c656cc zh: 11b1534b6d61e5de diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 278f2e5810..a2e0965af6 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -80,6 +80,8 @@ The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow 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. +With the built-in Windows runner and a skill registry, this provider registers the [ACL diagnosis skill](../sandbox-windows-acl/README.md#failures-and-recovery). An operator-supplied `runnerCommand` does not register it; provider disposal removes the skill and its extracted resources. + 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. ### Denial and runner-failure dialects diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index a7854c30d0..9f868b291e 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -80,6 +80,8 @@ Seatbelt profile 默认允许,带 `(deny file-write*)` 与来自共享 `writab Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,同时为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可撤销 ACE——共享工作区的会话共享其预期写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。该档报告 `partial` 强制执行,因为 NTFS 硬链接会把同一文件对象别名为多个路径、读取仍不受限,且被其他 AppContainer 工具以包 SID 标记过的目录树对 Low 完整性子进程不可读。 +当内置 Windows runner 与技能注册表同时可用时,此提供方会注册 [ACL 诊断技能](../sandbox-windows-acl/README.zh.md#failures-and-recovery)。自定义 `runnerCommand` 不注册该技能;提供方释放时移除技能及其提取的资源。 + 构建后的 ACL runner 缺失时,源码启动将 `tsx/esm/api` 加载器和 TypeScript 路径映射固定到本安装目录。命令的工作目录和环境中的 `TSX_TSCONFIG_PATH` 无法选择 runner 的源码依赖。 ### 拒绝与 runner 失败方言 diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index 07c4a42f38..45028746ff 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -295,11 +295,10 @@ export class LocalSandboxProvider extends SandboxProvider { this.configuredRunnerFailureSignatures = runnerFailureSignatures this.probeTimeoutMs = config.probeTimeoutMs as number assertPositiveFinite('probeTimeoutMs', this.probeTimeoutMs) - // The bundled diagnosis skill explains this backend's ACL failures, so it is - // registered wherever the backend composes. `ctx.inject` keeps `skills` - // optional: the sandbox must not require the skill registry. + // An operator-supplied runner does not use the ACL backend. The registry + // remains optional and may be mounted after this provider. /* v8 ignore next 3 -- Windows-only registration; the Linux coverage lane cannot take this branch */ - if (process.platform === 'win32') { + if (process.platform === 'win32' && this.runnerCommand === undefined) { ctx.inject(['skills'], (skillsCtx) => { registerAclDiagnosisSkill(skillsCtx) }) } // The temp grants are revoked with the provider: a clean server diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index 859fa1d8a5..646377e85c 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -50,6 +50,7 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { } } return { + registerAclDiagnosisSkill: vi.fn(), AclWriteGrant: MockAclWriteGrant, assertTempRootOutsideWorkspace: (workspaceRoot: string, tempRoot: string) => { const workspace = realpathSync.native(workspaceRoot) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 992b5a53cc..5e0e3dbb9f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -25,10 +25,10 @@ zh: 30bc370a5281bf0d /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/what-confinement-gives-you: en: 289e65bddf20dd79 - zh: 44b9fcedf944546a + zh: 7ff72ed64c4b4d1c /deepseek-ai-dsh-sandbox-windows-acl/use-this-package/failures-and-recovery: - en: c3a2ebffb3ca81d1 - zh: 1b9b392fe084aba5 + en: 47c32eff504fcd87 + zh: 83c13c06ccb2e07a /deepseek-ai-dsh-sandbox-windows-acl/understand-the-implementation: en: c2cde86c85c2c619 zh: a29a114e591f9d3f @@ -57,8 +57,8 @@ en: 817eb1b7de13872e zh: 06fe175e41e7459b /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work: - en: 2311808c0ded8564 - zh: 845ddb2408c861fd + en: b390e8c1cc9a85fa + zh: 67960a00330aee2e /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note: en: cc273320f6a1beaf zh: 5c57a312ddc50651 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index e7bff61376..b9523659ce 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -75,11 +75,11 @@ Temp isolation is per live session/workspace pair: sessions sharing a workspace `init()` throws on any Win32 failure — the child is never spawned unrestricted. A runner that fails before executing the command prints `windows-acl-run: ` to stderr and exits 127, which the seam's runner-failure rules classify as a broken sandbox rather than a denial. Cleanup is best-effort by design: `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`. -A denial this backend cannot explain — the child cannot open an object it was granted, or the provisioning call itself fails — is diagnosed by the `diagnose-windows-sandbox-acl` skill bundled in `assets/`. `registerAclDiagnosisSkill` contributes it wherever `dsh-sandbox-local` composes with a skill registry on Windows. Registration copies the resources into a private temporary directory readable by external PowerShell, including in ASAR and SEA deployments; fiber disposal unregisters the provider and removes its copy. Missing packaged assets fail registration. +A denial this backend cannot explain is diagnosed by the `diagnose-windows-sandbox-acl` skill bundled in `assets/`. `registerAclDiagnosisSkill` contributes it when `dsh-sandbox-local` uses its built-in Windows runner and a skill registry is available. Registration extracts resources for external PowerShell, including in ASAR and SEA deployments; fiber disposal unregisters the provider and removes its copy. Missing assets fail registration. -The script diagnoses without changing ACLs or existing file contents; effective `WRITE_DAC` and `WRITE_OWNER` checks open existing objects without modifying contents. `-Fix` removes package allow ACEs only when the requested path has both rights; `-GrantFullControl` adds the caller's missing rights while preserving explicit denies. The modes run separately, require an unconfined caller and `WRITE_DAC`, enforce a containing `-AllowRoot`, reject every reparse point in the target's ancestor chain, preserve ownership and save recovery files under `-Out` before mutation. Failed repairs automatically restore attempted DACL changes from that invocation in reverse order and exit nonzero, even when restoration succeeds. The emitted standalone `-Restore` commands preserve owner and SACL and enforce the same path restrictions. Restore earlier successful invocations in reverse order if a later step fails. +Diagnosis leaves ACLs and existing content unchanged. `-Fix` removes explicit package allow ACEs ancestor first and verifies their inherited copies disappeared; the requested path needs effective `WRITE_DAC` and `WRITE_OWNER`. `-GrantFullControl` adds the caller's missing rights. Repairs require `WRITE_DAC`, preserve denies, owner, inheritance and SACL, and back up each changed DACL. Targets must be strictly inside `-AllowRoot`; reparse paths and managed application trees are refused. Failed repairs restore attempted changes in reverse order and exit nonzero. Follow the [bundled skill](assets/diagnose-windows-sandbox-acl/SKILL.md) for repair selection, original-operation verification and recovery. -Every run emits `REPORT` JSON records describing observations, decisions, operation attempts and results with their reasons, followed by verification and summary records. `-Compact -Out ` saves all records in a unique JSONL file and prints a summary containing its path, every inspected path, findings, ACL actions, verification, rollback status, pending recovery commands and `nextAction`. ACL observations include allow and deny ACEs with SID, rights and inheritance, native listings, effective access and read errors; unknown values remain distinct from absent permissions. Mutations report their start before executing, so a caught exception retains prior results and recovery commands. A completed write does not imply verified repair or recovery. The [bundled skill](assets/diagnose-windows-sandbox-acl/SKILL.md) requires stopping after failure and verifying the original operation in its confined context after successful repair. +Every run emits `REPORT` JSON records of observations, actions, reasons and verification; unknown observations remain unknown. `-Compact -Out ` persists the full report and prints a summary with all inspected paths, findings, actions, recovery commands and `nextAction`. Action completion records API execution; verification records the observed result. The [recovery decisions](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md#acl-diagnosis-and-recovery) explain resource ownership and rollback limits. Inspection visits the requested object and each ancestor through the filesystem root. Each ACL observation's `path` identifies the inspected object; the classification's `details.packageObjects` lists objects carrying package allow ACEs without establishing the cause of the original failure. The [parent-package example](tests/expected/parent-package-report.jsonl) retains the fixture paths and shows selected fields, with `fixturePackageAces` projecting only the synthetic test SID from `aces`; host-owned ACEs and ancestors are omitted. The [failed-grant example](tests/expected/denied-grant-report.txt) retains operation paths and reasons. @@ -172,6 +172,8 @@ One extra catalog entry on Windows: the skill's description enters context with ## Known Limitations and Deferred Work +- **Diagnosis preserves integrity labels** — it cannot fix Low-labeled executables affecting programs launched outside DSH. It also cannot repair a caller's missing `WRITE_DAC`; extracted and recovery scripts are user-writable and are not supported elevated entrypoints. Resource directories can remain after an unclean shutdown. + diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index e2687a4f77..c08ac4217f 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -71,15 +71,17 @@ rmSync(tempDir, { recursive: true, force: true }) 临时隔离按每个活跃的会话/工作区对进行:共享工作区的会话共享其写权限,但无法写入彼此的临时目录。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权。 + + ### 失败与恢复 `init()` 在任何 Win32 失败时抛出——子进程绝不会不受限制地 spawn。执行命令前失败的 runner 会向 stderr 打印 `windows-acl-run: ` 并以 127 退出,seam 的 runner 失败规则将其归类为损坏的沙箱,而非拒绝。清理按设计尽力而为:`dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`。 -此后端无法解释的拒绝——子进程打不开本已授权的对象,或授权调用本身失败——交给 `assets/` 中随包发布的 `diagnose-windows-sandbox-acl` 技能诊断。只要 `dsh-sandbox-local` 在 Windows 上与技能注册表一起组合,`registerAclDiagnosisSkill` 就会注册它。注册时会把资源复制到外部 PowerShell 可读取的私有临时目录,ASAR 和 SEA 部署也适用;fiber 释放时注销提供者并删除副本。缺少打包资源会导致注册失败。 +此后端无法解释的拒绝交给 `assets/` 中随包发布的 `diagnose-windows-sandbox-acl` 技能诊断。当 `dsh-sandbox-local` 使用内置 Windows runner 且技能注册表可用时,`registerAclDiagnosisSkill` 会注册它。注册时提取供外部 PowerShell 使用的资源,ASAR 和 SEA 部署也适用;fiber 释放时注销提供者并删除副本。缺少资源会导致注册失败。 -脚本诊断时不修改 ACL 或已有文件内容;有效 `WRITE_DAC` 和 `WRITE_OWNER` 检查只打开已有对象,不修改内容。`-Fix` 仅在请求路径具有这两项权限时删除包 SID 的允许 ACE;`-GrantFullControl` 补充调用者缺少的权限,同时保留显式拒绝条目。两种模式分别执行,要求不受限的调用者及 `WRITE_DAC`,强制目标位于 `-AllowRoot` 内,拒绝目标祖先链上的所有重解析点,保持所有者不变,并在修改前将恢复文件保存到 `-Out`。修复失败时,脚本按相反顺序自动恢复本次调用中尝试修改的 DACL,即使恢复成功也以非零状态退出。输出的独立 `-Restore` 命令保留所有者和 SACL,并执行相同的路径限制。如果后续步骤失败,还应按相反顺序恢复此前成功调用的修改。 +诊断不修改 ACL 或已有内容。`-Fix` 从祖先开始移除显式包允许 ACE,并验证其继承副本已消失;请求路径需要有效的 `WRITE_DAC` 和 `WRITE_OWNER`。`-GrantFullControl` 补充调用者缺少的权限。修复要求 `WRITE_DAC`,保留拒绝条目、所有者、继承和 SACL,并备份每个修改的 DACL。目标必须严格位于 `-AllowRoot` 内;重解析路径和受管理的应用目录会被拒绝。修复失败时按相反顺序恢复尝试修改的内容,并以非零状态退出。修复选择、原始操作验证及恢复流程见[内置技能](assets/diagnose-windows-sandbox-acl/SKILL.md)。 -每次执行都会输出 `REPORT` JSON 记录,说明观察、决策、操作尝试及结果和各自原因,并记录验证结果与最终摘要。`-Compact -Out ` 将全部记录保存到独立 JSONL 文件,仅打印包含报告路径、全部分析路径、发现、ACL 操作、验证结果、回滚状态、待执行恢复命令和 `nextAction` 的摘要。ACL 观察包含允许和拒绝 ACE 的 SID、权限与继承信息、原生列表、有效权限及读取错误;未知值与权限缺失明确区分。修改在执行前报告开始状态,捕获异常后仍保留此前结果和恢复命令。写入完成不代表修复或恢复已通过验证。[内置技能](assets/diagnose-windows-sandbox-acl/SKILL.md) 要求失败后停止,修复成功后在原受限环境中验证最初失败的操作。 +每次执行都会输出包含观察、操作、原因和验证结果的 `REPORT` JSON 记录;未知观察保持未知。`-Compact -Out ` 保存完整报告,打印包含全部分析路径、发现、操作、恢复命令和 `nextAction` 的摘要。操作完成记录 API 执行情况,验证记录实际观察到的结果。[恢复决策](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md#acl-diagnosis-and-recovery) 说明资源归属和回滚限制。 检查会从请求对象逐级访问祖先,直到文件系统根目录。每条 ACL 观察的 `path` 标识实际检查的对象;分类结果的 `details.packageObjects` 列出包含包允许 ACE 的对象,但不能据此确认原始失败的原因。[父目录包 SID 示例](tests/expected/parent-package-report.jsonl) 保留测试目录路径并展示选定字段,其中 `fixturePackageAces` 只从 `aces` 提取合成测试 SID,省略机器自带的 ACE 和祖先目录。[授权失败示例](tests/expected/denied-grant-report.txt) 保留操作路径和原因。 @@ -172,6 +174,8 @@ Windows 上多一个目录条目:技能描述随目录进入上下文,正文 ## 已知限制与延期工作 +- **诊断保留完整性标签**——它无法修复 Low 标签的可执行文件影响用户在 DSH 外启动程序的问题,也无法修复调用者缺少 `WRITE_DAC` 的情况;提取的脚本和恢复脚本均可由用户写入,不支持作为提权入口。非正常退出后可能残留资源目录。 + diff --git a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md index db3b0701f2..e920ab0f72 100644 --- a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md +++ b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md @@ -35,9 +35,9 @@ Each full record has `kind`, `operation`, `path`, `status`, `reason`, `details`. ## Run the selected repair -Explain the finding and cost first, and briefly apologize for the harness's unhelpful denial after confirming the conflict. `-Fix` removes individual `S-1-15-2-*` allow ACEs and their access; well-known groups ending in 1 or 2 are preserved. `-GrantFullControl` adds a current-user allow ACE; it cannot cancel an explicit deny. Both preserve owner and SACL. Full control supplies `WRITE_DAC` and `WRITE_OWNER`; taking ownership alone does not. +Explain the finding and cost first. `-Fix` removes individual `S-1-15-2-*` allow ACEs at their explicit sources, ancestor first, then verifies inherited entries disappeared; well-known groups ending in 1 or 2 are preserved. This removes those packages' access. `-GrantFullControl` adds a current-user allow ACE; it cannot cancel an explicit deny. Both preserve owner, inheritance and SACL, including Low integrity labels. Low executable labels and their effects outside DSH are outside this repair's scope. Full control supplies `WRITE_DAC` and `WRITE_OWNER`; taking ownership alone does not. -Append `-AllowRoot ''` and **exactly one** of `-Fix` or `-GrantFullControl` to the diagnostic command, before `exit`. Keep `-Compact -Out`. Targets must be strictly inside that root; reparse paths are refused. Never widen the authorized root to reach an ancestor. Missing effective `WRITE_DAC` in the unconfined caller requires handing the user an exact command for their elevated terminal, not another sandbox escalation. Never initiate UAC/`runas` or run the whole agent elevated. +Append `-AllowRoot ''` and **exactly one** of `-Fix` or `-GrantFullControl` to the diagnostic command, before `exit`. Keep `-Compact -Out`. Every explicit source must be strictly inside that root; reparse paths and managed application trees (`LocalAppData\Packages`, `ProgramFiles\WindowsApps`) are refused. Never widen the authorized root to reach an ancestor. Missing effective `WRITE_DAC` in the unconfined caller requires stopping and reporting the path and missing rights for permission-policy review. Extracted scripts and recovery copies are user-writable: never supply commands to run them elevated, initiate UAC/`runas`, or run the whole agent elevated. Follow `nextAction`: `stop` ends repairs; `restore_pending_then_stop` requires the reported recovery commands in their listed order, then stop. Verified rollback does not turn a failed repair into success. Keep full report/backup paths and exact recovery commands for every invocation; never abbreviate executable paths. On recovery failure, stop and report the remaining commands without more repairs. diff --git a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 index 3173bab5ab..3106e444fa 100644 --- a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 +++ b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1 @@ -147,7 +147,7 @@ function Get-CurrentIdentity { return [System.Security.Principal.WindowsIdentity]::GetCurrent() } -# Integrity level and sandbox markers decide the symptom shape: the same foreign +# Integrity level affects the symptom: the same foreign # ACE fails a grant when the caller is below Medium and merely blocks the child # when the caller is not. # The integrity level lives in the token's group list, which .NET filters out of @@ -230,29 +230,37 @@ function Get-IntegritySid { } } -function Get-CallerFacts { - $sid = Get-IntegritySid - $markers = @() - foreach ($name in (Get-ChildItem env: | Select-Object -ExpandProperty Name)) { - if ($name -like 'SBX_*' -or $name -like 'CODEX_*') { $markers += $name } - } - return @{ Integrity = $sid; Markers = $markers } +function Get-NormalizedPath { + param([string]$Value) + $full = [System.IO.Path]::GetFullPath($Value) + $root = [System.IO.Path]::GetPathRoot($full) + if ($full.Length -le $root.Length) { return $root } + return $full.TrimEnd('\', '/') } function Test-DangerousRoot { param([string]$FullPath) - $trimmed = $FullPath.TrimEnd('\') + $trimmed = (Get-NormalizedPath $FullPath).TrimEnd('\') if ($trimmed -match '^[A-Za-z]:$') { return 'drive root' } if ($trimmed -ieq ([System.IO.Path]::GetFullPath($env:USERPROFILE).TrimEnd('\'))) { return 'user profile root' } if ($trimmed -ieq ([System.IO.Path]::GetFullPath($env:WINDIR).TrimEnd('\'))) { return 'Windows directory' } + foreach ($entry in @( + @{ directory = $env:LOCALAPPDATA; child = 'Packages' }, + @{ directory = $env:ProgramFiles; child = 'WindowsApps' }, + @{ directory = $env:ProgramW6432; child = 'WindowsApps' } + )) { + if (-not $entry.directory) { continue } + $protected = Get-NormalizedPath (Join-Path $entry.directory $entry.child) + if ($trimmed -ieq $protected -or (Test-UnderRoot $trimmed $protected)) { return 'managed application directory' } + } return $null } function Test-UnderRoot { param([string]$FullPath, [string]$Root) - $r = [System.IO.Path]::GetFullPath($Root).TrimEnd('\') - $c = [System.IO.Path]::GetFullPath($FullPath) - return $c.StartsWith($r + '\', [System.StringComparison]::OrdinalIgnoreCase) + $r = Get-NormalizedPath $Root + $c = Get-NormalizedPath $FullPath + return $c -ine $r -and $c.StartsWith($r.TrimEnd('\') + '\', [System.StringComparison]::OrdinalIgnoreCase) } function Get-RepairRefusal { @@ -429,7 +437,7 @@ function Get-ObjectFacts { function Get-Ancestors { param([string]$FullPath) $list = @() - $current = [System.IO.Path]::GetFullPath($FullPath) + $current = Get-NormalizedPath $FullPath while ($true) { $list += $current $parent = [System.IO.Path]::GetDirectoryName($current) @@ -477,9 +485,9 @@ try { $identity = Get-CurrentIdentity $meSid = $identity.User.Value Invoke-ReportedOperation initialize '' 'Load read-only access checks and DACL-only writes before inspecting permissions.' none { Initialize-NativeApi } - $caller = Get-CallerFacts - Write-Line ('CALLER SID={0} INTEGRITY={1} SANDBOX_MARKERS={2}' -f $meSid, $caller.Integrity, (($caller.Markers | Sort-Object) -join ',')) - Write-Report observation caller '' read 'The caller token determines effective access; sandbox marker names do not prove token permissions.' @{ sid = $meSid; integrity = $caller.Integrity; sandboxMarkers = $caller.Markers } + $integrity = Get-IntegritySid + Write-Line ('CALLER SID={0} INTEGRITY={1}' -f $meSid, $integrity) + Write-Report observation caller '' read 'The caller token determines effective access; unconfined execution does not imply elevation.' @{ sid = $meSid; integrity = $integrity } if (@(@($Fix.IsPresent, $GrantFullControl.IsPresent, [bool]$Restore) | Where-Object { $_ }).Count -gt 1) { throw [System.ArgumentException]::new('-Fix, -GrantFullControl and -Restore are separate operations; run one at a time') @@ -496,7 +504,7 @@ try { :paths foreach ($requested in $Path) { $currentPath = $requested - $full = [System.IO.Path]::GetFullPath($requested) + $full = Get-NormalizedPath $requested $currentPath = $full Write-Line ('PATH={0}' -f $full) if (-not (Test-Path -LiteralPath $full)) { @@ -569,7 +577,7 @@ try { $refusal = Get-RepairRefusal -FullPath $full -Root $AllowRoot if ($refusal) { Write-Line ('GRANT_REFUSED {0}' -f $refusal); Write-Decision grant $full refused $refusal; $refused++; break paths } if ($targetFacts.HasWriteDac -and $targetFacts.HasWriteOwner) { Write-Line ('GRANT_SKIPPED {0} already carries WRITE_DAC and WRITE_OWNER' -f $full); Write-Decision grant $full skipped 'Effective WRITE_DAC and WRITE_OWNER are already available; no grant or backup is needed.'; continue } - if (-not $targetFacts.HasWriteDac) { Write-Line 'GRANT_REFUSED the caller lacks WRITE_DAC; ask the user to run this command from an elevated terminal'; Write-Decision grant $full refused 'Effective WRITE_DAC is absent; this caller cannot change the DACL. Elevation may still be blocked by a deny ACE.'; $refused++; break paths } + if (-not $targetFacts.HasWriteDac) { Write-Line 'GRANT_REFUSED the caller lacks WRITE_DAC; stop for permission-policy review'; Write-Decision grant $full refused 'Effective WRITE_DAC is absent; this caller cannot change the DACL. Stop for permission-policy review; do not elevate a writable script copy.'; $refused++; break paths } Save-AclBackup -FullPath $full -Directory $Out -Root $AllowRoot # AddAccessRule preserves explicit denies; icacls /grant can remove a deny # for the same principal. Write only the DACL, leaving owner and SACL intact. @@ -607,27 +615,33 @@ try { break paths } if ($packageTargets.Count -eq 0) { Write-Decision fix $full skipped 'No package allow ACE was observed on the inspected objects; no ACL change or backup is needed.' } - foreach ($target in $packageTargets) { + $sources = @($packageTargets | Where-Object { + @($_.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited }).Count -gt 0 + }) + [array]::Reverse($sources) + foreach ($target in (@($sources) + @($packageTargets))) { $refusal = Get-RepairRefusal -FullPath $target.Object -Root $AllowRoot if ($refusal) { Write-Line ('FIX_REFUSED {0}' -f $refusal); Write-Decision fix $target.Object refused $refusal; $refused++; break paths } + if ($target.Errors.Count -gt 0) { Write-Decision fix $target.Object refused 'The ACL observations are incomplete; collateral changes could not be verified.'; $refused++; break paths } } if ($packageTargets.Count -gt 0 -and $needsPrecondition) { Write-Decision fix $full refused 'Package removal requires verified WRITE_DAC and WRITE_OWNER on the requested path. A failed grant must not be followed by -Fix.' $refused++; break paths } - foreach ($target in $packageTargets) { - $refusal = Get-RepairRefusal -FullPath $target.Object -Root $AllowRoot - if ($refusal) { Write-Line ('FIX_REFUSED {0}' -f $refusal); Write-Decision fix $target.Object refused $refusal; $refused++; break paths } - if ($target.Errors.Count -gt 0) { Write-Decision fix $target.Object refused 'The ACL observations are incomplete; collateral changes could not be verified.'; $refused++; break paths } + foreach ($target in $sources) { if (-not $target.HasWriteDac) { - Write-Line ('FIX_REFUSED {0} needs WRITE_DAC; ask the user to run the full-control grant from an elevated terminal first' -f $target.Object) + Write-Line ('FIX_REFUSED {0} needs WRITE_DAC; stop for permission-policy review' -f $target.Object) Write-Decision fix $target.Object refused 'Effective WRITE_DAC is absent; removing a package allow ACE requires that right.' $refused++ break paths } - $before = $target.AclLines + } + Write-Report decision fix_sources $full selected 'Remove explicit package allow ACEs from their sources, ancestor first. Inherited entries are verified after source changes; inheritance stays enabled.' @{ + sources = @($sources | ForEach-Object { $_.Object }); affectedPaths = @($packageTargets | ForEach-Object { $_.Object }) + } + foreach ($target in $sources) { Save-AclBackup -FullPath $target.Object -Directory $Out -Root $AllowRoot - $sids = @($target.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID } | ForEach-Object { $_.sid } | Sort-Object -Unique) + $sids = @($target.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited } | ForEach-Object { $_.sid } | Sort-Object -Unique) foreach ($sid in $sids) { $repairPath = $target.Object Invoke-ReportedOperation remove_package_allow $repairPath "Remove the observed package allow ACE for $sid as requested by -Fix; preserve deny ACEs and all other principals." acl { @@ -635,6 +649,9 @@ try { if ($LASTEXITCODE -ne 0) { throw "icacls removal exit ${LASTEXITCODE}: $($output -join "`n")" } } } + } + foreach ($target in $packageTargets) { + $before = $target.AclLines $after = Get-ObjectFacts -FullPath $target.Object -MeSid $meSid # icacls prefixes only the first ACE with the path, so compare entries # without that prefix when checking for collateral changes. @@ -655,10 +672,11 @@ try { (($expectedAces | ConvertTo-Json -Compress) -eq ($after.Aces | ConvertTo-Json -Compress)) $verified = $after.Readable -and $after.Errors.Count -eq 0 -and $after.PackageAces.Count -eq 0 -and $collateral Write-Report verification fix $target.Object $(if ($verified) { 'verified' } else { 'failed' }) 'The removal command completed; verify that package allow ACEs disappeared and other ACL listing entries remained unchanged. On failure, restore this invocation and stop.' @{ - removedSids = $sids; remainingPackageAces = $after.PackageAces; otherEntriesUnchanged = $collateral; recovery = $recoveries[-1].command + remainingPackageAces = $after.PackageAces; otherEntriesUnchanged = $collateral } if ($verified) { - foreach ($sid in $sids) { Write-Line ('FIXED {0} SID={1}' -f $target.Object, $sid); $fixed++ } + $explicitSids = @($target.Aces | Where-Object { $_.type -eq 'Allow' -and $_.sid -match $PACKAGE_SID -and -not $_.inherited } | ForEach-Object { $_.sid } | Sort-Object -Unique) + foreach ($sid in $explicitSids) { Write-Line ('FIXED {0} SID={1}' -f $target.Object, $sid); $fixed++ } } else { Write-Line ('FIX_FAILED {0} collateral_change={1}' -f $target.Object, (-not $collateral)) $refused++ diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 3dee1e7897..63690ec6b6 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -45,6 +45,8 @@ "yaml": "^2.4.2" }, "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:~", + "@deepseek-ai/cordis-plugin-include": "workspace:~", "@deepseek-ai/dsh-pwsh-local": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/cordis": "workspace:~", diff --git a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts index f6b7593826..c057289f7a 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/diagnose-script.spec.ts @@ -34,6 +34,8 @@ function pwshAvailable(): boolean { const script = fileURLToPath(new URL('../assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1', import.meta.url)) const PACKAGE_SID = 'S-1-15-2-1-2-3-4' const CAPABILITY_SID = 'S-1-15-3-1-2-3-4' +// Process creation and Add-Type compilation share the Windows coverage budget. +const timeout = Math.max(90_000, Number(process.env.DSH_COVERAGE_TEST_TIMEOUT_MS ?? 0)) // Vitest's asymmetric factories return any; expected matchers are opaque values. const containingObject = (value: Record): unknown => expect.objectContaining(value) @@ -66,7 +68,7 @@ function reports(run: ScriptRun): ScriptReport[] { } function pwsh(command: string): string { - return execFileSync('pwsh', ['/NoLogo', '/NonInteractive', '/NoProfile', '-Command', command], { encoding: 'utf8' }) + return execFileSync('pwsh', ['/NoLogo', '/NonInteractive', '/NoProfile', '-Command', command], { encoding: 'utf8', timeout, windowsHide: true }) } function runScript(args: readonly string[]): ScriptRun { @@ -78,16 +80,19 @@ function runPowerShell(args: readonly string[]): ScriptRun { const stdout = execFileSync('pwsh', ['/NoLogo', '/NonInteractive', '/NoProfile', ...args], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], + timeout, + windowsHide: true, }) return { code: 0, output: stdout } } catch (error) { const failure = error as { status?: number | null; stdout?: string; stderr?: string } + if (failure.status === null || failure.status === undefined) throw error return { code: failure.status ?? -1, output: `${failure.stdout ?? ''}${failure.stderr ?? ''}` } } } function icacls(path: string, ...args: readonly string[]): string { - return execFileSync('icacls', [path, ...args], { encoding: 'utf8', windowsHide: true }) + return execFileSync('icacls', [path, ...args], { encoding: 'utf8', timeout, windowsHide: true }) } function sddlOf(path: string): string { @@ -109,7 +114,7 @@ function normalized(path: string, lines: readonly string[]): string[] { }) } -describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl script', () => { +describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl script', { timeout }, () => { let scratch!: string let outDir!: string let meSid!: string @@ -129,7 +134,12 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri scratch = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-acl-diagnose-'))) outDir = makeDir('out') meSid = pwsh('[System.Security.Principal.WindowsIdentity]::GetCurrent().User.Value').trim() - }) + // Diagnosis inspects the real ancestor chain, including above our owned root. + const baseline = reports(runScript(['-Path', scratch])).find(entry => entry.operation === 'classify') + if (JSON.stringify(baseline?.details.packageObjects) !== '[]') { + throw new Error('ACL fixtures require TEMP/TMP on a tree whose ancestors have no individual package allow ACEs; choose a clean test temp root. Host ACLs are never repaired by this suite.') + } + }, timeout) afterAll(() => { // Restore what the grant case withheld before deleting the scratch tree. @@ -139,7 +149,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri `icacls '${scratch}' /grant:r "*${meSid}:(F)" | Out-Null`, ) rmSync(scratch, { recursive: true, force: true }) - }) + }, timeout) it('names a foreign package-SID ACE as the blocker and removes only that ACE', () => { const target = makeDir('stamped') @@ -162,7 +172,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri // Exactly the foreign ACE disappears; every other line survives unchanged. expect(normalized(target, aclLines(target))).toEqual(before.filter(line => !line.includes(PACKAGE_SID))) - }, 60_000) + }, timeout) it('reports a healthy directory as not this class and changes nothing', () => { const target = makeDir('healthy') @@ -177,7 +187,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri expect(repair.output, repair.output).toContain('SUMMARY FIXED=0 GRANTED=0 REFUSED=0') expect(reports(repair)).toContainEqual(containingObject({ kind: 'decision', operation: 'fix', path: target, status: 'skipped' })) expect(normalized(target, aclLines(target))).toEqual(before) - }, 60_000) + }, timeout) it('reports every inspected ancestor and locates a package ACE present only on the parent', async () => { const parent = makeDir('parent-package-report') @@ -227,7 +237,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri return JSON.stringify({ ...entry, details }) }).join('\n').replaceAll(JSON.stringify(parent).slice(1, -1), '{{parent}}') + '\n' await expect(transcript).toMatchFileSnapshot(fileURLToPath(new URL('./expected/parent-package-report.jsonl', import.meta.url))) - }, 60_000) + }, timeout) it('reports an inert capability SID and an inert DENY ACE without touching either', () => { const capability = makeDir('capability') @@ -258,7 +268,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri expect(repair.output, repair.output).toContain('SUMMARY FIXED=0 GRANTED=0 REFUSED=0') expect(normalized(path, aclLines(path))).toEqual(before.get(path)) } - }, 90_000) + }, timeout) it('grants full control for a Modify-only DACL without changing the owner', () => { const target = makeDir('missing-write-owner') @@ -272,7 +282,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri expect(repair.output, repair.output).toContain('SUMMARY FIXED=0 GRANTED=1 REFUSED=0') expect(aclLines(target).join('\n')).toMatch(/\(F\)/u) expect(ownerOf(target)).toBe(ownerBefore) - }, 60_000) + }, timeout) it('refuses a target outside -AllowRoot and refuses contradictory switches', () => { const target = makeDir('outside-root') @@ -289,7 +299,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri expect(contradictory.code).toBe(2) expect(contradictory.output, contradictory.output).toContain('run one at a time') expect(reports(contradictory)).toContainEqual(containingObject({ kind: 'error', status: 'stopped' })) - }, 60_000) + }, timeout) it.each(['-Fix', '-GrantFullControl'])('refuses ancestor junctions for %s without changing their destination', (repairSwitch) => { const allowed = makeDir(`junction-${repairSwitch}`) @@ -331,7 +341,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri expect(reports({ code: 0, output: restored })).toContainEqual(containingObject({ kind: 'verification', operation: 'restore', status: 'verified' })) expect(sddlOf(target)).toBe(before) expect(normalized(target, aclLines(target)).sort()).toEqual(linesBefore) - }, 60_000) + }, timeout) it('repairs a directory whose full-control ACE only applies to children', () => { const target = makeDir('inherit-only-full-control') @@ -372,7 +382,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('diagnose-windows-sandbox-acl scri } finally { unlinkSync(link) } - }, 60_000) + }, timeout) it('does not report a successful grant when a deny ACE blocks WRITE_OWNER', async () => { const target = makeDir('denied-write-owner') @@ -484,7 +494,7 @@ exit $LASTEXITCODE expect(recovery.code, recovery.output).toBe(0) expect(reports(recovery)).toContainEqual(containingObject({ kind: 'verification', operation: 'restore', status: 'verified' })) } - }, 60_000) + }, timeout) it('reports a missing path and an already-satisfied grant without mutating either', () => { const missing = join(scratch, 'missing') @@ -547,7 +557,7 @@ exit $LASTEXITCODE } finally { icacls(target, '/remove:d', `*${meSid}`) } - }, 60_000) + }, timeout) it('removes multiple package allow SIDs before verifying the resulting ACL', () => { const target = makeDir('multiple-packages') @@ -561,6 +571,123 @@ exit $LASTEXITCODE expect(reports(run).filter(entry => entry.kind === 'verification' && entry.operation === 'fix')).toHaveLength(1) }) + it.each([false, true])('repairs inherited package entries at their sources and restores the tree (explicit child: %s)', (explicitChild) => { + const parent = makeDir(`inherited-${explicitChild}`) + const child = join(parent, 'child') + const leaf = join(child, 'leaf') + mkdirSync(leaf, { recursive: true }) + icacls(parent, '/setintegritylevel', '(OI)(CI)L') + icacls(parent, '/grant', `*${PACKAGE_SID}:(OI)(CI)(RX)`) + if (explicitChild) icacls(child, '/grant', '*S-1-15-2-4-3-2-1:(OI)(CI)(RX)') + const paths = [parent, child, leaf] + const before = paths.map(sddlOf) + const run = runScript(['-Path', leaf, '-AllowRoot', scratch, '-Out', outDir, '-Fix']) + expect(run.code, run.output).toBe(0) + const entries = reports(run) + expect(entries.filter(entry => entry.operation === 'remove_package_allow' && entry.status === 'completed').map(entry => entry.path)) + .toEqual(explicitChild ? [parent, child] : [parent]) + expect(entries.filter(entry => entry.kind === 'verification' && entry.operation === 'fix').map(entry => entry.path)).toEqual([leaf, child, parent]) + for (const path of paths) { + expect(aclLines(path).join('\n')).not.toContain('S-1-15-2-') + expect(ownerOf(path)).toBe(meSid) + } + const commands = entries.at(-1)!.details.rollbackCommands as string[] + for (const command of commands) expect(runPowerShell(['-Command', command]).code).toBe(0) + expect(paths.map(sddlOf)).toEqual(before) + }) + + it('refuses an inherited source outside AllowRoot before touching an in-root explicit entry', () => { + const parent = makeDir('outside-inherited-source') + const root = join(parent, 'allowed') + const child = join(root, 'child') + mkdirSync(child, { recursive: true }) + icacls(parent, '/grant', `*${PACKAGE_SID}:(OI)(CI)(RX)`) + stamp(child, 'S-1-15-2-4-3-2-1') + const before = [parent, child].map(sddlOf) + const run = runScript(['-Path', child, '-AllowRoot', root, '-Out', outDir, '-Fix']) + expect(run.code).toBe(2) + expect(reports(run)).toContainEqual(containingObject({ kind: 'decision', status: 'refused', path: parent, reason: containingString('outside -AllowRoot') })) + expect(reports(run).filter(entry => entry.kind === 'action' && entry.details.effect === 'acl')).toEqual([]) + expect([parent, child].map(sddlOf)).toEqual(before) + }) + + it('restores explicit sources and inherited entries when final verification fails', () => { + const parent = makeDir('inherited-rollback') + const child = join(parent, 'child') + const leaf = join(child, 'leaf') + mkdirSync(leaf, { recursive: true }) + icacls(parent, '/grant', `*${PACKAGE_SID}:(OI)(CI)(RX)`) + icacls(child, '/grant', '*S-1-15-2-4-3-2-1:(OI)(CI)(RX)') + const paths = [parent, child, leaf] + const before = paths.map(sddlOf) + const quote = (value: string): string => `'${value.replaceAll("'", "''")}'` + const wrapper = join(scratch, 'fail-inherited-verification.ps1') + writeFileSync(wrapper, ` +$global:leafReads = 0 +function Get-Acl { + param([string]$LiteralPath) + if ($LiteralPath -eq ${quote(leaf)}) { + $global:leafReads++ + if ($global:leafReads -eq 2) { throw [System.IO.IOException]::new('final inherited verification unavailable') } + } + Microsoft.PowerShell.Security\\Get-Acl -LiteralPath $LiteralPath +} +& ${quote(script)} -Path ${quote(leaf)} -AllowRoot ${quote(scratch)} -Out ${quote(outDir)} -Fix +exit $LASTEXITCODE +`) + const run = runPowerShell(['-File', wrapper]) + expect(run.code, run.output).toBe(2) + expect(reports(run).filter(entry => entry.kind === 'verification' && entry.operation === 'restore').map(entry => entry.path)).toEqual([child, parent]) + expect(reports(run).at(-1)).toMatchObject({ details: { rollback: 'verified', nextAction: 'stop' } }) + expect(paths.map(sddlOf)).toEqual(before) + }) + + it.each(['-Fix', '-GrantFullControl', '-Restore'])('rejects equivalent spellings of AllowRoot through %s', (mode) => { + const target = makeDir(`root-spellings-${mode}`) + const backups = makeDir(`root-backups-${mode}`) + icacls(target, '/inheritance:r', '/grant:r', `*${meSid}:(M)`) + let record: string | undefined + if (mode === '-Restore') { + const grant = runScript(['-Path', target, '-AllowRoot', scratch, '-Out', backups, '-GrantFullControl']) + expect(grant.code, grant.output).toBe(0) + record = (reports(grant).at(-1)!.details.recoveries as { record: string }[])[0]!.record + } else if (mode === '-Fix') stamp(target, PACKAGE_SID) + const before = sddlOf(target) + for (const path of [target, `${target}\\`, `${target}/`, `${target}\\\\`, `${target}\\.`, target.toUpperCase()]) { + const args = ['-Path', path, '-AllowRoot', target, '-Out', backups, mode, ...(record ? [record] : [])] + const run = runScript(args) + expect(run.code, run.output).not.toBe(0) + expect(run.output).toContain('outside -AllowRoot') + expect(sddlOf(target)).toBe(before) + } + }) + + it.each(['Packages', 'WindowsApps'])('refuses managed application trees even inside AllowRoot (%s)', (directory) => { + const root = makeDir(`managed-${directory}`) + const managed = join(root, directory) + const child = join(managed, 'application') + mkdirSync(child, { recursive: true }) + stamp(child, PACKAGE_SID) + const key = directory === 'Packages' ? 'LOCALAPPDATA' : 'ProgramFiles' + const quote = (value: string): string => `'${value.replaceAll("'", "''")}'` + // PowerShell initializes ProgramFiles at startup; redirect only this child's lookup after startup. + const run = (path: string, mode: string): ScriptRun => runPowerShell(['-Command', + `$env:${key} = ${quote(root)}; & ${quote(script)} -Path ${quote(path)} -AllowRoot ${quote(root)} -Out ${quote(outDir)} ${mode}; exit $LASTEXITCODE`, + ]) + for (const path of [managed, child]) { + icacls(path, '/inheritance:r', '/grant:r', `*${meSid}:(M)`) + const before = sddlOf(path) + const grant = run(path, '-GrantFullControl') + expect(grant.code, grant.output).toBe(2) + expect(grant.output).toContain('managed application directory') + expect(sddlOf(path)).toBe(before) + } + const fix = run(child, '-Fix') + expect(fix.code, fix.output).toBe(2) + expect(fix.output).toContain('managed application directory') + expect(aclLines(child).join('\n')).toContain(PACKAGE_SID) + }) + it('rolls back earlier paths too when a later grant fails in the same invocation', () => { const first = makeDir('multi-grant-first') const second = makeDir('multi-grant-second') @@ -575,7 +702,7 @@ exit $LASTEXITCODE } finally { icacls(second, '/remove:d', `*${meSid}`) } - }, 60_000) + }, timeout) it('keeps all observations in unique reports and all ancestor paths in the compact summary', () => { const parent = makeDir('compact-parent') @@ -605,7 +732,7 @@ exit $LASTEXITCODE } expect(reportPaths.size).toBe(2) expect([sddlOf(parent), sddlOf(target)]).toEqual(before) - }, 60_000) + }, timeout) it('reports compact-output setup failures without changing the target or overwriting files', () => { const target = makeDir('compact-failure') diff --git a/packages/sandbox/sandbox-windows-acl/tests/fixtures/skill-composition/cordis.yml b/packages/sandbox/sandbox-windows-acl/tests/fixtures/skill-composition/cordis.yml new file mode 100644 index 0000000000..44222cbf73 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/fixtures/skill-composition/cordis.yml @@ -0,0 +1,4 @@ +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' +- id: skills + name: '@deepseek-ai/dsh-skill' diff --git a/packages/sandbox/sandbox-windows-acl/tests/skill-composition.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/skill-composition.spec.ts new file mode 100644 index 0000000000..5ab23646a1 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/skill-composition.spec.ts @@ -0,0 +1,63 @@ +/** The Windows-only provider registers through the shipped sandbox-local injection. */ +import { existsSync, readFileSync } from 'node:fs' +import { copyFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import * as SkillRegistry from '@deepseek-ai/dsh-skill' +import * as SandboxLocal from '@deepseek-ai/dsh-sandbox-local' +import { expect, it } from 'vitest' +import { ACL_DIAGNOSIS_SKILL } from '../src/acl-skill.ts' + +// sandbox-local's POSIX suites are excluded on Windows; this owner runs in Windows coverage. +it.skipIf(process.platform !== 'win32')('loads and unloads the ACL skill through the real sandbox-local composition', async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-acl-composition-')) + const ctx = new Context() + try { + // Loader persists disposal as disabled config; the committed seed stays read-only. + const configPath = join(root, 'cordis.yml') + await copyFile(new URL('./fixtures/skill-composition/cordis.yml', import.meta.url), configPath) + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const unexpectedLoaderOperation = (): never => { throw new Error('This composition does not use Node HMR operations') } + ctx.loader.internal = { + version: 'v2', + loadCache: new Map(), + register: unexpectedLoaderOperation, + getOrCreateModuleJob: unexpectedLoaderOperation, + resolveSync: unexpectedLoaderOperation, + load: unexpectedLoaderOperation, + async import(specifier: string) { + if (specifier === '@deepseek-ai/dsh-skill') return SkillRegistry + if (specifier === '@deepseek-ai/dsh-sandbox-local') return SandboxLocal + throw new Error(`Unexpected Loader import: ${specifier}`) + }, + } + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(configPath).href } }) + await ctx.loader.await() + expect((await ctx.skills.list()).map(entry => entry.name)).toEqual([ACL_DIAGNOSIS_SKILL]) + const loaded = await ctx.skills.get(ACL_DIAGNOSIS_SKILL) + expect(loaded).toMatchObject({ source: 'bundled', provider: 'dsh-windows-acl' }) + expect(loaded?.content).toContain('Stop after any failed/refused repair') + if (loaded?.resourceBase?.kind !== 'directory') throw new Error('Missing extracted skill resources') + const resources = loaded.resourceBase.path + expect(readFileSync(join(resources, 'scripts/diagnose-windows-sandbox-acl.ps1'), 'utf8')) + .toBe(readFileSync(fileURLToPath(new URL('../assets/diagnose-windows-sandbox-acl/scripts/diagnose-windows-sandbox-acl.ps1', import.meta.url)), 'utf8')) + const sandbox = [...ctx.loader.entries()].find(entry => entry.options.id === 'sandbox') + if (!sandbox?.fiber) throw new Error('Sandbox provider did not mount') + await sandbox.fiber.dispose() + expect(await ctx.skills.list()).toEqual([]) + expect(existsSync(resources)).toBe(false) + await ctx.loader.create({ + name: '@deepseek-ai/dsh-sandbox-local', + config: { runnerCommand: ['operator-runner'], runnerFailureSignatures: ['operator runner unavailable'] }, + }) + await ctx.loader.await() + expect(await ctx.skills.list()).toEqual([]) + } finally { + try { await ctx.fiber.dispose() } finally { await rm(root, { recursive: true, force: true }) } + } +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 220321ca7c..32ded520cf 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10193,6 +10193,12 @@ importers: '@deepseek-ai/cordis': specifier: workspace:~ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:~ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:~ + version: link:../../../vendor/loader '@deepseek-ai/dsh-pwsh-local': specifier: workspace:* version: link:../../shell/pwsh-local diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index d9fcf17470..04b917fc8b 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -560,7 +560,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'skill', title: 'Skill provider registry', mode: 'seam', - implementations: ['skill-badge', 'skill-filesystem', 'skill-office'], + implementations: ['sandbox-windows-acl', 'skill-badge', 'skill-filesystem', 'skill-office'], consumers: ['tool-skill'], note: 'Merges provider skill catalogs; tool-skill renders the session-prefix catalog and loads complete skill bodies.', }, diff --git a/snapshots/session/windows-acl-skill/session.v4.jsonl b/snapshots/session/windows-acl-skill/session.v4.jsonl index 339cb72186..6c169e06f7 100644 --- a/snapshots/session/windows-acl-skill/session.v4.jsonl +++ b/snapshots/session/windows-acl-skill/session.v4.jsonl @@ -9,13 +9,13 @@ {"type":"system/message","data":{"turn":1,"step":1,"message":{"role":"system","content":[{"type":"text","text":"{{system}}"}],"source":{"kind":"system-prompt"},"id":"{{message:2}}"}},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Load the diagnose-windows-sandbox-acl skill with the skill tool, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{message:1}}"},"surfaceOp":"append"} {"type":"user/message","data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"runtime-context","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{message:3}}"},"surfaceOp":"append"} -{"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `diagnose-windows-sandbox-acl`: Use on Windows for unexpected DSH sandbox access denials: workspace writes or listing fail, or an ordinarily readable path cannot be read. The bundled script inspects the path and every ancestor, reports observations and action reasons, and supports scoped, backed-up ACL repairs. Expected confinement denials need no ACL repair.\n- `model-only-skill`: Prove user-disabled skills remain available to the model.\n- `snapshot-skill`: Exercise project skill discovery and loading in snapshot tests.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"diagnose-windows-sandbox-acl","description":"Use on Windows for unexpected DSH sandbox access denials: workspace writes or listing fail, or an ordinarily readable path cannot be read. The bundled script inspects the path and every ancestor, reports observations and action reasons, and supports scoped, backed-up ACL repairs. Expected confinement denials need no ACL repair."},{"name":"model-only-skill","description":"Prove user-disabled skills remain available to the model."},{"name":"snapshot-skill","description":"Exercise project skill discovery and loading in snapshot tests."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} +{"type":"user/message","data":{"content":[{"type":"text","text":"\nA skill is a reusable set of task-specific instructions. The following skills are available in this session:\n\n\n- `diagnose-windows-sandbox-acl`: Use on Windows for unexpected DSH sandbox access denials: workspace writes or listing fail, or an ordinarily readable path cannot be read. The bundled script inspects the path and every ancestor, reports observations and action reasons, and supports scoped, backed-up ACL repairs. Expected confinement denials need no ACL repair.\n\n\nIf the user names a skill, or the task clearly matches a skill's description, call the `skill` tool with the exact skill name before taking task actions. Load all applicable skills, then follow their full instructions. This catalog contains summaries only; do not infer or follow a skill's instructions until it has been loaded.\nA user may also invoke a skill directly; its block then appears in this conversation. Follow it, and do not call the `skill` tool again for that skill.\n"}],"source":{"kind":"skill-catalog","form":"catalog","entries":[{"name":"diagnose-windows-sandbox-acl","description":"Use on Windows for unexpected DSH sandbox access denials: workspace writes or listing fail, or an ordinarily readable path cannot be read. The bundled script inspects the path and every ancestor, reports observations and action reasons, and supports scoped, backed-up ACL repairs. Expected confinement denials need no ACL repair."}]},"role":"user","id":"{{message:4}}"},"surfaceOp":"append"} {"type":"request/header","data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"session/title","data":{"title":"Load the diagnose-windows-sandbox-acl sk","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5},"stream":[{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430688,"index":0,"dt":[],"texts":["Load the requested skill."]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699430688,"index":1,"dt":[],"id":"call_skill_load","name":"skill","args":["{\"name\":\"diagnose-windows-sandbox-acl\"}"]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/diagnose-windows-sandbox-acl\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Diagnose Windows sandbox ACL failures\n\n**Stop after any failed/refused repair or failed verification.** A completed DACL write is not a successful repair. Never continue from a failed grant to `-Fix`, repeat the grant, or remove a deny ACE. The script automatically restores attempted changes from the failed invocation; verify its rollback result. Restore earlier successful invocations in reverse order using their exact recovery commands, then stop. A later backup alone cannot undo earlier repairs.\n\n## Decide whether diagnosis applies\n\nDiagnose unexpected writes/listing denied inside a `workspace-write` workspace, or reads the signed-in user should plainly have. Root-only failure and uniform failure are both eligible.\n\nStop and explain expected denials: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, or ConstrainedLanguage errors for .NET/COM/reflection. Use an authorized unconfined call or another permitted operation; do not repair ACLs for these cases.\n\n## Read the report before choosing a repair\n\nResolve the script from this skill's resource directory. Run it **unconfined from the first diagnostic call**: the confined token cannot inspect the conflicting object. This does not elevate the Windows token. Use one path per invocation; do not repeat `-Path` or pass a comma-separated string to `pwsh -File`.\n\n```powershell\n& '\\scripts\\diagnose-windows-sandbox-acl.ps1' -Path '' -Out '' -Compact\nexit $LASTEXITCODE\n```\n\nSubstitute full, quoted paths. Choose a persistent, user-owned `-Out` directory, preferably beside the failing workspace within the authorized tree. Never put reports or backups in the skill resource directory: it is deleted when the skill unloads. `-Compact` saves a unique full JSONL report and prints one `REPORT` summary. Diagnosis changes no ACL or existing content; it only writes this requested report. The summary includes all `inspectedPaths`, findings, decisions, ACL actions, verification, rollback state and `nextAction`. Use the summary to decide; read only specific records from the saved report when evidence is missing, without dumping the whole report or reading the implementation to rediscover these instructions.\n\nEach full record has `kind`, `operation`, `path`, `status`, `reason`, `details`. Unknown observations remain unknown. A deny ACE's presence alone does not establish causation. DSH provisions `S-1-4-*` workspace grants and an Everyone `DeleteSubdirectoriesAndFiles` deny; these are expected confinement entries, not package conflicts or automatically unresolved faults. Coexisting package ACEs may still block the confined child even when provisioning fails first; do not dismiss either finding. `completed` actions only confirm API execution. Check verification and the final summary. Missing summary means unconfirmed completion: inspect the report and recovery artifacts before doing anything else.\n\n| Verdict | Next step, only with complete observations |\n|---|---|\n| `CULPRIT` | Explain the affected paths and individual package allow SIDs; select `-Fix`. |\n| `PRECONDITION` | Explain missing effective `WRITE_DAC`/`WRITE_OWNER`; select `-GrantFullControl` if the caller has `WRITE_DAC`. |\n| `BOTH` | Grant on the affected object first. Only after verified success may a separate invocation use `-Fix`. Failure ends this sequence. |\n| `UNREADABLE`, `INCOMPLETE`, `NOT_THIS_CLASS` | Report observations and stop; do not infer a safe repair. |\n\n## Run the selected repair\n\nExplain the finding and cost first, and briefly apologize for the harness's unhelpful denial after confirming the conflict. `-Fix` removes individual `S-1-15-2-*` allow ACEs and their access; well-known groups ending in 1 or 2 are preserved. `-GrantFullControl` adds a current-user allow ACE; it cannot cancel an explicit deny. Both preserve owner and SACL. Full control supplies `WRITE_DAC` and `WRITE_OWNER`; taking ownership alone does not.\n\nAppend `-AllowRoot ''` and **exactly one** of `-Fix` or `-GrantFullControl` to the diagnostic command, before `exit`. Keep `-Compact -Out`. Targets must be strictly inside that root; reparse paths are refused. Never widen the authorized root to reach an ancestor. Missing effective `WRITE_DAC` in the unconfined caller requires handing the user an exact command for their elevated terminal, not another sandbox escalation. Never initiate UAC/`runas` or run the whole agent elevated.\n\nFollow `nextAction`: `stop` ends repairs; `restore_pending_then_stop` requires the reported recovery commands in their listed order, then stop. Verified rollback does not turn a failed repair into success. Keep full report/backup paths and exact recovery commands for every invocation; never abbreviate executable paths. On recovery failure, stop and report the remaining commands without more repairs.\n\nFor `verify_original_confined_operation`, repeat the original failed operation in the original confined context. If it still fails, restore this workflow's successful repairs in reverse order, report the error and stop. Do not start another repair cycle.\n\nUse the user's language for progress and final reporting. Concisely report observations and analyzed paths (including relevant ancestors), actions and reasons, verification, and the next step; link full reports. Label any authorized unconfined fallback as such, not as successful repair. Explain that another tool may recreate a removed package ACE. Never write probe files, edit ACLs by hand, change owners, erase denies, or replace child permissions recursively. Do not provide manual ACL-edit commands for the user either: only bundled repairs and generated recovery commands are supported; unresolved denies require permission-policy review or moving the workspace.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/diagnose-windows-sandbox-acl\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Diagnose Windows sandbox ACL failures\n\n**Stop after any failed/refused repair or failed verification.** A completed DACL write is not a successful repair. Never continue from a failed grant to `-Fix`, repeat the grant, or remove a deny ACE. The script automatically restores attempted changes from the failed invocation; verify its rollback result. Restore earlier successful invocations in reverse order using their exact recovery commands, then stop. A later backup alone cannot undo earlier repairs.\n\n## Decide whether diagnosis applies\n\nDiagnose unexpected writes/listing denied inside a `workspace-write` workspace, or reads the signed-in user should plainly have. Root-only failure and uniform failure are both eligible.\n\nStop and explain expected denials: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, or ConstrainedLanguage errors for .NET/COM/reflection. Use an authorized unconfined call or another permitted operation; do not repair ACLs for these cases.\n\n## Read the report before choosing a repair\n\nResolve the script from this skill's resource directory. Run it **unconfined from the first diagnostic call**: the confined token cannot inspect the conflicting object. This does not elevate the Windows token. Use one path per invocation; do not repeat `-Path` or pass a comma-separated string to `pwsh -File`.\n\n```powershell\n& '\\scripts\\diagnose-windows-sandbox-acl.ps1' -Path '' -Out '' -Compact\nexit $LASTEXITCODE\n```\n\nSubstitute full, quoted paths. Choose a persistent, user-owned `-Out` directory, preferably beside the failing workspace within the authorized tree. Never put reports or backups in the skill resource directory: it is deleted when the skill unloads. `-Compact` saves a unique full JSONL report and prints one `REPORT` summary. Diagnosis changes no ACL or existing content; it only writes this requested report. The summary includes all `inspectedPaths`, findings, decisions, ACL actions, verification, rollback state and `nextAction`. Use the summary to decide; read only specific records from the saved report when evidence is missing, without dumping the whole report or reading the implementation to rediscover these instructions.\n\nEach full record has `kind`, `operation`, `path`, `status`, `reason`, `details`. Unknown observations remain unknown. A deny ACE's presence alone does not establish causation. DSH provisions `S-1-4-*` workspace grants and an Everyone `DeleteSubdirectoriesAndFiles` deny; these are expected confinement entries, not package conflicts or automatically unresolved faults. Coexisting package ACEs may still block the confined child even when provisioning fails first; do not dismiss either finding. `completed` actions only confirm API execution. Check verification and the final summary. Missing summary means unconfirmed completion: inspect the report and recovery artifacts before doing anything else.\n\n| Verdict | Next step, only with complete observations |\n|---|---|\n| `CULPRIT` | Explain the affected paths and individual package allow SIDs; select `-Fix`. |\n| `PRECONDITION` | Explain missing effective `WRITE_DAC`/`WRITE_OWNER`; select `-GrantFullControl` if the caller has `WRITE_DAC`. |\n| `BOTH` | Grant on the affected object first. Only after verified success may a separate invocation use `-Fix`. Failure ends this sequence. |\n| `UNREADABLE`, `INCOMPLETE`, `NOT_THIS_CLASS` | Report observations and stop; do not infer a safe repair. |\n\n## Run the selected repair\n\nExplain the finding and cost first. `-Fix` removes individual `S-1-15-2-*` allow ACEs at their explicit sources, ancestor first, then verifies inherited entries disappeared; well-known groups ending in 1 or 2 are preserved. This removes those packages' access. `-GrantFullControl` adds a current-user allow ACE; it cannot cancel an explicit deny. Both preserve owner, inheritance and SACL, including Low integrity labels. Low executable labels and their effects outside DSH are outside this repair's scope. Full control supplies `WRITE_DAC` and `WRITE_OWNER`; taking ownership alone does not.\n\nAppend `-AllowRoot ''` and **exactly one** of `-Fix` or `-GrantFullControl` to the diagnostic command, before `exit`. Keep `-Compact -Out`. Every explicit source must be strictly inside that root; reparse paths and managed application trees (`LocalAppData\\Packages`, `ProgramFiles\\WindowsApps`) are refused. Never widen the authorized root to reach an ancestor. Missing effective `WRITE_DAC` in the unconfined caller requires stopping and reporting the path and missing rights for permission-policy review. Extracted scripts and recovery copies are user-writable: never supply commands to run them elevated, initiate UAC/`runas`, or run the whole agent elevated.\n\nFollow `nextAction`: `stop` ends repairs; `restore_pending_then_stop` requires the reported recovery commands in their listed order, then stop. Verified rollback does not turn a failed repair into success. Keep full report/backup paths and exact recovery commands for every invocation; never abbreviate executable paths. On recovery failure, stop and report the remaining commands without more repairs.\n\nFor `verify_original_confined_operation`, repeat the original failed operation in the original confined context. If it still fails, restore this workflow's successful repairs in reverse order, report the error and stop. Do not start another repair cycle.\n\nUse the user's language for progress and final reporting. Concisely report observations and analyzed paths (including relevant ancestors), actions and reasons, verification, and the next step; link full reports. Label any authorized unconfined fallback as such, not as successful repair. Explain that another tool may recreate a removed package ACE. Never write probe files, edit ACLs by hand, change owners, erase denies, or replace child permissions recursively. Do not provide manual ACL-edit commands for the user either: only bundled repairs and generated recovery commands are supported; unresolved denies require permission-policy review or moving the workspace.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4},"stream":[{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430709,"index":0,"dt":[],"texts":["The skill is loaded."]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699430709,"index":1,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} diff --git a/snapshots/session/windows-acl-skill/workspace/.dsh/skills/model-only-skill/SKILL.md b/snapshots/session/windows-acl-skill/workspace/.dsh/skills/model-only-skill/SKILL.md deleted file mode 100644 index ac7c02e29f..0000000000 --- a/snapshots/session/windows-acl-skill/workspace/.dsh/skills/model-only-skill/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: model-only-skill -description: Prove user-disabled skills remain available to the model. -user-invocable: false ---- - -Follow these model-only snapshot instructions. diff --git a/snapshots/session/windows-acl-skill/workspace/.dsh/skills/snapshot-skill/SKILL.md b/snapshots/session/windows-acl-skill/workspace/.dsh/skills/snapshot-skill/SKILL.md deleted file mode 100644 index b0816d6bee..0000000000 --- a/snapshots/session/windows-acl-skill/workspace/.dsh/skills/snapshot-skill/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: snapshot-skill -description: Exercise project skill discovery and loading in snapshot tests. ---- - -Follow these snapshot-only instructions. -Resolve referenced resources relative to this skill directory. diff --git a/snapshots/session/windows-acl-skill/workspace/.dsh/skills/user-only-skill/SKILL.md b/snapshots/session/windows-acl-skill/workspace/.dsh/skills/user-only-skill/SKILL.md deleted file mode 100644 index 4b02550d15..0000000000 --- a/snapshots/session/windows-acl-skill/workspace/.dsh/skills/user-only-skill/SKILL.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -name: user-only-skill -description: Prove model-disabled skills stay outside the model catalog. -disable-model-invocation: true ---- - -Follow these user-only snapshot instructions. From 979b77a6bcfedb267e55a4691e44a00ef81ad0cb Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:54:54 +0800 Subject: [PATCH 08/86] test(web): move the spreadsheet selection by keyboard before sidebar reflows The meeting-sheet step of document-preview.e2e.ts clicked the sheet overlay at the cell FortuneSheet already selects on mount, so its polls passed before the click committed, and the press resolved its cell inside a React state updater that later renders re-execute against the reflowed grid. On loaded Linux runners that re-execution landed after the first sidebar resize and committed B1 (the two-column sheet clamps the stale pointer x to its last column), failing the reflow loop with `expected 'B1' to be 'A1'`. Select A1 through ArrowRight/ArrowLeft instead: keyboard moves compute the target from committed state, so a re-executed updater yields the same cell, and the B1 -> A1 transition verifies the interaction before the reflows. Refs #5242 --- ...ection-by-keyboard-before-reflow.i18n.yaml | 22 +++++++++++ ...eet-selection-by-keyboard-before-reflow.md | 37 +++++++++++++++++++ ...-selection-by-keyboard-before-reflow.zh.md | 37 +++++++++++++++++++ apps/web/tests/document-preview.e2e.ts | 12 +++++- 4 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md create mode 100644 .agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md diff --git a/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.i18n.yaml new file mode 100644 index 0000000000..e88c1db82b --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.i18n.yaml @@ -0,0 +1,22 @@ +# Bilingual-pair consistency record for 2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md +/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions: + en: 015053d7e4cc8b74 + zh: 8afd22975bcb9814 +/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/problem: + en: 73050d2c7d3bceeb + zh: 68f45fbaa8df5786 +/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/decision: + en: 05033995f6accde4 + zh: 1de1372d99a4dc4b +/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/alternatives-considered: + en: 6085e5faea6ec83f + zh: f5a790200d7787a9 +/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/verification: + en: 4b57417a68d13275 + zh: e0bbbf54e449eb0c +/agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/consequences: + en: 03eedcc3cfdad180 + zh: 8d4843e01448fe83 diff --git a/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md new file mode 100644 index 0000000000..b4d07b3f9b --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md @@ -0,0 +1,37 @@ +# Agent Note: Web lane moves spreadsheet selections by keyboard before reflow assertions + +Status: implemented + +English | [中文](2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md) + +## Problem + +[document-preview.e2e.ts](../../../../apps/web/tests/document-preview.e2e.ts) opens `meeting.xlsx`, clicks the sheet overlay at `{ x: 60, y: 40 }` to select `A1`, then widens and narrows the sidebar four times and asserts after each reflow that the name box still reads `A1`. Every `CI master` run of the serial self-hosted Linux lane between 2026-09-24 and 2026-09-27 failed one or both locale cases of that scenario with `expected 'B1' to be 'A1'` at the first reflow, and hosted `node 24 / snapshots and artifacts` runs failed the same way (runs 36110572677 and 36213089675). The failure is not locale-bound: en-US failed alone in four master runs, both locales in three, zh-CN alone in three. Polling the read (#5243) changed nothing because `B1` is the committed selection, not a stale sample. + +`A1` is the workbook's initial selection, so the polls that follow the click are satisfied before the click commits and never verify what the click selected. FortuneSheet's `SheetOverlay` handles the press as `setContext(draft => handleCellAreaMouseDown(draft, globalCache, nativeEvent, cellInput, cellArea, …))`, and `handleCellAreaMouseDown` resolves the cell from `cellArea.getBoundingClientRect()` and the event's page coordinates at the moment the updater runs. React runs `useState` updaters during render and runs them again when the hook queue holds a lower-priority update ahead of them; the freshly mounted workbook still has such updates pending, and Playwright precedes the press with a `mousemove` whose FortuneSheet handler is a continuous-priority update on the same state. An instrumented verbatim copy of the scenario on the Blacksmith runner recorded four executions of `handleCellAreaMouseDown` for the one click: two before the click returned reading `left=969.5`, and two after the scenario had already set the panel to `1000px`, reading `left=725.5`. The stored pointer x of 985 then resolves to 259.5 inside the grid, and because the meeting sheet has two columns `colLocation` clamps it to the last one: `B1`. On a machine that commits the workbook's initial selection normalization before the click, the same point lies on A1's own selection box, whose `onMouseDown` stops propagation; the click then selects nothing, no geometry-bound updater is queued, and the scenario passes without having tested the click. + +## Decision + +The meeting-sheet step selects by keyboard. The scenario focuses the sheet overlay, presses `ArrowRight` and asserts `B1` with an empty formula bar, then presses `ArrowLeft` and asserts `A1` with `会议纪要`, and only then starts the reflow loop. `handleArrowKey` → `moveHighlightCell` computes the target from committed state — the current selection and `visibledatacolumn` — so a re-executed updater yields the same cell whatever the grid's geometry is by then, and the two moves make the interaction observable because `B1` differs from the initial selection. The reflow loop, its `A1` assertion, the sheet-tab scroll-control counts, and the canvas identity check are unchanged. + +The budget-sheet click at the start of the scenario stays a pointer click: it targets a cell other than the initial selection, its result is verified by the `46281` formula read, and keyboard, clipboard, and layout reads separate it from the first reflow by far more than one deferred render. The `values.csv` click followed by a narrowing stays too: a re-execution against the narrowed grid resolves a negative x, which `colLocation` clamps to column A, the same cell. + +## Alternatives considered + +**Poll the name box after each reflow.** #5243 did this. The re-executed updater's result is the final committed state, so the poll observes `B1` for its whole budget; there is no later state to wait for. + +**Wait for the selection box to render before clicking.** That restores the passing path in which the click lands on A1's own drag handle and selects nothing, so the assertions would verify the initial selection rather than an interaction. Clicking a cell other than the initial selection instead keeps the geometry-bound updater and its re-execution window open across the reflow that follows. + +**Wait for the renderer to go idle before the first reflow.** No product state names "React has no re-execution pending"; an idle or fixed wait sized to the runner is the flake-masking wait [dsh-ci-test-reliability](../../../skills/dsh-ci-test-reliability/SKILL.md) rejects. + +**Patch FortuneSheet to resolve the cell outside the updater.** `handleCellAreaMouseDown` lives in `@fortune-sheet/core`; snapshotting the rectangle before `setContext` is a dependency patch for a window a user cannot reach — a pointer press and a sidebar reflow inside one deferred render. It stays an upstream concern; the product change is out of this test fix. + +## Verification + +The instrumented verbatim copy of the shipped scenario, run on the hosted Linux lane of draft PR #5291 with `DEBUG=pw:api` and a `getBoundingClientRect` hook on the cell area, reproduced `expected 'B1' to be 'A1'` at the first reflow in 3 of 12 runs across two lane runs, each with the four-execution signature above and with `mousedown` targeting `#luckysheet-sheettable_0` rather than the drag handle. The keyboard-driven copy passed 6 of 6 in the lane run that reproduced the click failure. Locally, `Emulation.setCPUThrottlingRate` at 20× makes the click reach the cell area and records three executions of `handleCellAreaMouseDown` per click; the keyboard-driven copy passes 6 of 6 under that throttle and 6 of 6 with React's scheduler wake-ups deferred by 200 ms. + +`env -u TSX_TSCONFIG_PATH DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/document-preview.e2e.ts -t "fills the spreadsheet pane"` passes both locale cases in every local repetition recorded in the PR. + +## Consequences + +The scenario verifies a keyboard-made selection surviving sidebar reflows and no longer depends on where React's deferred render lands relative to the reflow. A pointer click in this lane remains valid when its target differs from the current selection and its effect is asserted before any layout change, or when a re-execution against the changed layout provably resolves the same cell; a click whose result feeds a reflow assertion within a few round trips does not. FortuneSheet's geometry-bound updater is unchanged, so a real pointer press followed within one deferred render by a sidebar reflow can still move the selection in the product. diff --git a/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md new file mode 100644 index 0000000000..cdcf0adcb2 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md @@ -0,0 +1,37 @@ +# Agent Note: Web lane moves spreadsheet selections by keyboard before reflow assertions + +Status: implemented + +[English](2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md) | 中文 + +## Problem + +[document-preview.e2e.ts](../../../../apps/web/tests/document-preview.e2e.ts) 打开 `meeting.xlsx`,在表格 overlay 的 `{ x: 60, y: 40 }` 处点击以选中 `A1`,随后把侧栏加宽、收窄各两次,并在每次重排后断言名称框仍显示 `A1`。2026-09-24 到 2026-09-27 之间 `CI master` 的 serial 自托管 Linux lane 每一次运行都在第一次重排处以 `expected 'B1' to be 'A1'` 失败该场景的一个或两个 locale 用例,托管的 `node 24 / snapshots and artifacts` 也以同样方式失败(run 36110572677 与 36213089675)。失败与 locale 无关:四次 master 运行只有 en-US 失败,三次两者都失败,三次只有 zh-CN 失败。把读取改为轮询(#5243)没有作用,因为 `B1` 是已提交的选区,不是过期采样。 + +`A1` 是工作簿的初始选区,所以点击之后的两个轮询在点击提交前就已满足,从未验证点击选中了什么。FortuneSheet 的 `SheetOverlay` 以 `setContext(draft => handleCellAreaMouseDown(draft, globalCache, nativeEvent, cellInput, cellArea, …))` 处理按下,而 `handleCellAreaMouseDown` 在 updater 执行的那一刻用 `cellArea.getBoundingClientRect()` 与事件的页面坐标解析目标单元格。React 在渲染期间运行 `useState` 的 updater,并且当 hook 队列里有更低优先级的更新排在它前面时会再次运行它;刚挂载的工作簿仍有这类更新待处理,而 Playwright 在按下之前先派发一次 `mousemove`,其 FortuneSheet 处理器是同一状态上的 continuous 优先级更新。在 Blacksmith runner 上运行的带探针的逐字副本记录到这一次点击触发了四次 `handleCellAreaMouseDown`:两次在点击返回前读到 `left=969.5`,两次在场景已把面板设为 `1000px` 之后读到 `left=725.5`。保存下来的指针 x 985 于是在网格内解析为 259.5,而会议表只有两列,`colLocation` 把它钳到最后一列:`B1`。在点击前就已提交工作簿初始选区归一化的机器上,同一位置落在 A1 自身的选区框上,其 `onMouseDown` 阻止了传播;点击于是什么也没选中,没有几何绑定的 updater 入队,场景在没有测试到点击的情况下通过。 + +## Decision + +会议表这一步改为键盘选择。场景聚焦表格 overlay,按 `ArrowRight` 并断言 `B1` 且公式栏为空,再按 `ArrowLeft` 并断言 `A1` 且公式栏为 `会议纪要`,然后才开始重排循环。`handleArrowKey` → `moveHighlightCell` 只依据已提交状态(当前选区与 `visibledatacolumn`)计算目标,所以被重新执行的 updater 无论此时网格几何如何都得到同一单元格,而两次移动使交互可观察,因为 `B1` 与初始选区不同。重排循环、其中的 `A1` 断言、表页滚动控件计数与 canvas 身份检查保持不变。 + +场景开头预算表的点击仍是指针点击:它的目标不是初始选区,其结果由 `46281` 公式读取验证,而键盘、剪贴板和布局读取把它与第一次重排隔开远超一次延迟渲染。`values.csv` 的点击及其后的收窄也保持不变:针对收窄后的网格重新执行会解析出负的 x,`colLocation` 把它钳到 A 列,即同一单元格。 + +## Alternatives considered + +**每次重排后轮询名称框。** #5243 这样做了。被重新执行的 updater 的结果就是最终提交状态,所以轮询在整个预算内都观察到 `B1`;没有更晚的状态可等。 + +**点击前等待选区框渲染。** 这会恢复点击落在 A1 自身拖拽手柄上、什么也不选的通过路径,断言于是验证的是初始选区而不是一次交互。改为点击初始选区以外的单元格则会保留几何绑定的 updater,其重新执行窗口跨越随后的重排。 + +**第一次重排前等待渲染器空闲。** 没有产品状态能表达“React 没有待重新执行的更新”;按 runner 调校的空闲等待或固定等待正是 [dsh-ci-test-reliability](../../../skills/dsh-ci-test-reliability/SKILL.md) 拒绝的掩盖式等待。 + +**给 FortuneSheet 打补丁,在 updater 之外解析单元格。** `handleCellAreaMouseDown` 位于 `@fortune-sheet/core`;在 `setContext` 之前快照矩形是针对用户无法触达的窗口(一次指针按下与一次侧栏重排落在同一次延迟渲染内)的依赖补丁。它仍是上游问题;产品改动不在本次测试修复范围内。 + +## Verification + +在草稿 PR #5291 的托管 Linux lane 上以 `DEBUG=pw:api` 与 cell area 上的 `getBoundingClientRect` 钩子运行的带探针逐字副本,在两次 lane 运行共 12 次中有 3 次在第一次重排处复现 `expected 'B1' to be 'A1'`,每次都带有上述四次执行的特征,且 `mousedown` 的目标是 `#luckysheet-sheettable_0` 而不是拖拽手柄。键盘驱动的副本在复现了点击失败的那次 lane 运行中 6 次全部通过。本地以 20× 的 `Emulation.setCPUThrottlingRate` 运行可让点击到达 cell area,并记录到每次点击三次 `handleCellAreaMouseDown` 执行;键盘驱动的副本在该限速下 6 次全部通过,在 React 调度器唤醒被推迟 200 ms 时也 6 次全部通过。 + +`env -u TSX_TSCONFIG_PATH DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/document-preview.e2e.ts -t "fills the spreadsheet pane"` 在 PR 中记录的每一次本地重复运行里都通过两个 locale 用例。 + +## Consequences + +场景验证的是键盘做出的选区在侧栏重排后仍然保留,不再依赖 React 的延迟渲染落在重排之前还是之后。此 lane 中的指针点击在目标不同于当前选区、且其效果在任何布局变化之前被断言时仍然有效,或者在针对变化后布局重新执行可证明解析为同一单元格时也有效;而结果在几个往返之内就被送入重排断言的点击则无效。FortuneSheet 的几何绑定 updater 未改动,所以产品中一次真实的指针按下若在一次延迟渲染内紧跟着侧栏重排,仍可能移动选区。 diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 7812b5b6b6..169384e44d 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -267,9 +267,17 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan const meetingCanvas = await canvas.elementHandle() if (meetingCanvas === null) throw new Error('meeting spreadsheet canvas is unavailable') await expectExcelLayout(excel) - await excel.locator('.fortune-sheet-overlay').click({ position: { x: 60, y: 40 } }) - await expect.poll(() => formula.innerText()).toBe('会议纪要') + // Arrow keys move the selection from committed state. A pointer click resolves its cell + // from the grid's live geometry inside a React state updater that a later render re-runs, + // so a click followed by the reflows below can land on whatever column the moved grid puts + // under the stale pointer coordinates. + await excel.locator('.fortune-sheet-overlay').focus() + await page.keyboard.press('ArrowRight') + await expect.poll(() => selection.innerText()).toBe('B1') + await expect.poll(() => formula.innerText()).toBe('') + await page.keyboard.press('ArrowLeft') await expect.poll(() => selection.innerText()).toBe('A1') + await expect.poll(() => formula.innerText()).toBe('会议纪要') for (const width of [1000, 360, 1000, 360]) { await layout.evaluate((node, width) => { node.textContent = `[data-sidebar-right-panel] { width: ${width}px !important; }` }, width) await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width) From fc885edf721dbd0769622c4093a460f8985bbbf5 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:12:40 +0800 Subject: [PATCH 09/86] test(pwsh): record a per-send readiness timeline in the loader-composition case The self-hosted Windows lane settles one or two of seven sends on the controlled prompt and the rest at the plain silence bound, and the settle reasons alone cannot say whether the marker was missing, its tail was invalidated by later output, or the foreground comparison failed. The case now records every decoded pty chunk with the sanitizer's marker verdict and prompt tail, every foreground poll, every input write, each send's settle reason and elapsed time, the session's prompt evidence replayed from those chunks, and the host, pwsh, PSReadLine, and console host versions; the settle assertions carry that timeline in their failure message and onTestFailed prints it when the runner's budget ends the case first. promptTailGraceMs returns to the product default: master runs with and without it took the same 20.8 s, so the lane's degraded sends never held the state it covers, and a nonzero value there would only lengthen a never-arriving-tail fallback. --- .../tests/loader-composition.spec.ts | 91 ++++-- .../tests/readiness-timeline.ts | 301 ++++++++++++++++++ 2 files changed, 371 insertions(+), 21 deletions(-) create mode 100644 packages/shell/tool-pwsh-persistent/tests/readiness-timeline.ts diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index edbf07509c..bfe01a0088 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -1,9 +1,9 @@ import { spawnSync } from 'node:child_process' import { mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { release, tmpdir, version } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' @@ -24,16 +24,44 @@ import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as ToolPwshPersistent from '@deepseek-ai/dsh-tool-pwsh-persistent' import { unsupportedInbox } from '@deepseek-ai/dsh-agent-loop-testkit' +import { ReadinessTimeline, TIMELINE_HEADER } from './readiness-timeline.ts' +const pwshPath = resolvePwshPath() const hasPwsh = spawnSync( - resolvePwshPath(), ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], + pwshPath, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', '$true'], { encoding: 'utf8' }, ).status === 0 +// Shell and console-host versions for a failure message: the Windows console host, not this +// process, decides how the prompt marker and its tail reach the session, and its build differs +// between the runners that pass this case and the self-hosted pool that fails it. +const HOST_FACTS_COMMAND = [ + '"pwsh $($PSVersionTable.PSVersion) PSReadLine $((Get-Module PSReadLine -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1).Version)"', + 'if ($env:OS -eq \'Windows_NT\') { "conhost $((Get-Item (Join-Path $env:SystemRoot \'System32\\conhost.exe\')).VersionInfo.FileVersion)" }', +].join('; ') + +function hostFacts(): string { + const probe = spawnSync( + pwshPath, ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', HOST_FACTS_COMMAND], + { encoding: 'utf8', timeout: 30_000 }, + ) + const shell = probe.status === 0 + ? probe.stdout.trim().split(/\r?\n/).join('; ') + : `version probe failed: ${probe.error?.message ?? probe.stderr.trim()}` + return [ + `host: ${process.platform} ${process.arch} ${release()} (${version()}); node ${process.version}`, + `shell: ${pwshPath}; ${shell}`, + // dsh-subprocess-local spawns node-pty without useConptyDll, so the system console host, + // not the OpenConsole build node-pty bundles, renders every session. + 'conpty: system conhost', + ].join('\n') +} + let root: string | undefined let context: Context | undefined afterEach(async () => { + vi.restoreAllMocks() await context?.fiber.dispose() context = undefined if (root !== undefined) await rm(root, { recursive: true, force: true }) @@ -100,12 +128,9 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp // send's wait reason, which pins the controlled-prompt fast path directly // instead of relying on how long silence would take to settle. ' handoffGraceMs: 300', - // The self-hosted Windows pool stalls the console renderer for seconds (issue 2487): the - // OSC marker reaches the session while the five-byte prompt tail that follows it does not - // arrive until the plain silence bound has passed, and every such send would otherwise - // settle as inferred_idle. The tolerance keeps those sends on the controlled-prompt path - // this case pins, without letting a missing prompt (no marker at all) escape the silence tier. - ' promptTailGraceMs: 5000', + // promptTailGraceMs keeps its product default (0): the self-hosted Windows failures of + // 2026-09-25..27 settled at the plain silence bound with the tolerance present and absent + // alike, so it never applied there and would only lengthen a never-arriving-tail fallback. ' scrollbackLines: 20000', // The first call pays the full pwsh cold-start latency (spawn + .NET + // PSReadLine + Defender) inside the tool deadline; a 60s bound on the @@ -153,10 +178,23 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp const startSend = terminals.startSend.bind(terminals) // A send that lost the controlled-prompt fast path settles as inferred_idle // after the silence tier, so recording why every send settled detects that - // regression immediately instead of through accumulated wall-clock. + // regression immediately instead of through accumulated wall-clock. The + // timeline records what the session observed on the way there — every pty + // chunk's prompt verdict, every foreground poll, every write — because the + // reason alone cannot say whether the marker was missing, its tail was + // invalidated by later output, or the foreground comparison failed. + const timeline = new ReadinessTimeline(hostFacts) + timeline.observeSanitizer() + const spawnTerminal = context.subprocess.spawnTerminal.bind(context.subprocess) + vi.spyOn(context.subprocess, 'spawnTerminal').mockImplementation(async (spec) => { + const handle = await spawnTerminal(spec) + timeline.observeTerminal(handle) + return handle + }) const settleReasons: TerminalWaitReason[] = [] vi.spyOn(terminals, 'startSend').mockImplementation((owner, id, request) => { const operation = startSend(owner, id, request) + timeline.track(operation) void operation.done.then( (settled) => { settleReasons.push(settled.waitReason) }, // A rejected send is the tool's error path, not a settle reason. @@ -164,16 +202,25 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp ) return operation }) + // The runner's timeout never reaches the assertions below; print the timeline + // for that path too, unless a failed assertion already carries it. + onTestFailed(({ task }) => { + if (task.result?.errors?.some(error => error.message?.includes(TIMELINE_HEADER))) return + console.error(timeline.format()) + }) const owner = await agent(context, root) const signal = new AbortController().signal - const execute = (id: string, command: string) => context!.tools.execute({ - signal, - callId: ToolCallId(id), - name: 'pwsh', - arguments: { command }, - agent: owner, - }) + const execute = (id: string, command: string) => { + timeline.label(id) + return context!.tools.execute({ + signal, + callId: ToolCallId(id), + name: 'pwsh', + arguments: { command }, + agent: owner, + }) + } expect(context.tools.schemas().map(schema => schema.name)).toEqual(['pwsh']) await execute('state', '$env:KEEP = "loader"; New-Item -ItemType Directory -Force -Path nested | Out-Null; Set-Location nested') @@ -206,9 +253,11 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp // Six commands settle on the controlled prompt; no send may fall back to the // silence tier, which is the 3.5 s-per-call degradation this suite pins. The // counts alone do not say which tier settled which send, so every reason the - // run recorded rides in the failure message (2026-09-25 self-hosted Windows - // lane reported one to two stdin_read settlements across three runs). - expect(settleReasons.filter(reason => reason === 'stdin_read').length, JSON.stringify(settleReasons)).toBeGreaterThanOrEqual(6) - expect(settleReasons).not.toContain('inferred_idle') + // run recorded and the per-send timeline ride in the failure message (the + // self-hosted Windows lane reported one to two stdin_read settlements per + // run on 2026-09-25..27, every other send at the plain silence bound). + const failure = (): string => `${JSON.stringify(settleReasons)}\n${timeline.format()}` + expect(settleReasons.filter(reason => reason === 'stdin_read').length, failure()).toBeGreaterThanOrEqual(6) + expect(settleReasons, failure()).not.toContain('inferred_idle') }, 120_000) }) diff --git a/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.ts b/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.ts new file mode 100644 index 0000000000..3255166bfa --- /dev/null +++ b/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.ts @@ -0,0 +1,301 @@ +/** + * Per-send readiness timeline for the persistent pwsh Loader-composition case: every + * decoded pty chunk as the session's sanitizer saw it, every foreground poll result, + * every input write, and how each send settled, formatted for a failure message so a + * lost controlled-prompt fast path names the state that lost it. + */ +import type { SubprocessTerminalForeground, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import type { TerminalSendOperation, TerminalWaitReason } from '@deepseek-ai/dsh-terminal' +import { CONTROLLED_PROMPT, TerminalSanitizer } from '@deepseek-ai/dsh-terminal-bash/src/sanitize.ts' +import type { SanitizedChunk } from '@deepseek-ai/dsh-terminal-bash/src/sanitize.ts' +import { vi } from 'vitest' + +/** First line of the formatted timeline; a failure message carrying it needs no second print. */ +export const TIMELINE_HEADER = 'readiness timeline (ms since the case started; s = terminal session in spawn order)' + +interface ChunkEvent { + kind: 'chunk' + at: number + session: number + units: number + sample: string + prompt: boolean + promptTail: string | undefined + textLength: number +} + +interface PollEvent { + kind: 'poll' + at: number + session: number + foreground: string +} + +interface WriteEvent { + kind: 'write' + at: number + session: number + units: number +} + +interface SpawnEvent { + kind: 'spawn' + at: number + session: number + pid: number +} + +type TimelineEvent = ChunkEvent | PollEvent | WriteEvent | SpawnEvent + +interface SendWindow { + label: string + startedAt: number + settledAt: number | undefined + outcome: TerminalWaitReason | 'rejected' | 'pending' +} + +const SAMPLE_UNITS = 100 +const SHOWN_HEAD_CHUNKS = 2 +const SHOWN_TAIL_CHUNKS = 6 + +function escapeControl(text: string): string { + return text.replace(/[\\\x00-\x1f\x7f]/g, (char) => { + switch (char) { + case '\\': return '\\\\' + case '\n': return '\\n' + case '\r': return '\\r' + case '\x1b': return '\\e' + default: return `\\x${char.charCodeAt(0).toString(16).padStart(2, '0')}` + } + }) +} + +function sample(text: string): string { + if (text.length <= 2 * SAMPLE_UNITS) return escapeControl(text) + return `${escapeControl(text.slice(0, SAMPLE_UNITS))}…(+${text.length - 2 * SAMPLE_UNITS} units)…${escapeControl(text.slice(-SAMPLE_UNITS))}` +} + +const TAIL_UNITS = 24 + +/** Quote an already-escaped sample. */ +function quoted(escaped: string): string { + return `"${escaped}"` +} + +/** The session keeps at most one unit past the controlled prompt, so a short prefix names the tail. */ +function tail(text: string | undefined): string { + if (text === undefined) return '-' + if (text.length <= TAIL_UNITS) return quoted(escapeControl(text)) + return `"${escapeControl(text.slice(0, TAIL_UNITS))}…(+${text.length - TAIL_UNITS} units)"` +} + +/** + * The session's own prompt-tail rule replayed over one window's chunks: a marker starts an + * empty tail, later printable text extends it up to one unit past the controlled prompt, and + * the exact prompt text is the fast-path evidence. Mirrors `LocalPtySession.onData`. + */ +function replayPromptEvidence(chunks: ChunkEvent[]): { promptSeen: boolean; promptTail: string; promptTextSeen: boolean } { + let promptSeen = false + let promptTail = '' + for (const chunk of chunks) { + if (chunk.prompt) { + promptSeen = true + promptTail = '' + } + if (promptSeen && chunk.promptTail !== undefined) { + const remaining = Math.max(0, CONTROLLED_PROMPT.length + 1 - promptTail.length) + promptTail += chunk.promptTail.slice(0, remaining) + if (chunk.promptTail.length > remaining) promptTail = `${CONTROLLED_PROMPT}\0` + } + } + return { promptSeen, promptTail, promptTextSeen: promptTail === CONTROLLED_PROMPT } +} + +/** Records one Loader-composition case's terminal traffic and formats it per send. */ +export class ReadinessTimeline { + private readonly origin = performance.now() + private hostFactsText: string | undefined + + /** + * @param hostFacts - host, shell, and console facts for the header; evaluated once, on the + * first `format()` call, so a passing case never pays for a shell probe. + */ + constructor(private readonly hostFacts: () => string = () => '') {} + + private readonly events: TimelineEvent[] = [] + private readonly sends: SendWindow[] = [] + private readonly sanitizers = new WeakMap() + private sanitizerCount = 0 + private spawnCount = 0 + private currentLabel: string | undefined + private labelUses = 0 + + private now(): number { + return Math.round(performance.now() - this.origin) + } + + /** Spy on every sanitizer's `push` so each decoded chunk is recorded with its prompt verdict. */ + observeSanitizer(): void { + // oxlint-disable-next-line typescript/unbound-method -- the spy body re-binds it to each sanitizer through `call`. + const original = TerminalSanitizer.prototype.push + const record = (sanitizer: TerminalSanitizer, chunk: string, result: SanitizedChunk): void => { + this.recordChunk(sanitizer, chunk, result) + } + vi.spyOn(TerminalSanitizer.prototype, 'push').mockImplementation(function (this: TerminalSanitizer, chunk: string): SanitizedChunk { + const result = original.call(this, chunk) + record(this, chunk, result) + return result + }) + } + + private recordChunk(sanitizer: TerminalSanitizer, chunk: string, result: SanitizedChunk): void { + let session = this.sanitizers.get(sanitizer) + if (session === undefined) { + session = this.sanitizerCount + this.sanitizerCount += 1 + this.sanitizers.set(sanitizer, session) + } + this.events.push({ + kind: 'chunk', + at: this.now(), + session, + units: chunk.length, + sample: sample(chunk), + prompt: result.prompt, + promptTail: result.promptTail, + textLength: result.text.length, + }) + } + + /** + * Wrap one spawned terminal so its writes and foreground polls are recorded. + * @param handle - the provider handle the session will drive. + */ + observeTerminal(handle: SubprocessTerminalHandle): void { + const session = this.spawnCount + this.spawnCount += 1 + this.events.push({ kind: 'spawn', at: this.now(), session, pid: handle.pid }) + const inspectForeground = handle.inspectForeground.bind(handle) + vi.spyOn(handle, 'inspectForeground').mockImplementation(async (): Promise => { + const foreground = await inspectForeground() + this.events.push({ kind: 'poll', at: this.now(), session, foreground: foreground === undefined ? 'undefined' : JSON.stringify(foreground) }) + return foreground + }) + const write = handle.write.bind(handle) + vi.spyOn(handle, 'write').mockImplementation(async (data: string): Promise => { + this.events.push({ kind: 'write', at: this.now(), session, units: data.length }) + await write(data) + }) + } + + /** + * Name every send tracked until the next label; a second send under one label reads `label/2`. + * @param label - the tool call id the sends belong to. + */ + label(label: string): void { + this.currentLabel = label + this.labelUses = 0 + } + + /** + * Open a send window and close it when the operation settles or rejects. + * @param operation - the send returned by `terminals.startSend`. + */ + track(operation: TerminalSendOperation): void { + this.labelUses += 1 + const label = this.currentLabel === undefined + ? `send#${this.sends.length + 1}` + : this.labelUses === 1 ? this.currentLabel : `${this.currentLabel}/${this.labelUses}` + const window: SendWindow = { label, startedAt: this.now(), settledAt: undefined, outcome: 'pending' } + this.sends.push(window) + void operation.done.then( + (settled) => { + window.settledAt = this.now() + window.outcome = settled.waitReason + }, + () => { + window.settledAt = this.now() + window.outcome = 'rejected' + }, + ) + } + + /** + * Render every send window plus the traffic between windows (startup, restarts, late output). + * @returns Multi-line text bounded per window: every prompt-bearing chunk, the first and last + * chunks, poll totals by foreground value, and the replayed prompt evidence. + */ + format(): string { + this.hostFactsText ??= this.hostFacts() + const lines: string[] = [TIMELINE_HEADER, ...this.hostFactsText.length > 0 ? [this.hostFactsText] : []] + const now = this.now() + let cursor = 0 + const windows: { title: string; from: number; to: number; send: SendWindow | undefined }[] = [] + // A send window spans its start through its settle instant inclusive: the poll that + // settled a send is recorded in the same millisecond as the settle. + this.sends.forEach((send, index) => { + if (send.startedAt > cursor) { + windows.push({ title: index === 0 ? 'before the first send' : 'between sends', from: cursor, to: send.startedAt, send: undefined }) + } + const to = (send.settledAt ?? now) + 1 + windows.push({ title: `#${index + 1} ${send.label}`, from: send.startedAt, to, send }) + cursor = to + }) + windows.push({ title: 'after the last send', from: cursor, to: now + 1, send: undefined }) + for (const window of windows) { + const events = this.events.filter(event => event.at >= window.from && event.at < window.to) + if (window.send === undefined && events.length === 0) continue + lines.push(...this.formatWindow(window.title, window.from, window.send, events)) + } + return lines.join('\n') + } + + private formatWindow(title: string, from: number, send: SendWindow | undefined, events: TimelineEvent[]): string[] { + const lines: string[] = [] + const outcome = send === undefined + ? '' + : send.settledAt === undefined + ? ` → still pending after ${this.now() - send.startedAt} ms` + : ` → ${send.outcome} after ${send.settledAt - send.startedAt} ms` + lines.push(`${title} (from +${from} ms)${outcome}`) + for (const event of events) { + if (event.kind === 'spawn') lines.push(` +${event.at} spawn s${event.session} pid ${event.pid}`) + if (event.kind === 'write') lines.push(` +${event.at} write s${event.session} ${event.units} units`) + } + const polls = events.filter((event): event is PollEvent => event.kind === 'poll') + if (polls.length > 0) { + const byForeground = new Map() + for (const poll of polls) byForeground.set(poll.foreground, (byForeground.get(poll.foreground) ?? 0) + 1) + const summary = [...byForeground].map(([foreground, count]) => `${foreground} ×${count}`).join(', ') + lines.push(` polls ${polls.length} (+${polls[0]?.at}…+${polls[polls.length - 1]?.at}): ${summary}`) + } + const chunks = events.filter((event): event is ChunkEvent => event.kind === 'chunk') + if (chunks.length > 0) { + const markers = chunks.filter(chunk => chunk.prompt).length + lines.push(` chunks ${chunks.length} (${markers} with a prompt marker)`) + const shown = new Set() + chunks.slice(0, SHOWN_HEAD_CHUNKS).forEach(chunk => shown.add(chunk)) + chunks.slice(-SHOWN_TAIL_CHUNKS).forEach(chunk => shown.add(chunk)) + chunks.filter(chunk => chunk.prompt).forEach(chunk => shown.add(chunk)) + let elided = 0 + for (const chunk of chunks) { + if (!shown.has(chunk)) { + elided += 1 + continue + } + if (elided > 0) { + lines.push(` … ${elided} chunks elided …`) + elided = 0 + } + lines.push(` +${chunk.at} s${chunk.session} ${chunk.units}u text=${chunk.textLength} marker=${chunk.prompt ? 'YES' : 'no'} tail=${tail(chunk.promptTail)} ${quoted(chunk.sample)}`) + } + if (send !== undefined) { + // Readiness evidence resets at the send's input write; replay from the last write. + const lastWrite = events.filter(event => event.kind === 'write').at(-1) + const evidence = replayPromptEvidence(chunks.filter(chunk => lastWrite === undefined || chunk.at >= lastWrite.at)) + lines.push(` replayed evidence at settle: promptSeen=${evidence.promptSeen} promptTail=${tail(evidence.promptTail)} promptTextSeen=${evidence.promptTextSeen}`) + } + } + return lines + } +} From f0a7f798a379914e50d556b3dc0c4e3f34e52577 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:55:28 +0800 Subject: [PATCH 10/86] docs(notes): correct the pwsh lane record to the observed silence-bound settles The 2026-09-27 14:30Z master run carried the tail grace and took the same 20.8 s as the run before it, so the lane's mechanism is not a stalled prompt tail; the composition keeps the default and the notes record the open investigation, the native pwsh 7.6.6 evidence, the ConPTY repaint re-emission of the 133;D mark as the leading hypothesis, and issue #5297 for the Windows pid 0 side finding. --- .../2026-09-27-pwsh-prompt-tail-grace.i18n.yaml | 12 ++++++------ .../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md | 6 +++--- .../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md | 6 +++--- ...-27-observation-waits-on-observed-state.i18n.yaml | 8 ++++---- ...2026-09-27-observation-waits-on-observed-state.md | 6 ++++-- ...6-09-27-observation-waits-on-observed-state.zh.md | 6 ++++-- 6 files changed, 24 insertions(+), 20 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml index af95d9cdf7..1eda5f53ae 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml @@ -6,11 +6,11 @@ en: 5e8c5673a0669d9c zh: c8442a6186288a2a /agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/problem: - en: ac4fb08e3203cee9 - zh: 69fcfcb4f8fc0319 + en: 5c48d66d06c50a09 + zh: 893853d96c26d6e9 /agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/decision: - en: 653c74442decb65a - zh: 46f01c45d48b846b + en: a998f5c6abdd68eb + zh: a7cdd13e1e4de6c6 /agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/alternatives-considered: en: 21a9a19b76f42758 zh: 30a8f5425b59900d @@ -18,5 +18,5 @@ en: b58904921286a1db zh: a696c2954d86c04f /agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/testing: - en: 78253191eea198fa - zh: 32bdd6807ddc13a0 + en: c741ea002d239ae6 + zh: d68846526e63ce51 diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md index 0aa25795c1..e836ef8088 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md +++ b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md @@ -6,11 +6,11 @@ English | [中文](2026-09-27-pwsh-prompt-tail-grace.zh.md) ## Problem -Windows sessions on the self-hosted lane settled one or two of seven sends on the controlled prompt and charged the silence tier for the rest (2026-09-25/26, issue #2487). The exact readiness evidence is the controlled prompt's printable tail after the OSC `133;D` marker, and the bound on waiting for it was `idleSilenceMs + handoffGraceMs`. A native Windows probe shows the render arrives as two pty chunks: the marker alone, then the five-byte `dsh> ` 2-28 ms later (56 renders), with nothing printable after any prompt and no terminal queries in the output. The marker is written by the shell's own prompt function and the tail by the same render, so a tail that arrives past the bound means the host stalled that delivery, not that the prompt is absent. The session settled `inferred_idle` anyway, and each such send cost the tool call roughly three seconds. +Windows sessions on the self-hosted lane settled one or two of seven sends on the controlled prompt and charged the silence tier for the rest (2026-09-25/26, issue #2487). The exact readiness evidence is the controlled prompt's printable tail after the OSC `133;D` marker, and the bound on waiting for it was `idleSilenceMs + handoffGraceMs`. A native Windows probe shows the render arrives as two pty chunks: the marker alone, then the five-byte `dsh> ` 2-28 ms later (56 renders under Windows PowerShell 5.1, the same two-chunk render under pwsh 7.6.6), with nothing printable after any prompt and no terminal queries in the output. The marker is written by the shell's own prompt function and the tail by the same render, so a tail that arrives past the bound means the host stalled that delivery, not that the prompt is absent. The session settled `inferred_idle` anyway, and each such send cost the tool call roughly three seconds. The lane's own failures turned out not to be that state: the first master run carrying this field (run 36326153388, 2026-09-27 14:30Z) took the same 20.8 s as the run before it (36309006133), so its degraded sends settled at the plain bound with the tolerance inert; the [Windows-lane observation note](../testing/2026-09-27-observation-waits-on-observed-state.md) records that open investigation. ## Decision -`dsh-terminal-bash` gains a validated `promptTailGraceMs` configuration field, default `0`. While a send has seen the prompt marker and its printable tail has not arrived, the silence bound becomes `idleSilenceMs + handoffGraceMs + promptTailGraceMs`; every other bound, wait reason, and code path is unchanged. The [persistent PTY readiness design](../feature/2026-07-16-persistent-pty-sessions.md) owns the tier ladder this bound extends. Zero reproduces the previous bound exactly, so an upgrade changes no deployment's behaviour. The persistent pwsh Loader-composition case sets 5000 ms, because the hosts whose stalls the field is for are exactly the ones that run it, and its assertion (at least six `stdin_read` settlements, no `inferred_idle`) then measures the controlled-prompt path instead of the host's delivery timing. `validateConfig` accepts zero for this field — the one numeric bound whose zero is a documented value — and rejects negative or fractional values. +`dsh-terminal-bash` gains a validated `promptTailGraceMs` configuration field, default `0`. While a send has seen the prompt marker and its printable tail has not arrived, the silence bound becomes `idleSilenceMs + handoffGraceMs + promptTailGraceMs`; every other bound, wait reason, and code path is unchanged. The [persistent PTY readiness design](../feature/2026-07-16-persistent-pty-sessions.md) owns the tier ladder this bound extends. Zero reproduces the previous bound exactly, so an upgrade changes no deployment's behaviour. The persistent pwsh Loader-composition case keeps the default: the self-hosted lane's degraded sends settle at the plain bound with the tolerance present or absent, so a nonzero value there would only lengthen a never-arriving-tail fallback from 3.3 s to 8.3 s per send while reporting a genuinely late tail as a slow pass instead of a named failure. `validateConfig` accepts zero for this field — the one numeric bound whose zero is a documented value — and rejects negative or fractional values. ## Alternatives considered @@ -28,4 +28,4 @@ A deployment whose console renderer stalls keeps those sends on the exact path b ## Testing -`packages/terminal/terminal-bash/tests/session.spec.ts` pins every state with fake timers: with `promptTailGraceMs` set, a marker whose tail arrives after the plain bound stays pending and then settles `stdin_read`; a marker whose tail never arrives falls back to `inferred_idle` at the extended bound; a tail that arrived and was invalidated by later output keeps the plain bound; and with the field at zero the same delayed delivery settles `inferred_idle`. `packages/terminal/terminal-bash/tests/config.spec.ts` accepts zero and rejects negative or fractional values, and rejects a nonzero tolerance shorter than one `pollIntervalMs`. `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` carries the end-to-end evidence on native Windows: with every session's prompt tail withheld for four seconds the case passes with the configured tolerance — nine settles, eight `stdin_read` and the `exit` command's `session_exit`, in 33.4 s and 37.5 s — and fails with the tolerance patched to zero, where two sends settle `inferred_idle` with `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3312-3316 ms. +`packages/terminal/terminal-bash/tests/session.spec.ts` pins every state with fake timers: with `promptTailGraceMs` set, a marker whose tail arrives after the plain bound stays pending and then settles `stdin_read`; a marker whose tail never arrives falls back to `inferred_idle` at the extended bound; a tail that arrived and was invalidated by later output keeps the plain bound; and with the field at zero the same delayed delivery settles `inferred_idle`. `packages/terminal/terminal-bash/tests/config.spec.ts` accepts zero and rejects negative or fractional values, and rejects a nonzero tolerance shorter than one `pollIntervalMs`. The end-to-end evidence on native Windows came from a guest-patched copy of `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` (Windows PowerShell 5.1, 2026-09-27): with every session's prompt tail withheld for four seconds and the tolerance at 5000 ms the case passed — nine settles, eight `stdin_read` and the `exit` command's `session_exit`, in 33.4 s and 37.5 s — and with the tolerance at zero two sends settled `inferred_idle` with `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3312-3316 ms. The committed case runs with the default and prints a per-send readiness timeline on failure, so that state is named when it occurs rather than inferred from wall time. diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md index feb551d35f..3b3a57b67a 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md @@ -6,11 +6,11 @@ Status: implemented ## Problem -自托管 lane 上的 Windows 会话在七次 send 中只有一到两次按受控提示符结算,其余都要支付静默层(2026-09-25/26,issue #2487)。精确的就绪证据是 OSC `133;D` 标记之后受控提示符的可打印尾部,而等待它的上界是 `idleSilenceMs + handoffGraceMs`。原生 Windows 探测表明该渲染以两个 pty 分块到达:先是标记本身,2–28 毫秒后是 5 字节的 `dsh> `(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询。标记由 shell 自己的 prompt 函数写出、尾部由同一次渲染写出,因此尾部晚于上界到达意味着宿主机拖慢了这次投递,而不是提示符缺失。会话此前仍会按 `inferred_idle` 结算,每次这样的 send 让工具调用多付约三秒。 +自托管 lane 上的 Windows 会话在七次 send 中只有一到两次按受控提示符结算,其余都要支付静默层(2026-09-25/26,issue #2487)。精确的就绪证据是 OSC `133;D` 标记之后受控提示符的可打印尾部,而等待它的上界是 `idleSilenceMs + handoffGraceMs`。原生 Windows 探测表明该渲染以两个 pty 分块到达:先是标记本身,2–28 毫秒后是 5 字节的 `dsh> `(Windows PowerShell 5.1 下 56 次渲染,pwsh 7.6.6 下同样是两分块渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询。标记由 shell 自己的 prompt 函数写出、尾部由同一次渲染写出,因此尾部晚于上界到达意味着宿主机拖慢了这次投递,而不是提示符缺失。会话此前仍会按 `inferred_idle` 结算,每次这样的 send 让工具调用多付约三秒。该 lane 自身的失败后来被证明不是这种状态:首个带着该字段的 master run(run 36326153388,2026-09-27 14:30Z)与其前一个 run(36309006133)同样耗时 20.8 秒,因此其退化的 send 是在宽限不起作用的情况下按普通上界结算的;[Windows lane 观测笔记](../testing/2026-09-27-observation-waits-on-observed-state.zh.md)记录了这项仍未关闭的调查。 ## Decision -`dsh-terminal-bash` 新增一个经过校验的 `promptTailGraceMs` 配置字段,默认 `0`。当某次 send 已看到提示符标记而其可打印尾部尚未到达时,静默上界变为 `idleSilenceMs + handoffGraceMs + promptTailGraceMs`;其他上界、等待原因与代码路径均不变。该上界所扩展的层级阶梯由[持久 PTY 就绪设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)拥有。零精确复现此前的上界,因此升级不改变任何部署的行为。持久 pwsh 的 Loader 组合用例设置 5000 毫秒,因为需要该字段的宿主机正是运行该用例的那些,而其断言(至少六次 `stdin_read` 结算、不含 `inferred_idle`)由此度量受控提示符路径,而不是宿主机的投递时序。`validateConfig` 允许该字段取零——这是唯一一个零为文档化取值的数值上界——并拒绝负数与小数。 +`dsh-terminal-bash` 新增一个经过校验的 `promptTailGraceMs` 配置字段,默认 `0`。当某次 send 已看到提示符标记而其可打印尾部尚未到达时,静默上界变为 `idleSilenceMs + handoffGraceMs + promptTailGraceMs`;其他上界、等待原因与代码路径均不变。该上界所扩展的层级阶梯由[持久 PTY 就绪设计](../feature/2026-07-16-persistent-pty-sessions.zh.md)拥有。零精确复现此前的上界,因此升级不改变任何部署的行为。持久 pwsh 的 Loader 组合用例保持默认值:自托管 lane 上退化的 send 无论有没有该宽限都按普通上界结算,因此在那里设置非零值只会把尾部永不到达时的回退从每次 send 3.3 秒拉长到 8.3 秒,并把真正迟到的尾部报告成一次缓慢的通过,而不是一次点名状态的失败。`validateConfig` 允许该字段取零——这是唯一一个零为文档化取值的数值上界——并拒绝负数与小数。 ## Alternatives considered @@ -28,4 +28,4 @@ Status: implemented ## Testing -`packages/terminal/terminal-bash/tests/session.spec.ts` 用假定时器固定每一种状态:设置 `promptTailGraceMs` 时,尾部晚于普通上界到达的标记保持挂起,随后按 `stdin_read` 结算;尾部始终不到时在扩展上界处回落到 `inferred_idle`;尾部已到达但被后续输出失效时保持普通上界;字段为零时,同样的延迟投递按 `inferred_idle` 结算。`packages/terminal/terminal-bash/tests/config.spec.ts` 接受零、拒绝负数或小数,并拒绝非零但短于一个 `pollIntervalMs` 的宽限。`packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 承载原生 Windows 上的端到端证据:把每个 session 的提示符尾部扣留四秒时,配置了该宽限后用例通过——九次结算、八次 `stdin_read` 加 `exit` 命令的 `session_exit`,耗时 33.4 秒与 37.5 秒;把该宽限改为零后同一停摆失败,其中两次 send 以 `inferred_idle` 结算,`promptSeen` 为 true、`promptTextSeen` 为 false、尾部为空且 `idleFor` 为 3312–3316 毫秒。 +`packages/terminal/terminal-bash/tests/session.spec.ts` 用假定时器固定每一种状态:设置 `promptTailGraceMs` 时,尾部晚于普通上界到达的标记保持挂起,随后按 `stdin_read` 结算;尾部始终不到时在扩展上界处回落到 `inferred_idle`;尾部已到达但被后续输出失效时保持普通上界;字段为零时,同样的延迟投递按 `inferred_idle` 结算。`packages/terminal/terminal-bash/tests/config.spec.ts` 接受零、拒绝负数或小数,并拒绝非零但短于一个 `pollIntervalMs` 的宽限。原生 Windows 上的端到端证据来自 guest 内打过补丁的 `packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts` 副本(Windows PowerShell 5.1,2026-09-27):把每个 session 的提示符尾部扣留四秒且宽限设为 5000 毫秒时用例通过——九次结算、八次 `stdin_read` 加 `exit` 命令的 `session_exit`,耗时 33.4 秒与 37.5 秒;宽限为零时两次 send 以 `inferred_idle` 结算,`promptSeen` 为 true、`promptTextSeen` 为 false、尾部为空且 `idleFor` 为 3312–3316 毫秒。提交的用例以默认值运行,并在失败时打印逐 send 的就绪时间线,因此该状态一旦出现就会被点名,而不是从墙钟时间推断。 diff --git a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml index 3e4c2f6895..7fbdbcbcd2 100644 --- a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml @@ -9,8 +9,8 @@ en: 270b015afc8de8ac zh: 65aeb1e270d19a27 /agent-note-windows-lane-observations-wait-on-the-state-they-read/decision: - en: 479cb25421102304 - zh: 9e23396728001e2c + en: 55a0bcab15f38620 + zh: 677545442a19c7dc /agent-note-windows-lane-observations-wait-on-the-state-they-read/alternatives-considered: en: 00e59c561085e081 zh: 4f6ac4dfc3259f58 @@ -18,5 +18,5 @@ en: 13db32f9da19d87b zh: 48081fc7035dea45 /agent-note-windows-lane-observations-wait-on-the-state-they-read/deferred: - en: 18dc81f98375fb04 - zh: e30413cd3d201b80 + en: e1c0bb4ec03b36a8 + zh: 6b1c2166c53f4e03 diff --git a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md index e0218a4d5f..b4a43d1509 100644 --- a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md +++ b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md @@ -16,7 +16,7 @@ Two self-hosted Windows lane cases observed state through a wall-clock window in `observingChild` stays in flight until the test has read the run record, so the read cannot race the run's end, and its bounded wait starts at the mocked launcher's hand-over instead of at test start: the recorded run is one atomic write that follows the launch, so the bound only has to cover that write. `RECORD_WAIT_MS` (2 s) is deliberately below the case budget, so a record that never appears reports as the assertion that names it rather than as a runner timeout. Both the repair-install case and the single-run case hand the child over through the same `spawned` hook. -The loader-composition settle-reason assertion carries `JSON.stringify(settleReasons)` as its failure message. The threshold and the assertions are unchanged. +The loader-composition settle-reason assertions carry the recorded reasons and a per-send readiness timeline in their failure message: every decoded pty chunk with the sanitizer's marker verdict and prompt tail, every foreground poll result, every input write, each send's settle reason and elapsed time, the session's prompt evidence replayed from those chunks, and the host, shell, PSReadLine, and console-host versions. `onTestFailed` prints the same timeline when the runner's budget ends the case before the assertions. The threshold and the assertions are unchanged. ## Alternatives considered @@ -32,4 +32,6 @@ Both plugin-manager record cases now observe the record while the run is in flig ## Deferred -The loader-composition failure of 2026-09-25/26 is an environment-sensitive readiness window, not a product logic error, and the product now owns a documented tolerance for it: the [prompt tail grace decision](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md). On Windows `isStdinWaiting` returns false and `foregroundPgid` returns the shell pid, so `stdin_read` can only come from the controlled-prompt tail. A native Windows probe (Windows 11 ARM64 guest, 8 vCPU, with the readiness probe enabled) shows the fast path is deterministic when delivery is normal: every prompt render is the OSC marker alone in one pty chunk followed by a five-byte `dsh> ` chunk 2-28 ms later (56 renders), nothing printable follows any prompt, the output carries no terminal queries, and 21 runs (baseline, six CPU burners, emulated x64, four concurrent pty-heavy instances, and Windows PowerShell 5.1 as the shell) settled all seven sends on the prompt, six `stdin_read` plus the `exit` command's `session_exit`. Withholding each session's prompt tail for four seconds reproduces the reported failure exactly: `expected 4 to be greater than or equal to 6` with `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`, the degraded settles carrying `promptSeen` true, `promptTextSeen` false, an empty tail and `idleFor` 3301/3313 ms (exactly the configured bound), 22.1/21.9 s against 17.5/20.9 s in CI, and every output assertion still passing. The CI trigger itself was not reproduced locally, so this case keeps its assertion and supplies `promptTailGraceMs` in its composition: it measures the controlled-prompt path rather than the host's delivery timing, and it still fails when no prompt marker appears at all. +The loader-composition failure on the self-hosted Windows lane (2026-09-25..27, issue #2487) remains open, and its mechanism is not a stalled prompt tail. The master run before the [prompt tail grace](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md) landed (run 36309006133, 2026-09-27 09:19Z) and the first run carrying it with `promptTailGraceMs: 5000` in this composition (run 36326153388, 14:30Z, reasons `["stdin_read","inferred_idle","inferred_idle","inferred_idle","inferred_idle","session_exit","inferred_idle"]`) both took 20.8 s, so the degraded sends settled at the plain `idleSilenceMs + handoffGraceMs` bound: either no marker was seen, or a marker was seen and its tail was invalidated by later printable text — the two states the tolerance does not cover. The foreground comparison is not a candidate on Windows: `WindowsProcessInspector.foregroundPgid` returns the shell pid unconditionally and `isStdinWaiting` returns false, so `shellPgid` always matches and `stdin_read` can only come from the controlled-prompt tail. The composition therefore keeps the field at its product default of `0`, and the failure message's timeline names the state directly the next time the lane fails; the 2026-09-27 12:51Z run (36320394583) instead hit the 120 s case budget before any assertion, which the same print covers. + +Native evidence with pwsh 7.6.6 and PSReadLine 2.4.5 on Windows 11 25H2 (conhost 10.0.26100.1; the earlier probes had run under Windows PowerShell 5.1 because the guest carries no pwsh 7): the case settles every send on the prompt at baseline, under eight CPU burners plus disk churn, under emulated x64 Node, under `vitest run --coverage --maxWorkers=1`, and with a 12-row viewport that scrolls from the second command, each prompt arriving as one in-order marker chunk followed by the five-byte tail. With an 8-row, 60-column viewport ConPTY re-emits the stored `133;D` mark inside repaint frames — five markers in one send, one followed by repainted row text (`\x07\e[Hdsh> Write-Output …`) — which leaves a tail that is not a prefix of the controlled prompt; on that console host the real prompt's marker still arrived last and the send settled `stdin_read`. That repaint path is the leading hypothesis for the lane, whose Windows build and console-host version are unknown (the job logs carry neither and the pool has no dispatchable job); the failure message records `os.release()`, `os.version()`, the pwsh and PSReadLine versions, and `conhost.exe`'s file version, and `dsh-subprocess-local` spawns node-pty without `useConptyDll`, so the system console host is the one in play. The pid `0` those probes also exposed on Windows is a separate defect, tracked by issue #5297. diff --git a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md index 079ae65451..809286cdbd 100644 --- a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md +++ b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md @@ -16,7 +16,7 @@ Status: implemented `observingChild` 会保持该 run 在进行中,直到测试读到 run 记录,因此读取不可能与 run 结束竞争;其有界等待从被 mock 的启动器交接处开始,而不是从用例开始处开始:被记录的 run 只是紧随启动之后的一次原子写入,因此等待上界只需覆盖这次写入。`RECORD_WAIT_MS`(2 秒)刻意低于用例预算,于是一条始终没有出现的记录会以指名它的断言报告,而不是以 runner 超时报告。修复安装用例与单 run 用例都通过同一个 `spawned` 钩子交接子进程。 -loader-composition 的结算层级断言把 `JSON.stringify(settleReasons)` 作为失败信息。阈值与断言本身不变。 +loader-composition 的结算层级断言把记录到的结算原因与一份逐 send 的就绪时间线放进失败信息:每个解码后的 pty 分块及 sanitizer 对它的标记判定与提示符尾部、每次前台轮询的结果、每次输入写入、每次 send 的结算原因与耗时、由这些分块回放出的会话提示符证据,以及宿主机、shell、PSReadLine 与控制台宿主的版本。当 runner 预算在断言之前结束用例时,`onTestFailed` 打印同一份时间线。阈值与断言本身不变。 ## Alternatives considered @@ -32,4 +32,6 @@ loader-composition 的结算层级断言把 `JSON.stringify(settleReasons)` 作 ## Deferred -loader-composition 于 2026-09-25/26 的失败是一个对投递时序敏感的就绪窗口,而不是 product 逻辑错误;product 现在为它拥有一个文档化的容忍度:[提示符尾部宽限决策](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)。在 Windows 上 `isStdinWaiting` 返回 false、`foregroundPgid` 返回 shell 自身的 pid,因此 `stdin_read` 只能来自受控提示符尾部。原生 Windows 探测(Windows 11 ARM64 guest,8 vCPU,启用就绪探测)表明只要投递正常,快路径就是确定的:每次提示符渲染都是 OSC 标记独占一个 pty 分块,紧随其后的下一个分块是 5 字节的 `dsh> `,间隔 2–28 毫秒(56 次渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询;21 次运行(基线、6 个 CPU burner、模拟 x64、4 个并发 pty 实例、以及以 Windows PowerShell 5.1 作为 shell)全部七次 send 都按提示符结算——六次 `stdin_read` 加 `exit` 命令的 `session_exit`。把每个 session 的提示符尾部扣留 4 秒可以精确复现上报的失败:`expected 4 to be greater than or equal to 6`,`settleReasons` 为 `["inferred_idle","stdin_read","stdin_read","stdin_read","stdin_read","session_exit","inferred_idle"]`,退化的结算带着 `promptSeen` 为 true、`promptTextSeen` 为 false、空尾部与 `idleFor` 3301/3313 毫秒(正是配置的上界),耗时 22.1/21.9 秒对照 CI 的 17.5/20.9 秒,且所有输出断言仍然通过。CI 的触发条件本身未能在本地复现,因此该用例保留其断言并在组合中提供 `promptTailGraceMs`:它度量受控提示符路径而不是宿主机的投递时序,且在完全没有提示符标记时仍然失败。 +自托管 Windows lane 上 loader-composition 的失败(2026-09-25..27,issue #2487)仍未关闭,其机制不是提示符尾部停摆。[提示符尾部宽限](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)合入前的 master run(run 36309006133,2026-09-27 09:19Z)与首个带着该字段、且本组合设置了 `promptTailGraceMs: 5000` 的 run(run 36326153388,14:30Z,结算原因为 `["stdin_read","inferred_idle","inferred_idle","inferred_idle","inferred_idle","session_exit","inferred_idle"]`)都耗时 20.8 秒,因此退化的 send 是按普通的 `idleSilenceMs + handoffGraceMs` 上界结算的:要么根本没看到标记,要么看到了标记但其尾部被随后的可打印文本失效——这正是该宽限不覆盖的两种状态。前台比较在 Windows 上不是候选:`WindowsProcessInspector.foregroundPgid` 无条件返回 shell pid、`isStdinWaiting` 返回 false,因此 `shellPgid` 总是匹配,`stdin_read` 只能来自受控提示符尾部。于是该组合把字段保持在 product 默认值 `0`,下一次该 lane 失败时,失败信息里的时间线会直接点名所处的状态;2026-09-27 12:51Z 的 run(36320394583)则是在任何断言之前就撞上 120 秒的用例预算,同一份打印也覆盖这条路径。 + +在 Windows 11 25H2(conhost 10.0.26100.1)上以 pwsh 7.6.6 与 PSReadLine 2.4.5 取得的原生证据(此前的探测因 guest 没有 pwsh 7 而运行在 Windows PowerShell 5.1 之下):基线、8 个 CPU burner 加磁盘反复读写、模拟 x64 Node、`vitest run --coverage --maxWorkers=1`、以及从第二条命令起就滚动的 12 行视口下,用例的每次 send 都按提示符结算,每个提示符都是一个按序到达的标记分块紧跟 5 字节尾部。在 8 行 60 列的视口下,ConPTY 会在重绘帧内重新发出已存储的 `133;D` 标记——一次 send 内出现五个标记,其中一个后面紧跟重绘的行文本(`\x07\e[Hdsh> Write-Output …`)——这使尾部不再是受控提示符的前缀;在该控制台宿主上,真正提示符的标记仍然最后到达,因此该 send 仍按 `stdin_read` 结算。这条重绘路径是该 lane 的首要假设,而该 lane 的 Windows 版本与控制台宿主版本未知(job 日志两者都没有,该 pool 也没有可手动触发的 job);失败信息会记录 `os.release()`、`os.version()`、pwsh 与 PSReadLine 版本以及 `conhost.exe` 的文件版本,而 `dsh-subprocess-local` 启动 node-pty 时未设置 `useConptyDll`,因此起作用的是系统控制台宿主。这些探测同时暴露的 Windows 上 pid 为 `0` 的问题是另一个缺陷,由 issue #5297 跟踪。 From b01a926d44b9c07682bcd3d9af40d8a1a96e7316 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 28 Sep 2026 00:58:56 +0800 Subject: [PATCH 11/86] test(lint): bound every process-spawning oxlint contract case at 90s Five cases in scripts/oxlint-contract.spec.ts spawned oxlint or the repository lint entrypoint on vitest's 5000 ms default while their nine siblings carried the 90_000 ceiling from the Windows spawn-budget alignment. The first darwin parity run on master (Sandbox run 36332053320) ran the file 2.2x slower than the passing run and timed out `prints only the final diagnostics when a fix retry still fails` at 6309 ms. Give the five cases the same per-case ceiling and record the decision in an Agent Note. Fixes #2581 Related to #2677 --- ...lint-contract-spawn-case-budgets.i18n.yaml | 19 ++++++++++++++ ...9-27-oxlint-contract-spawn-case-budgets.md | 25 +++++++++++++++++++ ...7-oxlint-contract-spawn-case-budgets.zh.md | 25 +++++++++++++++++++ scripts/oxlint-contract.spec.ts | 10 ++++---- 4 files changed, 74 insertions(+), 5 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md create mode 100644 .agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.zh.md diff --git a/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.i18n.yaml new file mode 100644 index 0000000000..2e72aa6761 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-27-oxlint-contract-spawn-case-budgets.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md +/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling: + en: 152f1e58efbf506e + zh: 889504ebfe5783ca +/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/problem: + en: d167d5d7da3b2be4 + zh: d225bfab0008a1a5 +/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/decision: + en: 34bd113b8c9228fb + zh: 99843bf33b703b5e +/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/alternatives-considered: + en: c929415601419c48 + zh: 31a62e9dd453a183 +/agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/consequences: + en: 5e7259ba9be2edd4 + zh: 12664440768eed2f diff --git a/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md new file mode 100644 index 0000000000..4bfd4b61d3 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md @@ -0,0 +1,25 @@ +# Agent Note: Oxlint contract cases that spawn a process carry a 90 s ceiling + +Status: implemented + +English | [中文](2026-09-27-oxlint-contract-spawn-case-budgets.zh.md) + +## Problem + +Twelve of the fourteen cases in [`scripts/oxlint-contract.spec.ts`](../../../../scripts/oxlint-contract.spec.ts) spawn a real child process: `runOxlint` starts the oxlint CLI, and `runRepositoryOxlint` boots [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) under tsx, which runs oxlint and, after `--fix`, runs it once more. The root `vitest.config.ts` sets no `testTimeout`, so a case without its own budget inherits Vitest's 5000 ms default; only the coverage lanes raise it through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`, and the `unit tests (darwin parity, macos-latest)` job in `sandbox.yml` runs `pnpm run test` plain. Nine cases carried an explicit `90_000` since the contended Windows spawn budgets (#3115) were aligned to that value; five ran on the default. Two runner classes exposed it: the self-hosted Windows pool, where process creation spikes to several seconds (#2581), and the hosted macos-latest runner of Sandbox run 36332053320, which ran the whole file 2.2× slower than the passing run 36320394290 and timed out `prints only the final diagnostics when a fix retry still fails` at 6309 ms, a case that took 1122 ms on the passing run. `Test timed out in 5000ms` names neither a contract violation nor the spawn that overran. + +## Decision + +Every case in the file that spawns oxlint or the repository lint entrypoint carries the same explicit per-case `90_000` ceiling. The two configuration-only cases, `keeps the complete stylistic contract in Oxlint` and `keeps repository lint workflows Oxlint-only`, spawn nothing and keep the default. The value is a completion ceiling for a hang, not a latency target: the slowest case on the slow darwin runner took 9507 ms, and the file under six-fold CPU oversubscription on a 12-core host peaked at 20767 ms, both under a quarter of the ceiling. Assertions, the absence of retries, and Vitest's global `testTimeout` are unchanged. + +## Alternatives considered + +**A `describe`-level `{ timeout: 90_000 }`.** Rejected: a `describe` value overrides the lane's `--testTimeout` instead of yielding to it (#2677), and the file would then carry two budget styles, per-case on the nine cases already bounded and suite-level on the rest. Per-case values keep one style and one place to read each case's ceiling. + +**Raising Vitest's global `testTimeout`.** Rejected: the whole unit inventory runs on the default; widening it to absorb one file's process spawns removes the hang detection the default provides everywhere else. + +**A tighter value sized to the observed 6.3 s.** Rejected: the Windows contention fires as multi-second spikes that rotated across cases under 15–30 s budgets before the 90 s alignment; a smaller number moves the flake rather than removing it. + +## Consequences + +A contract violation still fails through its own assertion well inside the ceiling; each case's assertions settle in 0.5–1.8 s on an idle host, so only a genuine hang waits the full 90 s. The Windows self-hosted lane, the Linux coverage lanes, and the darwin parity job read the same ceiling for this file regardless of `DSH_COVERAGE_TEST_TIMEOUT_MS`. Under 72 busy-loop processes on a 12-core host the unbounded fix-retry case reproduced `Test timed out in 5000ms` at 7084 ms before the change and passes with the ceiling after it. diff --git a/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.zh.md new file mode 100644 index 0000000000..4378df35f2 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 会 spawn 进程的 Oxlint 契约用例携带 90 s 上限 + +Status: implemented + +[English](2026-09-27-oxlint-contract-spawn-case-budgets.md) | 中文 + +## 问题 + +[`scripts/oxlint-contract.spec.ts`](../../../../scripts/oxlint-contract.spec.ts) 的 14 个用例中有 12 个会启动真实子进程:`runOxlint` 启动 oxlint CLI,`runRepositoryOxlint` 在 tsx 下引导 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts),后者运行 oxlint,并在 `--fix` 之后再运行一次。根 `vitest.config.ts` 没有设置 `testTimeout`,因此没有自带预算的用例继承 Vitest 的 5000 ms 默认值;只有 coverage 通道通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 抬高它,而 `sandbox.yml` 中的 `unit tests (darwin parity, macos-latest)` job 直接运行 `pnpm run test`。自 #3115 把有争抢的 Windows spawn 预算对齐到该值起,9 个用例携带显式的 `90_000`;5 个用例跑在默认值上。两类 runner 暴露了这一点:自托管 Windows 池,其进程创建会出现数秒尖峰(#2581);以及 Sandbox run 36332053320 的托管 macos-latest runner,它跑整个文件比通过的 run 36320394290 慢 2.2 倍,并让 `prints only the final diagnostics when a fix retry still fails` 在 6309 ms 时超时,而该用例在通过的 run 中耗时 1122 ms。`Test timed out in 5000ms` 既不指出契约违规,也不指出超时的那次 spawn。 + +## 决策 + +文件中每个会 spawn oxlint 或仓库 lint 入口的用例都携带同一个显式的用例级 `90_000` 上限。两个只检查配置的用例 `keeps the complete stylistic contract in Oxlint` 与 `keeps repository lint workflows Oxlint-only` 不 spawn 任何进程,保持默认值。该值是针对挂起的完成上限,不是延迟目标:慢 darwin runner 上最慢的用例耗时 9507 ms,12 核宿主机上六倍 CPU 超订下整个文件的峰值为 20767 ms,两者都不到上限的四分之一。断言、不加重试、以及 Vitest 全局 `testTimeout` 均不变。 + +## 曾考虑的替代方案 + +**`describe` 级 `{ timeout: 90_000 }`。** 拒绝:`describe` 级取值覆盖通道的 `--testTimeout` 而不是让位于它(#2677),而且文件会同时带两种预算风格,已有边界的 9 个用例是用例级,其余是套件级。用例级取值保持一种风格,每个用例的上限只在一处可读。 + +**抬高 Vitest 全局 `testTimeout`。** 拒绝:整个单元测试清单都跑在默认值上;为吸收一个文件的进程 spawn 而放宽它,会移除默认值在其他所有地方提供的挂起检测。 + +**按观测到的 6.3 s 收紧取值。** 拒绝:Windows 争抢表现为数秒尖峰,在对齐到 90 s 之前曾在 15–30 s 预算下于各用例间轮转出现;更小的数字只是移动 flake 而不是消除它。 + +## 后果 + +契约违规仍通过其自身断言在上限之内早早失败;空载宿主机上每个用例的断言在 0.5–1.8 s 内结算,因此只有真正的挂起才会等满 90 s。Windows 自托管通道、Linux coverage 通道与 darwin parity job 对本文件读到同一个上限,与 `DSH_COVERAGE_TEST_TIMEOUT_MS` 无关。在 12 核宿主机上以 72 个忙循环进程加载时,原本无边界的 fix-retry 用例在改动前于 7084 ms 复现 `Test timed out in 5000ms`,改动后在上限内通过。 diff --git a/scripts/oxlint-contract.spec.ts b/scripts/oxlint-contract.spec.ts index 0325cc8a05..549405a617 100644 --- a/scripts/oxlint-contract.spec.ts +++ b/scripts/oxlint-contract.spec.ts @@ -205,7 +205,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + expect(result.error).toBeUndefined() expect(result.status, normalizedOutput(result)).toBe(0) - }) + }, 90_000) it('keeps repository lint workflows Oxlint-only', async () => { const packageJson: unknown = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8')) @@ -356,7 +356,7 @@ export function unrelatedRead(): void { expect(result.error).toBeUndefined() expect(result.status, normalizedOutput(result)).toBe(0) - }) + }, 90_000) it('keeps staged validation project-free while preserving source rules', async () => { const configPath = join(repositoryRoot, '.oxlintrc.staged.json') @@ -392,7 +392,7 @@ export function unrelatedRead(): void { } finally { await rm(path, { force: true }) } - }) + }, 90_000) it('preserves successful fix output channels', async () => { const suffix = randomUUID() @@ -416,7 +416,7 @@ export function unrelatedRead(): void { } finally { await rm(path, { force: true }) } - }) + }, 90_000) it('prints only the final diagnostics when a fix retry still fails', async () => { const suffix = randomUUID() @@ -440,7 +440,7 @@ export function unrelatedRead(): void { } finally { await rm(path, { force: true }) } - }) + }, 90_000) it.each(['--fix', '--fix-suggestions', '--fix-dangerously'])( 'converges overlapping staged stylistic fixes through Oxlint under %s', From d7b5b422318910d58df90ba71b91f314f0b4a566 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:12:46 +0800 Subject: [PATCH 12/86] test(web): assert the overlay focus and record the measured probe counts Addresses ds-review-bot: the keyboard step now asserts that focus landed on the sheet overlay before pressing arrows, the comment keeps only the local why, and the Agent Note carries the measured counts (10 of 11 master runs, 7 of 38 probe runs, 26 of 26 keyboard runs, 10 of 10 local repetitions). --- ...selection-by-keyboard-before-reflow.i18n.yaml | 16 ++++++++-------- ...dsheet-selection-by-keyboard-before-reflow.md | 10 +++++----- ...eet-selection-by-keyboard-before-reflow.zh.md | 10 +++++----- apps/web/tests/document-preview.e2e.ts | 10 +++++----- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.i18n.yaml index e88c1db82b..2297d06fee 100644 --- a/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.i18n.yaml @@ -6,17 +6,17 @@ en: 015053d7e4cc8b74 zh: 8afd22975bcb9814 /agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/problem: - en: 73050d2c7d3bceeb - zh: 68f45fbaa8df5786 + en: 27ce4e01ca063f29 + zh: cd8c872750c725bc /agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/decision: - en: 05033995f6accde4 - zh: 1de1372d99a4dc4b + en: 49b7f008e97f05cb + zh: 00571e867ba82057 /agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/alternatives-considered: - en: 6085e5faea6ec83f - zh: f5a790200d7787a9 + en: 51c1315b1d424915 + zh: 2f2f537cfdde2469 /agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/verification: - en: 4b57417a68d13275 - zh: e0bbbf54e449eb0c + en: 316dbc106810d68f + zh: 8771fd22b885e4ab /agent-note-web-lane-moves-spreadsheet-selections-by-keyboard-before-reflow-assertions/consequences: en: 03eedcc3cfdad180 zh: 8d4843e01448fe83 diff --git a/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md index b4d07b3f9b..73b3f9e219 100644 --- a/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md +++ b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.md @@ -6,13 +6,13 @@ English | [中文](2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh ## Problem -[document-preview.e2e.ts](../../../../apps/web/tests/document-preview.e2e.ts) opens `meeting.xlsx`, clicks the sheet overlay at `{ x: 60, y: 40 }` to select `A1`, then widens and narrows the sidebar four times and asserts after each reflow that the name box still reads `A1`. Every `CI master` run of the serial self-hosted Linux lane between 2026-09-24 and 2026-09-27 failed one or both locale cases of that scenario with `expected 'B1' to be 'A1'` at the first reflow, and hosted `node 24 / snapshots and artifacts` runs failed the same way (runs 36110572677 and 36213089675). The failure is not locale-bound: en-US failed alone in four master runs, both locales in three, zh-CN alone in three. Polling the read (#5243) changed nothing because `B1` is the committed selection, not a stale sample. +[document-preview.e2e.ts](../../../../apps/web/tests/document-preview.e2e.ts) opens `meeting.xlsx`, clicks the sheet overlay at `{ x: 60, y: 40 }` to select `A1`, then widens and narrows the sidebar four times and asserts after each reflow that the name box still reads `A1`. Ten of the eleven `CI master` runs of the serial self-hosted Linux lane between 2026-09-24 and 2026-09-27 failed one or both locale cases of that scenario with `expected 'B1' to be 'A1'` at the first reflow, and hosted `node 24 / snapshots and artifacts` runs failed the same way (runs 36110572677 and 36213089675). The failure is not locale-bound: en-US failed alone in four master runs, both locales in three, zh-CN alone in three. Polling the read (#5243) changed nothing because `B1` is the committed selection, not a stale sample. `A1` is the workbook's initial selection, so the polls that follow the click are satisfied before the click commits and never verify what the click selected. FortuneSheet's `SheetOverlay` handles the press as `setContext(draft => handleCellAreaMouseDown(draft, globalCache, nativeEvent, cellInput, cellArea, …))`, and `handleCellAreaMouseDown` resolves the cell from `cellArea.getBoundingClientRect()` and the event's page coordinates at the moment the updater runs. React runs `useState` updaters during render and runs them again when the hook queue holds a lower-priority update ahead of them; the freshly mounted workbook still has such updates pending, and Playwright precedes the press with a `mousemove` whose FortuneSheet handler is a continuous-priority update on the same state. An instrumented verbatim copy of the scenario on the Blacksmith runner recorded four executions of `handleCellAreaMouseDown` for the one click: two before the click returned reading `left=969.5`, and two after the scenario had already set the panel to `1000px`, reading `left=725.5`. The stored pointer x of 985 then resolves to 259.5 inside the grid, and because the meeting sheet has two columns `colLocation` clamps it to the last one: `B1`. On a machine that commits the workbook's initial selection normalization before the click, the same point lies on A1's own selection box, whose `onMouseDown` stops propagation; the click then selects nothing, no geometry-bound updater is queued, and the scenario passes without having tested the click. ## Decision -The meeting-sheet step selects by keyboard. The scenario focuses the sheet overlay, presses `ArrowRight` and asserts `B1` with an empty formula bar, then presses `ArrowLeft` and asserts `A1` with `会议纪要`, and only then starts the reflow loop. `handleArrowKey` → `moveHighlightCell` computes the target from committed state — the current selection and `visibledatacolumn` — so a re-executed updater yields the same cell whatever the grid's geometry is by then, and the two moves make the interaction observable because `B1` differs from the initial selection. The reflow loop, its `A1` assertion, the sheet-tab scroll-control counts, and the canvas identity check are unchanged. +The meeting-sheet step selects by keyboard. The scenario focuses the sheet overlay (`tabindex="-1"`), asserts the focus landed, presses `ArrowRight` and asserts `B1` with an empty formula bar, then presses `ArrowLeft` and asserts `A1` with `会议纪要`, and only then starts the reflow loop. `handleArrowKey` → `moveHighlightCell` computes the target from committed state — the current selection and `visibledatacolumn` — so a re-executed updater yields the same cell whatever the grid's geometry is by then, and the two moves make the interaction observable because `B1` differs from the initial selection. The reflow loop, its `A1` assertion, the sheet-tab scroll-control counts, and the canvas identity check are unchanged. The budget-sheet click at the start of the scenario stays a pointer click: it targets a cell other than the initial selection, its result is verified by the `46281` formula read, and keyboard, clipboard, and layout reads separate it from the first reflow by far more than one deferred render. The `values.csv` click followed by a narrowing stays too: a re-execution against the narrowed grid resolves a negative x, which `colLocation` clamps to column A, the same cell. @@ -24,13 +24,13 @@ The budget-sheet click at the start of the scenario stays a pointer click: it ta **Wait for the renderer to go idle before the first reflow.** No product state names "React has no re-execution pending"; an idle or fixed wait sized to the runner is the flake-masking wait [dsh-ci-test-reliability](../../../skills/dsh-ci-test-reliability/SKILL.md) rejects. -**Patch FortuneSheet to resolve the cell outside the updater.** `handleCellAreaMouseDown` lives in `@fortune-sheet/core`; snapshotting the rectangle before `setContext` is a dependency patch for a window a user cannot reach — a pointer press and a sidebar reflow inside one deferred render. It stays an upstream concern; the product change is out of this test fix. +**Extend the `@fortune-sheet/react` patch to resolve the cell outside the updater.** `handleCellAreaMouseDown` lives in `@fortune-sheet/core`, and the window a user would need to hit is a pointer press and a sidebar reflow inside one deferred render. It stays an upstream concern; the product change is out of this test fix. ## Verification -The instrumented verbatim copy of the shipped scenario, run on the hosted Linux lane of draft PR #5291 with `DEBUG=pw:api` and a `getBoundingClientRect` hook on the cell area, reproduced `expected 'B1' to be 'A1'` at the first reflow in 3 of 12 runs across two lane runs, each with the four-execution signature above and with `mousedown` targeting `#luckysheet-sheettable_0` rather than the drag handle. The keyboard-driven copy passed 6 of 6 in the lane run that reproduced the click failure. Locally, `Emulation.setCPUThrottlingRate` at 20× makes the click reach the cell area and records three executions of `handleCellAreaMouseDown` per click; the keyboard-driven copy passes 6 of 6 under that throttle and 6 of 6 with React's scheduler wake-ups deferred by 200 ms. +The instrumented verbatim copy of the shipped scenario, run on the hosted Linux lane of PR #5291 while it was a draft, reproduced `expected 'B1' to be 'A1'` at the first reflow in 7 of 38 runs across three lane runs (2 of 6, 1 of 6, 4 of 20). Every failure recorded `mousedown` targeting `#luckysheet-sheettable_0` rather than the drag handle and the `B1` commit preceding the deferred `cellInput.focus()` that only the press updater schedules; the five failures in the two lane runs that also hooked the cell area's `getBoundingClientRect` all carried the four-execution signature above. The keyboard-driven copy ran in those same two lane runs and passed 26 of 26. Locally, `Emulation.setCPUThrottlingRate` at 20× makes the click reach the cell area and records three executions of `handleCellAreaMouseDown` per click; the keyboard-driven copy passes 6 of 6 under that throttle and 6 of 6 with React's scheduler wake-ups deferred by 200 ms. -`env -u TSX_TSCONFIG_PATH DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/document-preview.e2e.ts -t "fills the spreadsheet pane"` passes both locale cases in every local repetition recorded in the PR. +`env -u TSX_TSCONFIG_PATH DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/document-preview.e2e.ts -t "fills the spreadsheet pane"` passed both locale cases in 10 of 10 local repetitions, and the whole file passed its 6 cases in 2 of 3 local runs; the third failed the unrelated `Coding Tools` switch poll at `:849` while documentation gates ran concurrently, with the failure screenshot showing the switch on. ## Consequences diff --git a/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md index cdcf0adcb2..b2d6a3c5d7 100644 --- a/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md +++ b/.agents/notes/implemented/testing/2026-09-27-spreadsheet-selection-by-keyboard-before-reflow.zh.md @@ -6,13 +6,13 @@ Status: implemented ## Problem -[document-preview.e2e.ts](../../../../apps/web/tests/document-preview.e2e.ts) 打开 `meeting.xlsx`,在表格 overlay 的 `{ x: 60, y: 40 }` 处点击以选中 `A1`,随后把侧栏加宽、收窄各两次,并在每次重排后断言名称框仍显示 `A1`。2026-09-24 到 2026-09-27 之间 `CI master` 的 serial 自托管 Linux lane 每一次运行都在第一次重排处以 `expected 'B1' to be 'A1'` 失败该场景的一个或两个 locale 用例,托管的 `node 24 / snapshots and artifacts` 也以同样方式失败(run 36110572677 与 36213089675)。失败与 locale 无关:四次 master 运行只有 en-US 失败,三次两者都失败,三次只有 zh-CN 失败。把读取改为轮询(#5243)没有作用,因为 `B1` 是已提交的选区,不是过期采样。 +[document-preview.e2e.ts](../../../../apps/web/tests/document-preview.e2e.ts) 打开 `meeting.xlsx`,在表格 overlay 的 `{ x: 60, y: 40 }` 处点击以选中 `A1`,随后把侧栏加宽、收窄各两次,并在每次重排后断言名称框仍显示 `A1`。2026-09-24 到 2026-09-27 之间 `CI master` 的 serial 自托管 Linux lane 的 11 次运行中有 10 次在第一次重排处以 `expected 'B1' to be 'A1'` 失败该场景的一个或两个 locale 用例,托管的 `node 24 / snapshots and artifacts` 也以同样方式失败(run 36110572677 与 36213089675)。失败与 locale 无关:四次 master 运行只有 en-US 失败,三次两者都失败,三次只有 zh-CN 失败。把读取改为轮询(#5243)没有作用,因为 `B1` 是已提交的选区,不是过期采样。 `A1` 是工作簿的初始选区,所以点击之后的两个轮询在点击提交前就已满足,从未验证点击选中了什么。FortuneSheet 的 `SheetOverlay` 以 `setContext(draft => handleCellAreaMouseDown(draft, globalCache, nativeEvent, cellInput, cellArea, …))` 处理按下,而 `handleCellAreaMouseDown` 在 updater 执行的那一刻用 `cellArea.getBoundingClientRect()` 与事件的页面坐标解析目标单元格。React 在渲染期间运行 `useState` 的 updater,并且当 hook 队列里有更低优先级的更新排在它前面时会再次运行它;刚挂载的工作簿仍有这类更新待处理,而 Playwright 在按下之前先派发一次 `mousemove`,其 FortuneSheet 处理器是同一状态上的 continuous 优先级更新。在 Blacksmith runner 上运行的带探针的逐字副本记录到这一次点击触发了四次 `handleCellAreaMouseDown`:两次在点击返回前读到 `left=969.5`,两次在场景已把面板设为 `1000px` 之后读到 `left=725.5`。保存下来的指针 x 985 于是在网格内解析为 259.5,而会议表只有两列,`colLocation` 把它钳到最后一列:`B1`。在点击前就已提交工作簿初始选区归一化的机器上,同一位置落在 A1 自身的选区框上,其 `onMouseDown` 阻止了传播;点击于是什么也没选中,没有几何绑定的 updater 入队,场景在没有测试到点击的情况下通过。 ## Decision -会议表这一步改为键盘选择。场景聚焦表格 overlay,按 `ArrowRight` 并断言 `B1` 且公式栏为空,再按 `ArrowLeft` 并断言 `A1` 且公式栏为 `会议纪要`,然后才开始重排循环。`handleArrowKey` → `moveHighlightCell` 只依据已提交状态(当前选区与 `visibledatacolumn`)计算目标,所以被重新执行的 updater 无论此时网格几何如何都得到同一单元格,而两次移动使交互可观察,因为 `B1` 与初始选区不同。重排循环、其中的 `A1` 断言、表页滚动控件计数与 canvas 身份检查保持不变。 +会议表这一步改为键盘选择。场景聚焦表格 overlay(`tabindex="-1"`),断言焦点已落在其上,按 `ArrowRight` 并断言 `B1` 且公式栏为空,再按 `ArrowLeft` 并断言 `A1` 且公式栏为 `会议纪要`,然后才开始重排循环。`handleArrowKey` → `moveHighlightCell` 只依据已提交状态(当前选区与 `visibledatacolumn`)计算目标,所以被重新执行的 updater 无论此时网格几何如何都得到同一单元格,而两次移动使交互可观察,因为 `B1` 与初始选区不同。重排循环、其中的 `A1` 断言、表页滚动控件计数与 canvas 身份检查保持不变。 场景开头预算表的点击仍是指针点击:它的目标不是初始选区,其结果由 `46281` 公式读取验证,而键盘、剪贴板和布局读取把它与第一次重排隔开远超一次延迟渲染。`values.csv` 的点击及其后的收窄也保持不变:针对收窄后的网格重新执行会解析出负的 x,`colLocation` 把它钳到 A 列,即同一单元格。 @@ -24,13 +24,13 @@ Status: implemented **第一次重排前等待渲染器空闲。** 没有产品状态能表达“React 没有待重新执行的更新”;按 runner 调校的空闲等待或固定等待正是 [dsh-ci-test-reliability](../../../skills/dsh-ci-test-reliability/SKILL.md) 拒绝的掩盖式等待。 -**给 FortuneSheet 打补丁,在 updater 之外解析单元格。** `handleCellAreaMouseDown` 位于 `@fortune-sheet/core`;在 `setContext` 之前快照矩形是针对用户无法触达的窗口(一次指针按下与一次侧栏重排落在同一次延迟渲染内)的依赖补丁。它仍是上游问题;产品改动不在本次测试修复范围内。 +**扩展既有的 `@fortune-sheet/react` 补丁,在 updater 之外解析单元格。** `handleCellAreaMouseDown` 位于 `@fortune-sheet/core`,而用户需要命中的窗口是一次指针按下与一次侧栏重排落在同一次延迟渲染内。它仍是上游问题;产品改动不在本次测试修复范围内。 ## Verification -在草稿 PR #5291 的托管 Linux lane 上以 `DEBUG=pw:api` 与 cell area 上的 `getBoundingClientRect` 钩子运行的带探针逐字副本,在两次 lane 运行共 12 次中有 3 次在第一次重排处复现 `expected 'B1' to be 'A1'`,每次都带有上述四次执行的特征,且 `mousedown` 的目标是 `#luckysheet-sheettable_0` 而不是拖拽手柄。键盘驱动的副本在复现了点击失败的那次 lane 运行中 6 次全部通过。本地以 20× 的 `Emulation.setCPUThrottlingRate` 运行可让点击到达 cell area,并记录到每次点击三次 `handleCellAreaMouseDown` 执行;键盘驱动的副本在该限速下 6 次全部通过,在 React 调度器唤醒被推迟 200 ms 时也 6 次全部通过。 +PR #5291 处于草稿阶段时在托管 Linux lane 上运行的带探针逐字副本,在三次 lane 运行共 38 次中有 7 次(2/6、1/6、4/20)在第一次重排处复现 `expected 'B1' to be 'A1'`。每次失败都记录到 `mousedown` 的目标是 `#luckysheet-sheettable_0` 而不是拖拽手柄,且 `B1` 的提交先于只有按下 updater 才会安排的延迟 `cellInput.focus()`;在同时给 cell area 的 `getBoundingClientRect` 加了钩子的两次 lane 运行里,5 次失败全部带有上述四次执行的特征。键盘驱动的副本在这两次 lane 运行中 26 次全部通过。本地以 20× 的 `Emulation.setCPUThrottlingRate` 运行可让点击到达 cell area,并记录到每次点击三次 `handleCellAreaMouseDown` 执行;键盘驱动的副本在该限速下 6 次全部通过,在 React 调度器唤醒被推迟 200 ms 时也 6 次全部通过。 -`env -u TSX_TSCONFIG_PATH DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/document-preview.e2e.ts -t "fills the spreadsheet pane"` 在 PR 中记录的每一次本地重复运行里都通过两个 locale 用例。 +`env -u TSX_TSCONFIG_PATH DSH_SNAPSHOT=replay pnpm exec vitest run --config vitest.web.config.ts apps/web/tests/document-preview.e2e.ts -t "fills the spreadsheet pane"` 在本地 10 次重复中 10 次都通过两个 locale 用例;整个文件的 6 个用例在 3 次本地运行中 2 次通过,第 3 次在文档 gate 并行运行时失败于无关的 `:849` `Coding Tools` 开关轮询,失败截图显示开关已打开。 ## Consequences diff --git a/apps/web/tests/document-preview.e2e.ts b/apps/web/tests/document-preview.e2e.ts index 169384e44d..f95e50f102 100644 --- a/apps/web/tests/document-preview.e2e.ts +++ b/apps/web/tests/document-preview.e2e.ts @@ -267,11 +267,11 @@ it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pan const meetingCanvas = await canvas.elementHandle() if (meetingCanvas === null) throw new Error('meeting spreadsheet canvas is unavailable') await expectExcelLayout(excel) - // Arrow keys move the selection from committed state. A pointer click resolves its cell - // from the grid's live geometry inside a React state updater that a later render re-runs, - // so a click followed by the reflows below can land on whatever column the moved grid puts - // under the stale pointer coordinates. - await excel.locator('.fortune-sheet-overlay').focus() + // Arrow keys pick the cell from committed state, so a later render cannot re-resolve it + // against the reflowed grid the way it re-resolves a pointer press. + const overlay = excel.locator('.fortune-sheet-overlay') + await overlay.focus() + await expect.poll(() => overlay.evaluate(node => document.activeElement === node)).toBe(true) await page.keyboard.press('ArrowRight') await expect.poll(() => selection.innerText()).toBe('B1') await expect.poll(() => formula.innerText()).toBe('') From da84e3a3f7ff9121c8c1d6bd8dab428e64c05085 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:20:53 +0800 Subject: [PATCH 13/86] docs(notes): correct the oxlint contract budget note counts and alternatives Seven cases, not nine, carried 90_000 before the change; the other seven ran on the default and five of those spawn. Record the lane-flag alternative, cite the archived describe-level budget notes and the accepted risk, and bound the idle-host timing claim to the measured cases. --- ...9-27-oxlint-contract-spawn-case-budgets.i18n.yaml | 12 ++++++------ .../2026-09-27-oxlint-contract-spawn-case-budgets.md | 8 +++++--- ...26-09-27-oxlint-contract-spawn-case-budgets.zh.md | 8 +++++--- 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.i18n.yaml index 2e72aa6761..f50d88bce9 100644 --- a/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.i18n.yaml @@ -6,14 +6,14 @@ en: 152f1e58efbf506e zh: 889504ebfe5783ca /agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/problem: - en: d167d5d7da3b2be4 - zh: d225bfab0008a1a5 + en: 08d6430c0b4e868f + zh: b98c15541bcea4ee /agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/decision: en: 34bd113b8c9228fb zh: 99843bf33b703b5e /agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/alternatives-considered: - en: c929415601419c48 - zh: 31a62e9dd453a183 + en: 2b317170890802f7 + zh: 2a5221dcfa8b9116 /agent-note-oxlint-contract-cases-that-spawn-a-process-carry-a-90-s-ceiling/consequences: - en: 5e7259ba9be2edd4 - zh: 12664440768eed2f + en: e484a10680ca20b8 + zh: 8fd9fcf13e3dbcd7 diff --git a/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md index 4bfd4b61d3..fc4e47fdf8 100644 --- a/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md +++ b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.md @@ -6,7 +6,7 @@ English | [中文](2026-09-27-oxlint-contract-spawn-case-budgets.zh.md) ## Problem -Twelve of the fourteen cases in [`scripts/oxlint-contract.spec.ts`](../../../../scripts/oxlint-contract.spec.ts) spawn a real child process: `runOxlint` starts the oxlint CLI, and `runRepositoryOxlint` boots [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) under tsx, which runs oxlint and, after `--fix`, runs it once more. The root `vitest.config.ts` sets no `testTimeout`, so a case without its own budget inherits Vitest's 5000 ms default; only the coverage lanes raise it through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`, and the `unit tests (darwin parity, macos-latest)` job in `sandbox.yml` runs `pnpm run test` plain. Nine cases carried an explicit `90_000` since the contended Windows spawn budgets (#3115) were aligned to that value; five ran on the default. Two runner classes exposed it: the self-hosted Windows pool, where process creation spikes to several seconds (#2581), and the hosted macos-latest runner of Sandbox run 36332053320, which ran the whole file 2.2× slower than the passing run 36320394290 and timed out `prints only the final diagnostics when a fix retry still fails` at 6309 ms, a case that took 1122 ms on the passing run. `Test timed out in 5000ms` names neither a contract violation nor the spawn that overran. +Twelve of the fourteen cases in [`scripts/oxlint-contract.spec.ts`](../../../../scripts/oxlint-contract.spec.ts) spawn a real child process: `runOxlint` starts the oxlint CLI, and `runRepositoryOxlint` boots [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts) under tsx, which runs oxlint and, after `--fix`, runs it once more. The root `vitest.config.ts` sets no `testTimeout`, so a case without its own budget inherits Vitest's 5000 ms default; the coverage lanes raise it through `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` and the Windows native lane passes `--testTimeout 90000`, while the `unit tests (darwin parity, macos-latest)` job in `sandbox.yml` and a developer's `pnpm run test` run plain. Seven cases (five `it` declarations, one of them a three-way `it.each`) carried the explicit `90_000` that #3115 aligned the contended Windows spawn budgets to; the other seven ran on the default, and five of those spawn. Two runner classes exposed it: the self-hosted Windows pool, where process creation spikes to several seconds (#2581), and the hosted macos-latest runner of Sandbox run 36332053320, which ran the whole file 2.2× slower than the passing run 36320394290 and timed out `prints only the final diagnostics when a fix retry still fails` at 6309 ms, a case that took 1122 ms on the passing run. `Test timed out in 5000ms` names neither a contract violation nor the spawn that overran. ## Decision @@ -14,7 +14,9 @@ Every case in the file that spawns oxlint or the repository lint entrypoint carr ## Alternatives considered -**A `describe`-level `{ timeout: 90_000 }`.** Rejected: a `describe` value overrides the lane's `--testTimeout` instead of yielding to it (#2677), and the file would then carry two budget styles, per-case on the nine cases already bounded and suite-level on the rest. Per-case values keep one style and one place to read each case's ceiling. +**A `describe`-level `{ timeout: 90_000 }`.** Rejected: case-level values already bound seven cases and take precedence over a `describe` value, so a suite-level number would govern only the remaining seven, including the two configuration-only cases that spawn nothing, and the file would carry two budget styles. Per-case values keep one style, one place to read each case's ceiling, and the default on the cases that spawn nothing. Either form overrides the lane's `--testTimeout` rather than yielding to it (#2677). The archived [translation-pairing-merge](../../archived/testing/2026-08-27-translation-pairing-merge-budget.md) and [Lefthook budget](../../archived/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md) notes chose the `describe` form for files whose cases carried no constants of their own, because a case added later without an allowance inherits the default; this file already carried per-case constants, so that risk is accepted here and named under Consequences. + +**Passing `--testTimeout 90000` to the darwin parity job and letting the cases inherit the lane budget**, the form the [subagent teardown budgets](2026-09-07-subagent-teardown-test-budgets.md) note prefers for suites bound by process creation. Rejected: the cost is the file's own tsx boot plus two oxlint runs, not a darwin property, so a developer's plain `pnpm run test` would keep the default; the parity job exists to run the same command developers run; and seven cases already carry constants, so a lane flag would split the file's budget between two sources. **Raising Vitest's global `testTimeout`.** Rejected: the whole unit inventory runs on the default; widening it to absorb one file's process spawns removes the hang detection the default provides everywhere else. @@ -22,4 +24,4 @@ Every case in the file that spawns oxlint or the repository lint entrypoint carr ## Consequences -A contract violation still fails through its own assertion well inside the ceiling; each case's assertions settle in 0.5–1.8 s on an idle host, so only a genuine hang waits the full 90 s. The Windows self-hosted lane, the Linux coverage lanes, and the darwin parity job read the same ceiling for this file regardless of `DSH_COVERAGE_TEST_TIMEOUT_MS`. Under 72 busy-loop processes on a 12-core host the unbounded fix-retry case reproduced `Test timed out in 5000ms` at 7084 ms before the change and passes with the ceiling after it. +A contract violation still fails through its own assertion well inside the ceiling; the five cases bounded here settle in 0.5–1.7 s on an idle host and no spawning case in the file exceeded 3.8 s there, so only a genuine hang waits the full 90 s. The Windows self-hosted lane, the Linux coverage lanes, the darwin parity job, and a local `pnpm run test` read the same ceiling for this file regardless of `DSH_COVERAGE_TEST_TIMEOUT_MS`. A spawning case added later without its own `90_000` runs on the default until the file's inventory is checked. Under 72 busy-loop processes on a 12-core host the unbounded fix-retry case reproduced `Test timed out in 5000ms` at 7084 ms before the change and passes with the ceiling after it. diff --git a/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.zh.md b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.zh.md index 4378df35f2..2b04cfe058 100644 --- a/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.zh.md +++ b/.agents/notes/implemented/testing/2026-09-27-oxlint-contract-spawn-case-budgets.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[`scripts/oxlint-contract.spec.ts`](../../../../scripts/oxlint-contract.spec.ts) 的 14 个用例中有 12 个会启动真实子进程:`runOxlint` 启动 oxlint CLI,`runRepositoryOxlint` 在 tsx 下引导 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts),后者运行 oxlint,并在 `--fix` 之后再运行一次。根 `vitest.config.ts` 没有设置 `testTimeout`,因此没有自带预算的用例继承 Vitest 的 5000 ms 默认值;只有 coverage 通道通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 抬高它,而 `sandbox.yml` 中的 `unit tests (darwin parity, macos-latest)` job 直接运行 `pnpm run test`。自 #3115 把有争抢的 Windows spawn 预算对齐到该值起,9 个用例携带显式的 `90_000`;5 个用例跑在默认值上。两类 runner 暴露了这一点:自托管 Windows 池,其进程创建会出现数秒尖峰(#2581);以及 Sandbox run 36332053320 的托管 macos-latest runner,它跑整个文件比通过的 run 36320394290 慢 2.2 倍,并让 `prints only the final diagnostics when a fix retry still fails` 在 6309 ms 时超时,而该用例在通过的 run 中耗时 1122 ms。`Test timed out in 5000ms` 既不指出契约违规,也不指出超时的那次 spawn。 +[`scripts/oxlint-contract.spec.ts`](../../../../scripts/oxlint-contract.spec.ts) 的 14 个用例中有 12 个会启动真实子进程:`runOxlint` 启动 oxlint CLI,`runRepositoryOxlint` 在 tsx 下引导 [`scripts/run-oxlint.ts`](../../../../scripts/run-oxlint.ts),后者运行 oxlint,并在 `--fix` 之后再运行一次。根 `vitest.config.ts` 没有设置 `testTimeout`,因此没有自带预算的用例继承 Vitest 的 5000 ms 默认值;coverage 通道通过 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000` 抬高它,Windows 原生通道传入 `--testTimeout 90000`,而 `sandbox.yml` 中的 `unit tests (darwin parity, macos-latest)` job 与开发者本机的 `pnpm run test` 都直接运行。7 个用例(5 个 `it` 声明,其中一个是三路 `it.each`)携带 #3115 把有争抢的 Windows spawn 预算对齐到的显式 `90_000`;另外 7 个跑在默认值上,其中 5 个会 spawn。两类 runner 暴露了这一点:自托管 Windows 池,其进程创建会出现数秒尖峰(#2581);以及 Sandbox run 36332053320 的托管 macos-latest runner,它跑整个文件比通过的 run 36320394290 慢 2.2 倍,并让 `prints only the final diagnostics when a fix retry still fails` 在 6309 ms 时超时,而该用例在通过的 run 中耗时 1122 ms。`Test timed out in 5000ms` 既不指出契约违规,也不指出超时的那次 spawn。 ## 决策 @@ -14,7 +14,9 @@ Status: implemented ## 曾考虑的替代方案 -**`describe` 级 `{ timeout: 90_000 }`。** 拒绝:`describe` 级取值覆盖通道的 `--testTimeout` 而不是让位于它(#2677),而且文件会同时带两种预算风格,已有边界的 9 个用例是用例级,其余是套件级。用例级取值保持一种风格,每个用例的上限只在一处可读。 +**`describe` 级 `{ timeout: 90_000 }`。** 拒绝:用例级取值已经约束 7 个用例并且优先于 `describe` 级取值,因此套件级数字只会作用于其余 7 个用例,包括两个不 spawn 的纯配置用例,而且文件会同时带两种预算风格。用例级取值保持一种风格,每个用例的上限只在一处可读,不 spawn 的用例保持默认值。两种形式都覆盖通道的 `--testTimeout` 而不是让位于它(#2677)。已归档的 [translation-pairing-merge](../../archived/testing/2026-08-27-translation-pairing-merge-budget.md) 与 [Lefthook 预算](../../archived/testing/2026-08-29-windows-lane-hook-and-lefthook-budget.md)两篇 Note 为用例本身不带常量的文件选择了 `describe` 形式,理由是之后新增而未带余量的用例会继承默认值;本文件已经带有用例级常量,因此这里接受该风险并在「后果」中点名。 + +**给 darwin parity job 传入 `--testTimeout 90000`,让用例继承通道预算**,这是[子代理清理预算](2026-09-07-subagent-teardown-test-budgets.zh.md)一篇对进程创建受限套件偏好的形式。拒绝:成本来自本文件自身的 tsx 引导加两次 oxlint 运行,不是 darwin 的属性,因此开发者直接运行的 `pnpm run test` 仍会停留在默认值;parity job 的存在意义就是运行与开发者相同的命令;而且 7 个用例已带常量,通道旗标会把本文件的预算拆到两个来源。 **抬高 Vitest 全局 `testTimeout`。** 拒绝:整个单元测试清单都跑在默认值上;为吸收一个文件的进程 spawn 而放宽它,会移除默认值在其他所有地方提供的挂起检测。 @@ -22,4 +24,4 @@ Status: implemented ## 后果 -契约违规仍通过其自身断言在上限之内早早失败;空载宿主机上每个用例的断言在 0.5–1.8 s 内结算,因此只有真正的挂起才会等满 90 s。Windows 自托管通道、Linux coverage 通道与 darwin parity job 对本文件读到同一个上限,与 `DSH_COVERAGE_TEST_TIMEOUT_MS` 无关。在 12 核宿主机上以 72 个忙循环进程加载时,原本无边界的 fix-retry 用例在改动前于 7084 ms 复现 `Test timed out in 5000ms`,改动后在上限内通过。 +契约违规仍通过其自身断言在上限之内早早失败;本次加边界的 5 个用例在空载宿主机上 0.5–1.7 s 内结算,文件中任何会 spawn 的用例在那里都不超过 3.8 s,因此只有真正的挂起才会等满 90 s。Windows 自托管通道、Linux coverage 通道、darwin parity job 与本机 `pnpm run test` 对本文件读到同一个上限,与 `DSH_COVERAGE_TEST_TIMEOUT_MS` 无关。之后新增而未自带 `90_000` 的 spawn 用例会跑在默认值上,直到有人核对本文件的清单。在 12 核宿主机上以 72 个忙循环进程加载时,原本无边界的 fix-retry 用例在改动前于 7084 ms 复现 `Test timed out in 5000ms`,改动后在上限内通过。 From 51ede0f8c57a72c070c7e0c075f712d028172a55 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:22:54 +0800 Subject: [PATCH 14/86] test(pwsh): build the failure message lazily and bound windows by event order Review round 1: the settle assertions build their message only when one is about to fail, so a passing run never pays the host-facts shell probe; send windows are bounded by event sequence numbers instead of milliseconds, so two sends that hand over within one millisecond keep their own events; the replay starts at the send's own input write and ignores the emulator's terminal-protocol replies; a throwing foreground inspection is recorded before the send rejects; a sanitizer is attributed to the most recently spawned terminal, which the class documents as the sequential-session assumption. A unit spec pins the replay rule against the session's evidence states, the window boundaries, the reply/input distinction, and the bounded, escaped rendering. Notes and comments drop change narration and the unverified claim about the pool's build. --- ...026-09-27-pwsh-prompt-tail-grace.i18n.yaml | 4 +- .../2026-09-27-pwsh-prompt-tail-grace.md | 2 +- .../2026-09-27-pwsh-prompt-tail-grace.zh.md | 2 +- ...ervation-waits-on-observed-state.i18n.yaml | 4 +- ...-27-observation-waits-on-observed-state.md | 2 +- ...-observation-waits-on-observed-state.zh.md | 2 +- .../tests/loader-composition.spec.ts | 20 ++- .../tests/readiness-timeline.spec.ts | 143 ++++++++++++++++++ .../tests/readiness-timeline.ts | 133 +++++++++++----- 9 files changed, 256 insertions(+), 56 deletions(-) create mode 100644 packages/shell/tool-pwsh-persistent/tests/readiness-timeline.spec.ts diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml index 1eda5f53ae..d0742687e2 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.i18n.yaml @@ -6,8 +6,8 @@ en: 5e8c5673a0669d9c zh: c8442a6186288a2a /agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/problem: - en: 5c48d66d06c50a09 - zh: 893853d96c26d6e9 + en: 906f8707987ff36c + zh: 40430ec1e03ee340 /agent-note-a-seen-prompt-marker-keeps-its-own-tail-grace/decision: en: a998f5c6abdd68eb zh: a7cdd13e1e4de6c6 diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md index e836ef8088..375ba521d5 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md +++ b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.md @@ -6,7 +6,7 @@ English | [中文](2026-09-27-pwsh-prompt-tail-grace.zh.md) ## Problem -Windows sessions on the self-hosted lane settled one or two of seven sends on the controlled prompt and charged the silence tier for the rest (2026-09-25/26, issue #2487). The exact readiness evidence is the controlled prompt's printable tail after the OSC `133;D` marker, and the bound on waiting for it was `idleSilenceMs + handoffGraceMs`. A native Windows probe shows the render arrives as two pty chunks: the marker alone, then the five-byte `dsh> ` 2-28 ms later (56 renders under Windows PowerShell 5.1, the same two-chunk render under pwsh 7.6.6), with nothing printable after any prompt and no terminal queries in the output. The marker is written by the shell's own prompt function and the tail by the same render, so a tail that arrives past the bound means the host stalled that delivery, not that the prompt is absent. The session settled `inferred_idle` anyway, and each such send cost the tool call roughly three seconds. The lane's own failures turned out not to be that state: the first master run carrying this field (run 36326153388, 2026-09-27 14:30Z) took the same 20.8 s as the run before it (36309006133), so its degraded sends settled at the plain bound with the tolerance inert; the [Windows-lane observation note](../testing/2026-09-27-observation-waits-on-observed-state.md) records that open investigation. +Windows sessions on the self-hosted lane settled one or two of seven sends on the controlled prompt and charged the silence tier for the rest (2026-09-25/26, issue #2487). The exact readiness evidence is the controlled prompt's printable tail after the OSC `133;D` marker, and the bound on waiting for it was `idleSilenceMs + handoffGraceMs`. A native Windows probe shows the render arrives as two pty chunks: the marker alone, then the five-byte `dsh> ` 2-28 ms later (56 renders under Windows PowerShell 5.1, the same two-chunk render under pwsh 7.6.6), with nothing printable after any prompt and no terminal queries in the output. The marker is written by the shell's own prompt function and the tail by the same render, so a tail that arrives past the bound means the host stalled that delivery, not that the prompt is absent. The session settled `inferred_idle` anyway, and each such send cost the tool call roughly three seconds. The lane's own failures are not that state: the first master run carrying this field (run 36326153388, 2026-09-27 14:30Z) took the same 20.8 s as the run before it (36309006133), so its degraded sends settled at the plain bound with the tolerance inert; the [Windows-lane observation note](../testing/2026-09-27-observation-waits-on-observed-state.md) records the open investigation. ## Decision diff --git a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md index 3b3a57b67a..511cedf728 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md @@ -6,7 +6,7 @@ Status: implemented ## Problem -自托管 lane 上的 Windows 会话在七次 send 中只有一到两次按受控提示符结算,其余都要支付静默层(2026-09-25/26,issue #2487)。精确的就绪证据是 OSC `133;D` 标记之后受控提示符的可打印尾部,而等待它的上界是 `idleSilenceMs + handoffGraceMs`。原生 Windows 探测表明该渲染以两个 pty 分块到达:先是标记本身,2–28 毫秒后是 5 字节的 `dsh> `(Windows PowerShell 5.1 下 56 次渲染,pwsh 7.6.6 下同样是两分块渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询。标记由 shell 自己的 prompt 函数写出、尾部由同一次渲染写出,因此尾部晚于上界到达意味着宿主机拖慢了这次投递,而不是提示符缺失。会话此前仍会按 `inferred_idle` 结算,每次这样的 send 让工具调用多付约三秒。该 lane 自身的失败后来被证明不是这种状态:首个带着该字段的 master run(run 36326153388,2026-09-27 14:30Z)与其前一个 run(36309006133)同样耗时 20.8 秒,因此其退化的 send 是在宽限不起作用的情况下按普通上界结算的;[Windows lane 观测笔记](../testing/2026-09-27-observation-waits-on-observed-state.zh.md)记录了这项仍未关闭的调查。 +自托管 lane 上的 Windows 会话在七次 send 中只有一到两次按受控提示符结算,其余都要支付静默层(2026-09-25/26,issue #2487)。精确的就绪证据是 OSC `133;D` 标记之后受控提示符的可打印尾部,而等待它的上界是 `idleSilenceMs + handoffGraceMs`。原生 Windows 探测表明该渲染以两个 pty 分块到达:先是标记本身,2–28 毫秒后是 5 字节的 `dsh> `(Windows PowerShell 5.1 下 56 次渲染,pwsh 7.6.6 下同样是两分块渲染),任何提示符之后都没有可打印文本,输出中也没有终端查询。标记由 shell 自己的 prompt 函数写出、尾部由同一次渲染写出,因此尾部晚于上界到达意味着宿主机拖慢了这次投递,而不是提示符缺失。会话此前仍会按 `inferred_idle` 结算,每次这样的 send 让工具调用多付约三秒。该 lane 自身的失败不是这种状态:首个带着该字段的 master run(run 36326153388,2026-09-27 14:30Z)与其前一个 run(36309006133)同样耗时 20.8 秒,因此其退化的 send 是在宽限不起作用的情况下按普通上界结算的;[Windows lane 观测笔记](../testing/2026-09-27-observation-waits-on-observed-state.zh.md)记录了这项仍未关闭的调查。 ## Decision diff --git a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml index 7fbdbcbcd2..e444419931 100644 --- a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.i18n.yaml @@ -18,5 +18,5 @@ en: 13db32f9da19d87b zh: 48081fc7035dea45 /agent-note-windows-lane-observations-wait-on-the-state-they-read/deferred: - en: e1c0bb4ec03b36a8 - zh: 6b1c2166c53f4e03 + en: 8e182739fdf90baf + zh: 5f93f7b7d9518764 diff --git a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md index b4a43d1509..522358e508 100644 --- a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md +++ b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.md @@ -34,4 +34,4 @@ Both plugin-manager record cases now observe the record while the run is in flig The loader-composition failure on the self-hosted Windows lane (2026-09-25..27, issue #2487) remains open, and its mechanism is not a stalled prompt tail. The master run before the [prompt tail grace](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.md) landed (run 36309006133, 2026-09-27 09:19Z) and the first run carrying it with `promptTailGraceMs: 5000` in this composition (run 36326153388, 14:30Z, reasons `["stdin_read","inferred_idle","inferred_idle","inferred_idle","inferred_idle","session_exit","inferred_idle"]`) both took 20.8 s, so the degraded sends settled at the plain `idleSilenceMs + handoffGraceMs` bound: either no marker was seen, or a marker was seen and its tail was invalidated by later printable text — the two states the tolerance does not cover. The foreground comparison is not a candidate on Windows: `WindowsProcessInspector.foregroundPgid` returns the shell pid unconditionally and `isStdinWaiting` returns false, so `shellPgid` always matches and `stdin_read` can only come from the controlled-prompt tail. The composition therefore keeps the field at its product default of `0`, and the failure message's timeline names the state directly the next time the lane fails; the 2026-09-27 12:51Z run (36320394583) instead hit the 120 s case budget before any assertion, which the same print covers. -Native evidence with pwsh 7.6.6 and PSReadLine 2.4.5 on Windows 11 25H2 (conhost 10.0.26100.1; the earlier probes had run under Windows PowerShell 5.1 because the guest carries no pwsh 7): the case settles every send on the prompt at baseline, under eight CPU burners plus disk churn, under emulated x64 Node, under `vitest run --coverage --maxWorkers=1`, and with a 12-row viewport that scrolls from the second command, each prompt arriving as one in-order marker chunk followed by the five-byte tail. With an 8-row, 60-column viewport ConPTY re-emits the stored `133;D` mark inside repaint frames — five markers in one send, one followed by repainted row text (`\x07\e[Hdsh> Write-Output …`) — which leaves a tail that is not a prefix of the controlled prompt; on that console host the real prompt's marker still arrived last and the send settled `stdin_read`. That repaint path is the leading hypothesis for the lane, whose Windows build and console-host version are unknown (the job logs carry neither and the pool has no dispatchable job); the failure message records `os.release()`, `os.version()`, the pwsh and PSReadLine versions, and `conhost.exe`'s file version, and `dsh-subprocess-local` spawns node-pty without `useConptyDll`, so the system console host is the one in play. The pid `0` those probes also exposed on Windows is a separate defect, tracked by issue #5297. +Native evidence with pwsh 7.6.6 and PSReadLine 2.4.5 on Windows 11 25H2 (conhost 10.0.26100.1; the probes the tail-grace note cites ran under Windows PowerShell 5.1, the guest's only preinstalled shell): the case settles every send on the prompt at baseline, under eight CPU burners plus disk churn, under emulated x64 Node, under `vitest run --coverage --maxWorkers=1`, and with a 12-row viewport that scrolls from the second command, each prompt arriving as one in-order marker chunk followed by the five-byte tail. With an 8-row, 60-column viewport ConPTY re-emits the stored `133;D` mark inside repaint frames — five markers in one send, one followed by repainted row text (`\x07\e[Hdsh> Write-Output …`) — which leaves a tail that is not a prefix of the controlled prompt; on that console host the real prompt's marker still arrived last and the send settled `stdin_read`. That repaint path is the leading hypothesis for the lane, whose Windows build and console-host version are unknown (the job logs carry neither and the pool has no dispatchable job); the failure message records `os.release()`, `os.version()`, the pwsh and PSReadLine versions, and `conhost.exe`'s file version, and `dsh-subprocess-local` spawns node-pty without `useConptyDll`, so the system console host is the one in play. The pid `0` those probes also exposed on Windows is a separate defect, tracked by issue #5297. diff --git a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md index 809286cdbd..9adf17b517 100644 --- a/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md +++ b/.agents/notes/implemented/testing/2026-09-27-observation-waits-on-observed-state.zh.md @@ -34,4 +34,4 @@ loader-composition 的结算层级断言把记录到的结算原因与一份逐 自托管 Windows lane 上 loader-composition 的失败(2026-09-25..27,issue #2487)仍未关闭,其机制不是提示符尾部停摆。[提示符尾部宽限](../bug-fix/2026-09-27-pwsh-prompt-tail-grace.zh.md)合入前的 master run(run 36309006133,2026-09-27 09:19Z)与首个带着该字段、且本组合设置了 `promptTailGraceMs: 5000` 的 run(run 36326153388,14:30Z,结算原因为 `["stdin_read","inferred_idle","inferred_idle","inferred_idle","inferred_idle","session_exit","inferred_idle"]`)都耗时 20.8 秒,因此退化的 send 是按普通的 `idleSilenceMs + handoffGraceMs` 上界结算的:要么根本没看到标记,要么看到了标记但其尾部被随后的可打印文本失效——这正是该宽限不覆盖的两种状态。前台比较在 Windows 上不是候选:`WindowsProcessInspector.foregroundPgid` 无条件返回 shell pid、`isStdinWaiting` 返回 false,因此 `shellPgid` 总是匹配,`stdin_read` 只能来自受控提示符尾部。于是该组合把字段保持在 product 默认值 `0`,下一次该 lane 失败时,失败信息里的时间线会直接点名所处的状态;2026-09-27 12:51Z 的 run(36320394583)则是在任何断言之前就撞上 120 秒的用例预算,同一份打印也覆盖这条路径。 -在 Windows 11 25H2(conhost 10.0.26100.1)上以 pwsh 7.6.6 与 PSReadLine 2.4.5 取得的原生证据(此前的探测因 guest 没有 pwsh 7 而运行在 Windows PowerShell 5.1 之下):基线、8 个 CPU burner 加磁盘反复读写、模拟 x64 Node、`vitest run --coverage --maxWorkers=1`、以及从第二条命令起就滚动的 12 行视口下,用例的每次 send 都按提示符结算,每个提示符都是一个按序到达的标记分块紧跟 5 字节尾部。在 8 行 60 列的视口下,ConPTY 会在重绘帧内重新发出已存储的 `133;D` 标记——一次 send 内出现五个标记,其中一个后面紧跟重绘的行文本(`\x07\e[Hdsh> Write-Output …`)——这使尾部不再是受控提示符的前缀;在该控制台宿主上,真正提示符的标记仍然最后到达,因此该 send 仍按 `stdin_read` 结算。这条重绘路径是该 lane 的首要假设,而该 lane 的 Windows 版本与控制台宿主版本未知(job 日志两者都没有,该 pool 也没有可手动触发的 job);失败信息会记录 `os.release()`、`os.version()`、pwsh 与 PSReadLine 版本以及 `conhost.exe` 的文件版本,而 `dsh-subprocess-local` 启动 node-pty 时未设置 `useConptyDll`,因此起作用的是系统控制台宿主。这些探测同时暴露的 Windows 上 pid 为 `0` 的问题是另一个缺陷,由 issue #5297 跟踪。 +在 Windows 11 25H2(conhost 10.0.26100.1)上以 pwsh 7.6.6 与 PSReadLine 2.4.5 取得的原生证据(尾部宽限笔记所引用的探测运行在 Windows PowerShell 5.1 之下,那是 guest 唯一预装的 shell):基线、8 个 CPU burner 加磁盘反复读写、模拟 x64 Node、`vitest run --coverage --maxWorkers=1`、以及从第二条命令起就滚动的 12 行视口下,用例的每次 send 都按提示符结算,每个提示符都是一个按序到达的标记分块紧跟 5 字节尾部。在 8 行 60 列的视口下,ConPTY 会在重绘帧内重新发出已存储的 `133;D` 标记——一次 send 内出现五个标记,其中一个后面紧跟重绘的行文本(`\x07\e[Hdsh> Write-Output …`)——这使尾部不再是受控提示符的前缀;在该控制台宿主上,真正提示符的标记仍然最后到达,因此该 send 仍按 `stdin_read` 结算。这条重绘路径是该 lane 的首要假设,而该 lane 的 Windows 版本与控制台宿主版本未知(job 日志两者都没有,该 pool 也没有可手动触发的 job);失败信息会记录 `os.release()`、`os.version()`、pwsh 与 PSReadLine 版本以及 `conhost.exe` 的文件版本,而 `dsh-subprocess-local` 启动 node-pty 时未设置 `useConptyDll`,因此起作用的是系统控制台宿主。这些探测同时暴露的 Windows 上 pid 为 `0` 的问题是另一个缺陷,由 issue #5297 跟踪。 diff --git a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts index bfe01a0088..bd2204aa1f 100644 --- a/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/loader-composition.spec.ts @@ -33,8 +33,8 @@ const hasPwsh = spawnSync( ).status === 0 // Shell and console-host versions for a failure message: the Windows console host, not this -// process, decides how the prompt marker and its tail reach the session, and its build differs -// between the runners that pass this case and the self-hosted pool that fails it. +// process, decides how the prompt marker and its tail reach the session, and its build on the +// self-hosted pool is unknown. const HOST_FACTS_COMMAND = [ '"pwsh $($PSVersionTable.PSVersion) PSReadLine $((Get-Module PSReadLine -ListAvailable | Sort-Object Version -Descending | Select-Object -First 1).Version)"', 'if ($env:OS -eq \'Windows_NT\') { "conhost $((Get-Item (Join-Path $env:SystemRoot \'System32\\conhost.exe\')).VersionInfo.FileVersion)" }', @@ -194,7 +194,7 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp const settleReasons: TerminalWaitReason[] = [] vi.spyOn(terminals, 'startSend').mockImplementation((owner, id, request) => { const operation = startSend(owner, id, request) - timeline.track(operation) + timeline.track(operation, request) void operation.done.then( (settled) => { settleReasons.push(settled.waitReason) }, // A rejected send is the tool's error path, not a settle reason. @@ -251,13 +251,17 @@ describe.skipIf(!hasPwsh)('persistent pwsh through a real cordis.yml Loader comp expect(text(await execute('after-exit', 'Write-Output "$PWD"'))).toBe(root) // Six commands settle on the controlled prompt; no send may fall back to the - // silence tier, which is the 3.5 s-per-call degradation this suite pins. The + // silence tier, which is the 3.3 s-per-call degradation this suite pins. The // counts alone do not say which tier settled which send, so every reason the // run recorded and the per-send timeline ride in the failure message (the // self-hosted Windows lane reported one to two stdin_read settlements per - // run on 2026-09-25..27, every other send at the plain silence bound). - const failure = (): string => `${JSON.stringify(settleReasons)}\n${timeline.format()}` - expect(settleReasons.filter(reason => reason === 'stdin_read').length, failure()).toBeGreaterThanOrEqual(6) - expect(settleReasons, failure()).not.toContain('inferred_idle') + // run on 2026-09-25..27, every other send at the plain silence bound). The + // message is built only when an assertion is about to fail: formatting runs + // the host-facts shell probe, which a passing run must not pay for. + const stdinReads = settleReasons.filter(reason => reason === 'stdin_read').length + const degraded = stdinReads < 6 || settleReasons.includes('inferred_idle') + const failure = degraded ? `${JSON.stringify(settleReasons)}\n${timeline.format()}` : '' + expect(stdinReads, failure).toBeGreaterThanOrEqual(6) + expect(settleReasons, failure).not.toContain('inferred_idle') }, 120_000) }) diff --git a/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.spec.ts b/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.spec.ts new file mode 100644 index 0000000000..153c2c0bd4 --- /dev/null +++ b/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.spec.ts @@ -0,0 +1,143 @@ +import { Readable } from 'node:stream' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import type { TerminalSendOperation, TerminalSendResult } from '@deepseek-ai/dsh-terminal' +import { CONTROLLED_PROMPT, TerminalSanitizer } from '@deepseek-ai/dsh-terminal-bash/src/sanitize.ts' +import { ReadinessTimeline, TIMELINE_HEADER, replayPromptEvidence } from './readiness-timeline.ts' + +const MARKER = '\x1b]133;D;0\x07' + +afterEach(() => { + vi.restoreAllMocks() +}) + +interface FakeTerminal { + handle: SubprocessTerminalHandle + foreground: { processGroupId: number; inputWaiting: boolean } | undefined + inspectError: Error | undefined + writes: string[] +} + +function fakeTerminal(pid: number): FakeTerminal { + const fake: FakeTerminal = { + foreground: { processGroupId: pid, inputWaiting: false }, + inspectError: undefined, + writes: [], + handle: undefined as unknown as SubprocessTerminalHandle, + } + const output = new Readable({ read() {} }) + fake.handle = { + pid, + output, + done: new Promise(() => {}), + async write(data: string) { fake.writes.push(data) }, + async resize() {}, + async inspectForeground() { + if (fake.inspectError !== undefined) throw fake.inspectError + return fake.foreground + }, + async inspectActivity() { return { state: 'unknown' as const, revision: 0 } }, + async signalForeground() { return pid }, + async terminate() {}, + } + return fake +} + +function fakeSend(): { operation: TerminalSendOperation; settle: (waitReason: TerminalSendResult['waitReason']) => void; reject: () => void } { + const resolvers = Promise.withResolvers() + const operation: TerminalSendOperation = { + done: resolvers.promise, + cancel: () => false, + readOutput: () => ({ delta: '', truncated: false }), + } + return { + operation, + settle: (waitReason) => { resolvers.resolve({ viewport: '', waitReason, sessionStatus: { kind: 'running' }, truncated: false }) }, + reject: () => { resolvers.reject(new Error('inspection failed')) }, + } +} + +// Let the operation's `done` continuation record the window's end before the next step. +const settled = (): Promise => new Promise(resolve => setTimeout(resolve, 0)) + +describe('replayPromptEvidence', () => { + it('mirrors the session rule: exact tail, invalidated tail, pending prefix, absent marker', () => { + expect(replayPromptEvidence([{ prompt: true, promptTail: '' }, { prompt: false, promptTail: CONTROLLED_PROMPT }])) + .toEqual({ promptSeen: true, promptTail: CONTROLLED_PROMPT, promptTextSeen: true }) + expect(replayPromptEvidence([{ prompt: true, promptTail: '' }, { prompt: false, promptTail: `${CONTROLLED_PROMPT}Write-Output` }])) + .toEqual({ promptSeen: true, promptTail: `${CONTROLLED_PROMPT}\0`, promptTextSeen: false }) + expect(replayPromptEvidence([{ prompt: true, promptTail: '' }, { prompt: false, promptTail: 'dsh' }])) + .toEqual({ promptSeen: true, promptTail: 'dsh', promptTextSeen: false }) + expect(replayPromptEvidence([{ prompt: false, promptTail: undefined }, { prompt: false, promptTail: 'dsh> ' }])) + .toEqual({ promptSeen: false, promptTail: '', promptTextSeen: false }) + // A later marker restarts the tail, as the session's onData does. + expect(replayPromptEvidence([ + { prompt: true, promptTail: 'stale text' }, + { prompt: true, promptTail: '' }, + { prompt: false, promptTail: CONTROLLED_PROMPT }, + ])).toEqual({ promptSeen: true, promptTail: CONTROLLED_PROMPT, promptTextSeen: true }) + }) +}) + +describe('ReadinessTimeline', () => { + it('keeps sends that hand over within one millisecond apart and replays from the input write only', async () => { + const timeline = new ReadinessTimeline(() => 'host: fake') + timeline.observeSanitizer() + const terminal = fakeTerminal(4242) + timeline.observeTerminal(terminal.handle) + const sanitizer = new TerminalSanitizer(1024) + + // Send A: input write, a cursor-position query answered by the emulator (a reply write that + // must not reset the evidence), then the marker and tail, then the settling poll. + timeline.label('a') + const a = fakeSend() + timeline.track(a.operation, { text: 'echo a', submit: true }) + await terminal.handle.write('echo a\r') + sanitizer.push('a-output\r\n\x1b[6n') + await terminal.handle.write('\x1b[1;1R') + sanitizer.push(MARKER) + sanitizer.push(CONTROLLED_PROMPT) + await terminal.handle.inspectForeground() + a.settle('stdin_read') + await settled() + + // Send B starts in the same millisecond as A's settle and never sees a marker. + timeline.label('b') + const b = fakeSend() + timeline.track(b.operation, { text: 'echo b', submit: true }) + await terminal.handle.write('echo b\r') + sanitizer.push('b-output\r\n') + terminal.inspectError = new Error('snapshot unreadable') + await expect(terminal.handle.inspectForeground()).rejects.toThrow('snapshot unreadable') + b.reject() + await settled() + + const text = timeline.format() + expect(text.startsWith(`${TIMELINE_HEADER}\nhost: fake\n`)).toBe(true) + const [, windowA = '', windowB = ''] = text.split(/^(?=#\d )/m) + expect(windowA).toMatch(/^#1 a \(from \+\d+ ms\) → stdin_read after \d+ ms\n/) + expect(windowA).toContain('write s0 7 units (input)') + expect(windowA).toContain('write s0 6 units (reply)') + expect(windowA).toContain('replayed evidence at settle: promptSeen=true promptTail="dsh> " promptTextSeen=true') + expect(windowA).not.toContain('b-output') + expect(windowB).toMatch(/^#2 b \(from \+\d+ ms\) → rejected after \d+ ms\n/) + expect(windowB).toContain('polls 1') + expect(windowB).toContain('threw snapshot unreadable') + expect(windowB).toContain('replayed evidence at settle: promptSeen=false promptTail="" promptTextSeen=false') + expect(windowB).not.toContain('a-output') + }) + + it('shows every marker-bearing chunk, bounds the rest, and escapes control bytes', () => { + const timeline = new ReadinessTimeline() + timeline.observeSanitizer() + const sanitizer = new TerminalSanitizer(1024) + for (let line = 1; line <= 30; line += 1) sanitizer.push(`${line}\r\n`) + sanitizer.push(`\x1b[H\x1b[K${MARKER}`) + sanitizer.push(CONTROLLED_PROMPT) + const text = timeline.format() + expect(text).toContain('chunks 32 (1 with a prompt marker)') + expect(text).toContain('… 24 chunks elided …') + expect(text).toContain('marker=YES tail="" "\\e[H\\e[K\\e]133;D;0\\x07"') + expect(text).not.toMatch(/[\x00-\x08\x0b-\x1f]/) + }) +}) diff --git a/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.ts b/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.ts index 3255166bfa..521c0605a0 100644 --- a/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.ts +++ b/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.ts @@ -5,7 +5,7 @@ * lost controlled-prompt fast path names the state that lost it. */ import type { SubprocessTerminalForeground, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' -import type { TerminalSendOperation, TerminalWaitReason } from '@deepseek-ai/dsh-terminal' +import type { TerminalSendOperation, TerminalSendRequest, TerminalWaitReason } from '@deepseek-ai/dsh-terminal' import { CONTROLLED_PROMPT, TerminalSanitizer } from '@deepseek-ai/dsh-terminal-bash/src/sanitize.ts' import type { SanitizedChunk } from '@deepseek-ai/dsh-terminal-bash/src/sanitize.ts' import { vi } from 'vitest' @@ -15,6 +15,7 @@ export const TIMELINE_HEADER = 'readiness timeline (ms since the case started; s interface ChunkEvent { kind: 'chunk' + seq: number at: number session: number units: number @@ -26,6 +27,7 @@ interface ChunkEvent { interface PollEvent { kind: 'poll' + seq: number at: number session: number foreground: string @@ -33,13 +35,17 @@ interface PollEvent { interface WriteEvent { kind: 'write' + seq: number at: number session: number units: number + /** Whether the bytes are the tracked send's own input; other writes are terminal-protocol replies. */ + input: boolean } interface SpawnEvent { kind: 'spawn' + seq: number at: number session: number pid: number @@ -47,13 +53,35 @@ interface SpawnEvent { type TimelineEvent = ChunkEvent | PollEvent | WriteEvent | SpawnEvent +/** An event before the timeline stamps its sequence number and time. */ +type RecordedEvent = { + [K in TimelineEvent['kind']]: Omit, 'seq' | 'at'> +}[TimelineEvent['kind']] + interface SendWindow { label: string + /** The exact bytes the session writes for this send; a matching write is the evidence reset. */ + input: string startedAt: number + startSeq: number settledAt: number | undefined + endSeq: number | undefined outcome: TerminalWaitReason | 'rejected' | 'pending' } +/** Replayed prompt evidence for one send window. */ +export interface PromptEvidence { + promptSeen: boolean + promptTail: string + promptTextSeen: boolean +} + +/** A chunk as the replay needs it: the sanitizer's verdict for one decoded pty chunk. */ +export interface ReplayChunk { + prompt: boolean + promptTail: string | undefined +} + const SAMPLE_UNITS = 100 const SHOWN_HEAD_CHUNKS = 2 const SHOWN_TAIL_CHUNKS = 6 @@ -93,8 +121,10 @@ function tail(text: string | undefined): string { * The session's own prompt-tail rule replayed over one window's chunks: a marker starts an * empty tail, later printable text extends it up to one unit past the controlled prompt, and * the exact prompt text is the fast-path evidence. Mirrors `LocalPtySession.onData`. + * @param chunks - the sanitizer verdicts since the send's input write, in delivery order. + * @returns The evidence the session holds after the last chunk. */ -function replayPromptEvidence(chunks: ChunkEvent[]): { promptSeen: boolean; promptTail: string; promptTextSeen: boolean } { +export function replayPromptEvidence(chunks: readonly ReplayChunk[]): PromptEvidence { let promptSeen = false let promptTail = '' for (const chunk of chunks) { @@ -111,10 +141,15 @@ function replayPromptEvidence(chunks: ChunkEvent[]): { promptSeen: boolean; prom return { promptSeen, promptTail, promptTextSeen: promptTail === CONTROLLED_PROMPT } } -/** Records one Loader-composition case's terminal traffic and formats it per send. */ +/** + * Records one Loader-composition case's terminal traffic and formats it per send. Sessions are + * sequential in that case (a fresh terminal spawns only after the previous shell exited), so a + * sanitizer is attributed to the most recently spawned terminal on its first chunk. + */ export class ReadinessTimeline { private readonly origin = performance.now() private hostFactsText: string | undefined + private seq = 0 /** * @param hostFacts - host, shell, and console facts for the header; evaluated once, on the @@ -125,7 +160,6 @@ export class ReadinessTimeline { private readonly events: TimelineEvent[] = [] private readonly sends: SendWindow[] = [] private readonly sanitizers = new WeakMap() - private sanitizerCount = 0 private spawnCount = 0 private currentLabel: string | undefined private labelUses = 0 @@ -134,6 +168,11 @@ export class ReadinessTimeline { return Math.round(performance.now() - this.origin) } + private push(event: RecordedEvent): void { + this.events.push({ ...event, seq: this.seq, at: this.now() }) + this.seq += 1 + } + /** Spy on every sanitizer's `push` so each decoded chunk is recorded with its prompt verdict. */ observeSanitizer(): void { // oxlint-disable-next-line typescript/unbound-method -- the spy body re-binds it to each sanitizer through `call`. @@ -151,13 +190,11 @@ export class ReadinessTimeline { private recordChunk(sanitizer: TerminalSanitizer, chunk: string, result: SanitizedChunk): void { let session = this.sanitizers.get(sanitizer) if (session === undefined) { - session = this.sanitizerCount - this.sanitizerCount += 1 + session = Math.max(0, this.spawnCount - 1) this.sanitizers.set(sanitizer, session) } - this.events.push({ + this.push({ kind: 'chunk', - at: this.now(), session, units: chunk.length, sample: sample(chunk), @@ -174,16 +211,25 @@ export class ReadinessTimeline { observeTerminal(handle: SubprocessTerminalHandle): void { const session = this.spawnCount this.spawnCount += 1 - this.events.push({ kind: 'spawn', at: this.now(), session, pid: handle.pid }) + this.push({ kind: 'spawn', session, pid: handle.pid }) const inspectForeground = handle.inspectForeground.bind(handle) vi.spyOn(handle, 'inspectForeground').mockImplementation(async (): Promise => { - const foreground = await inspectForeground() - this.events.push({ kind: 'poll', at: this.now(), session, foreground: foreground === undefined ? 'undefined' : JSON.stringify(foreground) }) + let foreground: SubprocessTerminalForeground | undefined + try { + foreground = await inspectForeground() + } catch (error: unknown) { + // A throwing inspection rejects the send; the window records what it threw. + this.push({ kind: 'poll', session, foreground: `threw ${error instanceof Error ? error.message : String(error)}` }) + throw error + } + this.push({ kind: 'poll', session, foreground: foreground === undefined ? 'undefined' : JSON.stringify(foreground) }) return foreground }) const write = handle.write.bind(handle) vi.spyOn(handle, 'write').mockImplementation(async (data: string): Promise => { - this.events.push({ kind: 'write', at: this.now(), session, units: data.length }) + const active = this.sends.at(-1) + const input = active !== undefined && active.endSeq === undefined && data === active.input + this.push({ kind: 'write', session, units: data.length, input }) await write(data) }) } @@ -198,26 +244,33 @@ export class ReadinessTimeline { } /** - * Open a send window and close it when the operation settles or rejects. + * Open a send window and close it when the operation settles or rejects. Windows are bounded + * by event sequence numbers, so two sends that hand over within one millisecond keep their + * own events; the millisecond stamps are for display only. * @param operation - the send returned by `terminals.startSend`. + * @param request - the request that send carries; its bytes identify the input write. */ - track(operation: TerminalSendOperation): void { + track(operation: TerminalSendOperation, request: TerminalSendRequest): void { this.labelUses += 1 const label = this.currentLabel === undefined ? `send#${this.sends.length + 1}` : this.labelUses === 1 ? this.currentLabel : `${this.currentLabel}/${this.labelUses}` - const window: SendWindow = { label, startedAt: this.now(), settledAt: undefined, outcome: 'pending' } + const window: SendWindow = { + label, + input: `${request.text}${request.submit ? '\r' : ''}`, + startedAt: this.now(), + startSeq: this.seq, + settledAt: undefined, + endSeq: undefined, + outcome: 'pending', + } this.sends.push(window) - void operation.done.then( - (settled) => { - window.settledAt = this.now() - window.outcome = settled.waitReason - }, - () => { - window.settledAt = this.now() - window.outcome = 'rejected' - }, - ) + const close = (outcome: SendWindow['outcome']): void => { + window.settledAt = this.now() + window.endSeq = this.seq + window.outcome = outcome + } + void operation.done.then((settled) => { close(settled.waitReason) }, () => { close('rejected') }) } /** @@ -228,24 +281,23 @@ export class ReadinessTimeline { format(): string { this.hostFactsText ??= this.hostFacts() const lines: string[] = [TIMELINE_HEADER, ...this.hostFactsText.length > 0 ? [this.hostFactsText] : []] - const now = this.now() let cursor = 0 - const windows: { title: string; from: number; to: number; send: SendWindow | undefined }[] = [] - // A send window spans its start through its settle instant inclusive: the poll that - // settled a send is recorded in the same millisecond as the settle. + let cursorAt = 0 + const windows: { title: string; from: number; fromAt: number; to: number; send: SendWindow | undefined }[] = [] this.sends.forEach((send, index) => { - if (send.startedAt > cursor) { - windows.push({ title: index === 0 ? 'before the first send' : 'between sends', from: cursor, to: send.startedAt, send: undefined }) + if (send.startSeq > cursor) { + windows.push({ title: index === 0 ? 'before the first send' : 'between sends', from: cursor, fromAt: cursorAt, to: send.startSeq, send: undefined }) } - const to = (send.settledAt ?? now) + 1 - windows.push({ title: `#${index + 1} ${send.label}`, from: send.startedAt, to, send }) + const to = send.endSeq ?? this.seq + windows.push({ title: `#${index + 1} ${send.label}`, from: send.startSeq, fromAt: send.startedAt, to, send }) cursor = to + cursorAt = send.settledAt ?? this.now() }) - windows.push({ title: 'after the last send', from: cursor, to: now + 1, send: undefined }) + windows.push({ title: 'after the last send', from: cursor, fromAt: cursorAt, to: this.seq, send: undefined }) for (const window of windows) { - const events = this.events.filter(event => event.at >= window.from && event.at < window.to) + const events = this.events.filter(event => event.seq >= window.from && event.seq < window.to) if (window.send === undefined && events.length === 0) continue - lines.push(...this.formatWindow(window.title, window.from, window.send, events)) + lines.push(...this.formatWindow(window.title, window.fromAt, window.send, events)) } return lines.join('\n') } @@ -260,7 +312,7 @@ export class ReadinessTimeline { lines.push(`${title} (from +${from} ms)${outcome}`) for (const event of events) { if (event.kind === 'spawn') lines.push(` +${event.at} spawn s${event.session} pid ${event.pid}`) - if (event.kind === 'write') lines.push(` +${event.at} write s${event.session} ${event.units} units`) + if (event.kind === 'write') lines.push(` +${event.at} write s${event.session} ${event.units} units (${event.input ? 'input' : 'reply'})`) } const polls = events.filter((event): event is PollEvent => event.kind === 'poll') if (polls.length > 0) { @@ -290,9 +342,10 @@ export class ReadinessTimeline { lines.push(` +${chunk.at} s${chunk.session} ${chunk.units}u text=${chunk.textLength} marker=${chunk.prompt ? 'YES' : 'no'} tail=${tail(chunk.promptTail)} ${quoted(chunk.sample)}`) } if (send !== undefined) { - // Readiness evidence resets at the send's input write; replay from the last write. - const lastWrite = events.filter(event => event.kind === 'write').at(-1) - const evidence = replayPromptEvidence(chunks.filter(chunk => lastWrite === undefined || chunk.at >= lastWrite.at)) + // Readiness evidence resets at the send's input write; terminal-protocol replies the + // emulator writes back do not reset it, so the replay starts at the last input write. + const lastInput = events.filter((event): event is WriteEvent => event.kind === 'write' && event.input).at(-1) + const evidence = replayPromptEvidence(chunks.filter(chunk => lastInput === undefined || chunk.seq > lastInput.seq)) lines.push(` replayed evidence at settle: promptSeen=${evidence.promptSeen} promptTail=${tail(evidence.promptTail)} promptTextSeen=${evidence.promptTextSeen}`) } } From 0310066b39980c78cb3d93da3ebfcbed8dbb66aa Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:30:26 +0800 Subject: [PATCH 15/86] test(pwsh): build the fake terminal handle without an unknown cast The unit spec's fake handle is a typed object whose methods read a mutable state record, so the no-new-unknown-casts gate has nothing to reject. --- .../tests/readiness-timeline.spec.ts | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 deletions(-) diff --git a/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.spec.ts b/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.spec.ts index 153c2c0bd4..2d090b31a0 100644 --- a/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.spec.ts +++ b/packages/shell/tool-pwsh-persistent/tests/readiness-timeline.spec.ts @@ -1,6 +1,6 @@ import { Readable } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SubprocessOutcome, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessOutcome, SubprocessTerminalForeground, SubprocessTerminalHandle } from '@deepseek-ai/dsh-subprocess' import type { TerminalSendOperation, TerminalSendResult } from '@deepseek-ai/dsh-terminal' import { CONTROLLED_PROMPT, TerminalSanitizer } from '@deepseek-ai/dsh-terminal-bash/src/sanitize.ts' import { ReadinessTimeline, TIMELINE_HEADER, replayPromptEvidence } from './readiness-timeline.ts' @@ -11,36 +11,29 @@ afterEach(() => { vi.restoreAllMocks() }) -interface FakeTerminal { - handle: SubprocessTerminalHandle - foreground: { processGroupId: number; inputWaiting: boolean } | undefined +interface FakeTerminalState { + foreground: SubprocessTerminalForeground | undefined inspectError: Error | undefined - writes: string[] } -function fakeTerminal(pid: number): FakeTerminal { - const fake: FakeTerminal = { - foreground: { processGroupId: pid, inputWaiting: false }, - inspectError: undefined, - writes: [], - handle: undefined as unknown as SubprocessTerminalHandle, - } - const output = new Readable({ read() {} }) - fake.handle = { +function fakeTerminal(pid: number): { handle: SubprocessTerminalHandle; state: FakeTerminalState; writes: string[] } { + const state: FakeTerminalState = { foreground: { processGroupId: pid, inputWaiting: false }, inspectError: undefined } + const writes: string[] = [] + const handle: SubprocessTerminalHandle = { pid, - output, + output: new Readable({ read() {} }), done: new Promise(() => {}), - async write(data: string) { fake.writes.push(data) }, + async write(data: string) { writes.push(data) }, async resize() {}, async inspectForeground() { - if (fake.inspectError !== undefined) throw fake.inspectError - return fake.foreground + if (state.inspectError !== undefined) throw state.inspectError + return state.foreground }, async inspectActivity() { return { state: 'unknown' as const, revision: 0 } }, async signalForeground() { return pid }, async terminate() {}, } - return fake + return { handle, state, writes } } function fakeSend(): { operation: TerminalSendOperation; settle: (waitReason: TerminalSendResult['waitReason']) => void; reject: () => void } { @@ -107,7 +100,7 @@ describe('ReadinessTimeline', () => { timeline.track(b.operation, { text: 'echo b', submit: true }) await terminal.handle.write('echo b\r') sanitizer.push('b-output\r\n') - terminal.inspectError = new Error('snapshot unreadable') + terminal.state.inspectError = new Error('snapshot unreadable') await expect(terminal.handle.inspectForeground()).rejects.toThrow('snapshot unreadable') b.reject() await settled() From d2d755077b1f7426010fb6879fd691d436337c90 Mon Sep 17 00:00:00 2001 From: Huanqi Cao Date: Mon, 28 Sep 2026 11:46:04 +0800 Subject: [PATCH 16/86] fix(sandbox-windows-acl): request the diagnosis escalation after a denial The bundled diagnosis skill told the model to run its script unconfined from the first call, which forbids trying the confined path and leaves no path but handing the command to the user. Run it in the current sandbox first, and request one escalated retry of the same call when a denial or an unwritable report directory prevents it; a refused or unavailable approval reports the path as undiagnosed. Refresh the recorded skill replay and re-record the README pairing hashes. --- 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 +- .../assets/diagnose-windows-sandbox-acl/SKILL.md | 4 ++-- snapshots/session/windows-acl-skill/session.v4.jsonl | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index 5e0e3dbb9f..95d12002ac 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml @@ -57,8 +57,8 @@ en: 817eb1b7de13872e zh: 06fe175e41e7459b /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work: - en: b390e8c1cc9a85fa - zh: 67960a00330aee2e + en: 046831a864786b2a + zh: 4a261fbab3396ea4 /deepseek-ai-dsh-sandbox-windows-acl/known-limitations-and-deferred-work/dev-note: en: cc273320f6a1beaf zh: 5c57a312ddc50651 diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index b9523659ce..402071c72a 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -188,7 +188,7 @@ These limits define when the backend is a poor fit or needs special operational - **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** — 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. -- **The bundled diagnosis skill needs an unconfined caller** — both the classification and the repairs read or write security descriptors a confined child cannot reach, so the session must escalate that one call; with no approval channel the escalation fails closed and the model can only hand the user the exact command to run. +- **The bundled diagnosis skill needs an unconfined caller** — both the classification and the repairs read or write security descriptors a confined child cannot reach, so the session must escalate that one call; with no approval channel the escalation fails closed and the path stays undiagnosed. - **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 c08ac4217f..623e9cea06 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -190,7 +190,7 @@ Windows 上多一个目录条目:技能描述随目录进入上下文,正文 - **读侧隔离与网络策略不在范围内**——`WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 - **读取会被其他基于 AppContainer 的工具以包 SID 授权过的对象挡住。** 在本机上,当文件的 DACL 携带针对包 SID(`S-1-15-2-…`)的 ACE 时,Low 完整性的令牌无法访问它——即使同一份 DACL 同时向用户授予完全控制、向 Everyone 授予读取(已观测:只给新文件加这一条 ACE 即可复现拒绝,补授 Everyone 读取无法解除,而同样内容复制到别处仍可读)。其背后的内核规则尚未确证,也不由本包掌控;以 AppContainer 自我隔离的工具正是会写入这类 ACE,因此被它们标记过的目录树对本后端的子进程将不可读。移除外来 ACE(或重新安装受影响的目录树)即可恢复访问。 - **宽目录与 FAT 卷警告已推迟;FAT 类残留未经验证。** UI 侧警告尚未实现,FAT 卷作为授权根会大声失败,而授权根之外的 FAT 类目标不存储安全描述符;其有效完整性标签由系统分配而非记录在对象上,因此标签层在该处的行为未经测试。FAT 仍被视为遗留残留。 -- **随包发布的诊断技能需要不受限的调用者。** 无论分类还是修复,都要读写受限子进程够不到的安全描述符,因此会话必须为那一次调用升权;没有审批通道时升权 fail-closed,模型只能把确切的命令交给用户自行执行。 +- **随包发布的诊断技能需要不受限的调用者。** 无论分类还是修复,都要读写受限子进程够不到的安全描述符,因此会话必须为那一次调用升权;没有审批通道时升权 fail-closed,该路径保持未诊断。 - **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/assets/diagnose-windows-sandbox-acl/SKILL.md b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md index e920ab0f72..68f3a1f8df 100644 --- a/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md +++ b/packages/sandbox/sandbox-windows-acl/assets/diagnose-windows-sandbox-acl/SKILL.md @@ -11,11 +11,11 @@ description: 'Use on Windows for unexpected DSH sandbox access denials: workspac Diagnose unexpected writes/listing denied inside a `workspace-write` workspace, or reads the signed-in user should plainly have. Root-only failure and uniform failure are both eligible. -Stop and explain expected denials: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, or ConstrainedLanguage errors for .NET/COM/reflection. Use an authorized unconfined call or another permitted operation; do not repair ACLs for these cases. +Stop and explain expected denials: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, or ConstrainedLanguage errors for .NET/COM/reflection. Request an approval for the one call you still need; do not repair ACLs for these cases. ## Read the report before choosing a repair -Resolve the script from this skill's resource directory. Run it **unconfined from the first diagnostic call**: the confined token cannot inspect the conflicting object. This does not elevate the Windows token. Use one path per invocation; do not repeat `-Path` or pass a comma-separated string to `pwsh -File`. +Resolve the script from this skill's resource directory. Run it in the current sandbox first: classification completes under `workspace-write` with a writable `-Out` directory, while `read-only` cannot write one and refuses. When a denial or an unwritable report directory prevents the call, request one escalation of that same call through the normal approval path instead of assuming the diagnosis cannot run; if approval is refused, unavailable to the session, or forbidden by policy, report the path as undiagnosed and stop. Unconfined does not elevate the Windows token. Use one path per invocation; do not repeat `-Path` or pass a comma-separated string to `pwsh -File`. ```powershell & '\scripts\diagnose-windows-sandbox-acl.ps1' -Path '' -Out '' -Compact diff --git a/snapshots/session/windows-acl-skill/session.v4.jsonl b/snapshots/session/windows-acl-skill/session.v4.jsonl index 6c169e06f7..6b1e51078b 100644 --- a/snapshots/session/windows-acl-skill/session.v4.jsonl +++ b/snapshots/session/windows-acl-skill/session.v4.jsonl @@ -15,7 +15,7 @@ {"type":"session/title","data":{"title":"Load the diagnose-windows-sandbox-acl sk","messageSeqs":[8],"source":{"kind":"fallback"}}} {"type":"assistant/message","data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"Load the requested skill."},{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:5}}"},"usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5},"stream":[{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430688,"index":0,"dt":[],"texts":["Load the requested skill."]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}},{"type":"tool-call-chunks","time0":1788699430688,"index":1,"dt":[],"id":"call_skill_load","name":"skill","args":["{\"name\":\"diagnose-windows-sandbox-acl\"}"]},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"Load the requested skill."}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"usage","usage":{"inputTokens":100,"outputTokens":20,"cacheReadTokens":0,"reasoningTokens":5}}},{"type":"chunk","time":1788699430688,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}]},"surfaceOp":"append"} {"type":"tool/call","data":{"turn":1,"step":1,"callId":"call_skill_load","name":"skill","arguments":"{\"name\":\"diagnose-windows-sandbox-acl\"}"}} -{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/diagnose-windows-sandbox-acl\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Diagnose Windows sandbox ACL failures\n\n**Stop after any failed/refused repair or failed verification.** A completed DACL write is not a successful repair. Never continue from a failed grant to `-Fix`, repeat the grant, or remove a deny ACE. The script automatically restores attempted changes from the failed invocation; verify its rollback result. Restore earlier successful invocations in reverse order using their exact recovery commands, then stop. A later backup alone cannot undo earlier repairs.\n\n## Decide whether diagnosis applies\n\nDiagnose unexpected writes/listing denied inside a `workspace-write` workspace, or reads the signed-in user should plainly have. Root-only failure and uniform failure are both eligible.\n\nStop and explain expected denials: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, or ConstrainedLanguage errors for .NET/COM/reflection. Use an authorized unconfined call or another permitted operation; do not repair ACLs for these cases.\n\n## Read the report before choosing a repair\n\nResolve the script from this skill's resource directory. Run it **unconfined from the first diagnostic call**: the confined token cannot inspect the conflicting object. This does not elevate the Windows token. Use one path per invocation; do not repeat `-Path` or pass a comma-separated string to `pwsh -File`.\n\n```powershell\n& '\\scripts\\diagnose-windows-sandbox-acl.ps1' -Path '' -Out '' -Compact\nexit $LASTEXITCODE\n```\n\nSubstitute full, quoted paths. Choose a persistent, user-owned `-Out` directory, preferably beside the failing workspace within the authorized tree. Never put reports or backups in the skill resource directory: it is deleted when the skill unloads. `-Compact` saves a unique full JSONL report and prints one `REPORT` summary. Diagnosis changes no ACL or existing content; it only writes this requested report. The summary includes all `inspectedPaths`, findings, decisions, ACL actions, verification, rollback state and `nextAction`. Use the summary to decide; read only specific records from the saved report when evidence is missing, without dumping the whole report or reading the implementation to rediscover these instructions.\n\nEach full record has `kind`, `operation`, `path`, `status`, `reason`, `details`. Unknown observations remain unknown. A deny ACE's presence alone does not establish causation. DSH provisions `S-1-4-*` workspace grants and an Everyone `DeleteSubdirectoriesAndFiles` deny; these are expected confinement entries, not package conflicts or automatically unresolved faults. Coexisting package ACEs may still block the confined child even when provisioning fails first; do not dismiss either finding. `completed` actions only confirm API execution. Check verification and the final summary. Missing summary means unconfirmed completion: inspect the report and recovery artifacts before doing anything else.\n\n| Verdict | Next step, only with complete observations |\n|---|---|\n| `CULPRIT` | Explain the affected paths and individual package allow SIDs; select `-Fix`. |\n| `PRECONDITION` | Explain missing effective `WRITE_DAC`/`WRITE_OWNER`; select `-GrantFullControl` if the caller has `WRITE_DAC`. |\n| `BOTH` | Grant on the affected object first. Only after verified success may a separate invocation use `-Fix`. Failure ends this sequence. |\n| `UNREADABLE`, `INCOMPLETE`, `NOT_THIS_CLASS` | Report observations and stop; do not infer a safe repair. |\n\n## Run the selected repair\n\nExplain the finding and cost first. `-Fix` removes individual `S-1-15-2-*` allow ACEs at their explicit sources, ancestor first, then verifies inherited entries disappeared; well-known groups ending in 1 or 2 are preserved. This removes those packages' access. `-GrantFullControl` adds a current-user allow ACE; it cannot cancel an explicit deny. Both preserve owner, inheritance and SACL, including Low integrity labels. Low executable labels and their effects outside DSH are outside this repair's scope. Full control supplies `WRITE_DAC` and `WRITE_OWNER`; taking ownership alone does not.\n\nAppend `-AllowRoot ''` and **exactly one** of `-Fix` or `-GrantFullControl` to the diagnostic command, before `exit`. Keep `-Compact -Out`. Every explicit source must be strictly inside that root; reparse paths and managed application trees (`LocalAppData\\Packages`, `ProgramFiles\\WindowsApps`) are refused. Never widen the authorized root to reach an ancestor. Missing effective `WRITE_DAC` in the unconfined caller requires stopping and reporting the path and missing rights for permission-policy review. Extracted scripts and recovery copies are user-writable: never supply commands to run them elevated, initiate UAC/`runas`, or run the whole agent elevated.\n\nFollow `nextAction`: `stop` ends repairs; `restore_pending_then_stop` requires the reported recovery commands in their listed order, then stop. Verified rollback does not turn a failed repair into success. Keep full report/backup paths and exact recovery commands for every invocation; never abbreviate executable paths. On recovery failure, stop and report the remaining commands without more repairs.\n\nFor `verify_original_confined_operation`, repeat the original failed operation in the original confined context. If it still fails, restore this workflow's successful repairs in reverse order, report the error and stop. Do not start another repair cycle.\n\nUse the user's language for progress and final reporting. Concisely report observations and analyzed paths (including relevant ancestors), actions and reasons, verification, and the next step; link full reports. Label any authorized unconfined fallback as such, not as successful repair. Explain that another tool may recreate a removed package ACE. Never write probe files, edit ACLs by hand, change owners, erase denies, or replace child permissions recursively. Do not provide manual ACL-edit commands for the user either: only bundled repairs and generated recovery commands are supported; unresolved denies require permission-policy review or moving the workspace.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","data":{"turn":1,"step":1,"message":{"role":"tool","source":{"kind":"tool","callId":"call_skill_load"},"toolCallId":"call_skill_load","content":[{"type":"text","text":"\n\nBase directory for this skill: {{cwd}}/.dsh/skills/diagnose-windows-sandbox-acl\nResolve relative paths mentioned by this skill against the base directory before using them. Load referenced resources only as needed.\n\n\n\n# Diagnose Windows sandbox ACL failures\n\n**Stop after any failed/refused repair or failed verification.** A completed DACL write is not a successful repair. Never continue from a failed grant to `-Fix`, repeat the grant, or remove a deny ACE. The script automatically restores attempted changes from the failed invocation; verify its rollback result. Restore earlier successful invocations in reverse order using their exact recovery commands, then stop. A later backup alone cannot undo earlier repairs.\n\n## Decide whether diagnosis applies\n\nDiagnose unexpected writes/listing denied inside a `workspace-write` workspace, or reads the signed-in user should plainly have. Root-only failure and uniform failure are both eligible.\n\nStop and explain expected denials: writes outside the workspace, any write in `read-only`, piped grandchild `spawn EPERM`, or ConstrainedLanguage errors for .NET/COM/reflection. Request an approval for the one call you still need; do not repair ACLs for these cases.\n\n## Read the report before choosing a repair\n\nResolve the script from this skill's resource directory. Run it in the current sandbox first: classification completes under `workspace-write` with a writable `-Out` directory, while `read-only` cannot write one and refuses. When a denial or an unwritable report directory prevents the call, request one escalation of that same call through the normal approval path instead of assuming the diagnosis cannot run; if approval is refused, unavailable to the session, or forbidden by policy, report the path as undiagnosed and stop. Unconfined does not elevate the Windows token. Use one path per invocation; do not repeat `-Path` or pass a comma-separated string to `pwsh -File`.\n\n```powershell\n& '\\scripts\\diagnose-windows-sandbox-acl.ps1' -Path '' -Out '' -Compact\nexit $LASTEXITCODE\n```\n\nSubstitute full, quoted paths. Choose a persistent, user-owned `-Out` directory, preferably beside the failing workspace within the authorized tree. Never put reports or backups in the skill resource directory: it is deleted when the skill unloads. `-Compact` saves a unique full JSONL report and prints one `REPORT` summary. Diagnosis changes no ACL or existing content; it only writes this requested report. The summary includes all `inspectedPaths`, findings, decisions, ACL actions, verification, rollback state and `nextAction`. Use the summary to decide; read only specific records from the saved report when evidence is missing, without dumping the whole report or reading the implementation to rediscover these instructions.\n\nEach full record has `kind`, `operation`, `path`, `status`, `reason`, `details`. Unknown observations remain unknown. A deny ACE's presence alone does not establish causation. DSH provisions `S-1-4-*` workspace grants and an Everyone `DeleteSubdirectoriesAndFiles` deny; these are expected confinement entries, not package conflicts or automatically unresolved faults. Coexisting package ACEs may still block the confined child even when provisioning fails first; do not dismiss either finding. `completed` actions only confirm API execution. Check verification and the final summary. Missing summary means unconfirmed completion: inspect the report and recovery artifacts before doing anything else.\n\n| Verdict | Next step, only with complete observations |\n|---|---|\n| `CULPRIT` | Explain the affected paths and individual package allow SIDs; select `-Fix`. |\n| `PRECONDITION` | Explain missing effective `WRITE_DAC`/`WRITE_OWNER`; select `-GrantFullControl` if the caller has `WRITE_DAC`. |\n| `BOTH` | Grant on the affected object first. Only after verified success may a separate invocation use `-Fix`. Failure ends this sequence. |\n| `UNREADABLE`, `INCOMPLETE`, `NOT_THIS_CLASS` | Report observations and stop; do not infer a safe repair. |\n\n## Run the selected repair\n\nExplain the finding and cost first. `-Fix` removes individual `S-1-15-2-*` allow ACEs at their explicit sources, ancestor first, then verifies inherited entries disappeared; well-known groups ending in 1 or 2 are preserved. This removes those packages' access. `-GrantFullControl` adds a current-user allow ACE; it cannot cancel an explicit deny. Both preserve owner, inheritance and SACL, including Low integrity labels. Low executable labels and their effects outside DSH are outside this repair's scope. Full control supplies `WRITE_DAC` and `WRITE_OWNER`; taking ownership alone does not.\n\nAppend `-AllowRoot ''` and **exactly one** of `-Fix` or `-GrantFullControl` to the diagnostic command, before `exit`. Keep `-Compact -Out`. Every explicit source must be strictly inside that root; reparse paths and managed application trees (`LocalAppData\\Packages`, `ProgramFiles\\WindowsApps`) are refused. Never widen the authorized root to reach an ancestor. Missing effective `WRITE_DAC` in the unconfined caller requires stopping and reporting the path and missing rights for permission-policy review. Extracted scripts and recovery copies are user-writable: never supply commands to run them elevated, initiate UAC/`runas`, or run the whole agent elevated.\n\nFollow `nextAction`: `stop` ends repairs; `restore_pending_then_stop` requires the reported recovery commands in their listed order, then stop. Verified rollback does not turn a failed repair into success. Keep full report/backup paths and exact recovery commands for every invocation; never abbreviate executable paths. On recovery failure, stop and report the remaining commands without more repairs.\n\nFor `verify_original_confined_operation`, repeat the original failed operation in the original confined context. If it still fails, restore this workflow's successful repairs in reverse order, report the error and stop. Do not start another repair cycle.\n\nUse the user's language for progress and final reporting. Concisely report observations and analyzed paths (including relevant ancestors), actions and reasons, verification, and the next step; link full reports. Label any authorized unconfined fallback as such, not as successful repair. Explain that another tool may recreate a removed package ACE. Never write probe files, edit ACLs by hand, change owners, erase denies, or replace child permissions recursively. Do not provide manual ACL-edit commands for the user either: only bundled repairs and generated recovery commands are supported; unresolved denies require permission-policy review or moving the workspace.\n\n"}],"isError":false,"id":"{{message:6}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","data":{"turn":1,"step":1}} {"type":"step/start","data":{"turn":1,"step":2}} {"type":"assistant/message","data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The skill is loaded."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{message:7}}"},"usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4},"stream":[{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}},{"type":"reasoning-chunks","time0":1788699430709,"index":0,"dt":[],"texts":["The skill is loaded."]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-start","index":1,"blockType":"text"}},{"type":"text-chunks","time0":1788699430709,"index":1,"dt":[],"texts":["DONE"]},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The skill is loaded."}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"usage","usage":{"inputTokens":180,"outputTokens":10,"cacheReadTokens":0,"reasoningTokens":4}}},{"type":"chunk","time":1788699430709,"chunk":{"type":"finish","reason":{"kind":"stop"}}}]},"surfaceOp":"append"} From 32ade298b511b8c2ce084524bf589d3a7d3205bb Mon Sep 17 00:00:00 2001 From: 07akioni <07akioni2@gmail.com> Date: Mon, 28 Sep 2026 11:58:06 +0800 Subject: [PATCH 17/86] fix(telemetry): align desktop product reporting timeouts --- packages/bundle/web-app/README.i18n.yaml | 4 ++-- packages/bundle/web-app/README.md | 2 ++ packages/bundle/web-app/README.zh.md | 2 ++ packages/bundle/web-app/cordis.patch.yml | 5 +++-- packages/bundle/web-app/tests/product-analytics.spec.ts | 4 +++- 5 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 161739e50d..f8909fb4db 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -6,8 +6,8 @@ en: af2e4e5296596e8f zh: 70c0b5ac93209d00 /deepseek-ai-dsh-web-app: - en: 8523b283e794cf59 - zh: 7764704f7e98f19b + en: 1c44c4111fe600df + zh: f1014e84901a5240 /deepseek-ai-dsh-web-app/summary: en: 0ebb6c3ca8c0a84e zh: 4d85e02c769ddae0 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 952ff9cafa..e9a0d53fd4 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -9,6 +9,8 @@ English | [中文](README.zh.md) Desktop analytics follows the [product collection policy](../../client/product-analytics/README.md), including its live application setting. Web usage is excluded. +Desktop analytics schedules partial batches every 30 seconds, with a 15-second exporter timeout and a 20-second processor timeout. Shutdown waits at most 2 seconds, so pending events may be lost on exit. + ## Summary Run `dsh --profile web` to open an interactive browser GUI with chat, model and settings management, and session history. It uses the same model access, tools, and safety defaults as other dsh surfaces. Startup prints an authenticated URL and normally opens it in the default browser; SSH sessions and `--no-open` leave the URL for manual opening. You can change the port and allow extra hosts, but cannot bind all network interfaces. Choose this package for interactive browser work; use `dsh-headless` for one-shot command-line tasks. diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index a82057b09f..46d6ac451a 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -9,6 +9,8 @@ kind: "package-bundle" 桌面埋点遵循[产品采集策略](../../client/product-analytics/README.zh.md)及其动态应用配置,不包含 Web 使用情况。 +桌面埋点每 30 秒调度未满批次,exporter 超时为 15 秒,processor 超时为 20 秒。退出时最多等待 2 秒,因此尚未发送完成的事件可能丢失。 + ## 概述 运行 `dsh --profile web`,打开提供聊天、模型与设置管理以及会话历史的交互式浏览器 GUI。它使用与其他 dsh 表层相同的模型访问、工具与安全默认值。启动时会打印带认证信息的 URL,通常还会在默认浏览器中打开;SSH 会话和 `--no-open` 会保留该 URL,供你手动打开。你可以更改端口并允许额外主机,但不能绑定所有网络接口。需要在浏览器中交互式工作时选择本包;一次性的命令行任务应使用 `dsh-headless`。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 0659d73674..6a70826264 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -46,8 +46,9 @@ name: '@deepseek-ai/dsh-host-product-telemetry-otel' disabled: !!js "ctx.get('profileContext')?.name !== 'desktop'" config: - timeoutMillis: 1000 - exportTimeoutMillis: 1500 + scheduledDelayMillis: 30000 + timeoutMillis: 15000 + exportTimeoutMillis: 20000 shutdownTimeoutMillis: 2000 serviceName: deepseek-harness-desktop serviceVersion: !!js process.env.DSH_CLIENT_VERSION diff --git a/packages/bundle/web-app/tests/product-analytics.spec.ts b/packages/bundle/web-app/tests/product-analytics.spec.ts index 178d775d6f..f90f7fade4 100644 --- a/packages/bundle/web-app/tests/product-analytics.spec.ts +++ b/packages/bundle/web-app/tests/product-analytics.spec.ts @@ -47,7 +47,9 @@ it.each(['desktop', 'web'])('limits collection and its shutdown to the Desktop l expect(ctx.get('productAnalytics')).toBeUndefined() return } - expect(rows[0]!.config).toMatchObject({ timeoutMillis: 1000, exportTimeoutMillis: 1500, shutdownTimeoutMillis: 2000 }) + expect(rows[0]!.config).toMatchObject({ + scheduledDelayMillis: 30000, timeoutMillis: 15000, exportTimeoutMillis: 20000, shutdownTimeoutMillis: 2000, + }) const analytics = ctx.productAnalytics const analyticsFiber = ctx.loader.resolve('product-analytics').fiber const telemetryFiber = entry.fiber From 9ab3f2c990f7c02fab7050e9e05a4c285b23e715 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 28 Sep 2026 12:56:52 +0800 Subject: [PATCH 18/86] ci(sandbox): grant the darwin unit parity job the coverage lanes' test budget The unit-darwin job ran the whole unit inventory at Vitest's 5000 ms default on a shared 3-vCPU macos-latest runner; the first two master runs after #5292 each timed out a different set of cases that pass in under a second on a quiet host. Every other whole-inventory lane raises the budget through DSH_COVERAGE_TEST_TIMEOUT_MS=90000. The job now sets that variable and runs `pnpm run check:ci:unit`, a run-gates aggregate that is `pnpm run test` (native build, then `vitest run`) through the path that consumes it. ci-unit receives no CI worker settings, so the inventory inherits the environment `pnpm run test` finds: on a 3-CPU host the defaults would inject DSH_COVERAGE_PARTITIONS=2, which run-gates.spec.ts reads as a partitioned coverage lane. Fixes #5325 --- ...rial-cross-platform-ci-reference.i18n.yaml | 4 +- ...7-21-serial-cross-platform-ci-reference.md | 2 +- ...1-serial-cross-platform-ci-reference.zh.md | 2 +- .github/workflows/sandbox.yml | 9 ++++- package.json | 1 + scripts/ci-workflow.spec.ts | 18 +++++++++ scripts/run-gates.spec.ts | 33 +++++++++++++++++ scripts/run-gates.ts | 37 +++++++++++++++++-- 8 files changed, 97 insertions(+), 9 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index c161ba26e8..3cb0aae486 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml @@ -9,8 +9,8 @@ en: 4fb1c27a631b32d3 zh: 9dbe9a4b9aadfca9 /agent-note-serial-cross-platform-ci-reference/decision: - en: 7c58d88d4c002f19 - zh: a3b961be49f2a96b + en: de8d4ab11006cbdb + zh: 7e338f387244fcec /agent-note-serial-cross-platform-ci-reference/alternatives-considered: en: 3610152c5c4e93af zh: f4c09d327716f750 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index ca2b47c1d0..d65ac05b08 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -24,7 +24,7 @@ Platform ownership remains explicit inside that complete aggregate. `terminal-ba The macOS reference runs the ordinary Vitest project in forked processes. Node 24 on macOS arm64 has aborted in its CJS lexer from a worker thread; the process boundary contains that external runtime failure without removing any test from the aggregate, while Linux and Windows retain the lower-overhead thread pool. Repository-owned races are fixed at their observation boundaries: dev bundle polling stages each candidate table, graph, and watch-baseline map before publishing a rescan, and a missing bundle remains dirty until a successful content hash. PTY readiness retains a prompt candidate while polling checks foreground ownership; the ordinary silence bound covers inherited markers from interactive children. Real PTY fixtures assemble synchronization tokens at runtime so the interactive shell's input echo cannot satisfy a child-readiness wait. The live-link package-manager e2e preserves the workflow-prepared Corepack home and pnpm metadata/store caches while isolating the other managers' mutable caches, so it does not discard reusable package-manager state before the install. -The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. A fifth job, `unit-darwin`, runs the complete unit suite on `macos-latest` under its own budget, so the darwin-only unit expectations keep a platform oracle without holding the Seatbelt leg to the unit suite's duration. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge. +The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow belongs to the reference side of the same split. Its bwrap, Landlock x64/arm64, and Seatbelt real-kernel matrix runs only after a push to `master`. Those four jobs are diagnostic: they are not branch-protection requirements and do not feed `all checks passed` across workflow files. A fifth job, `unit-darwin`, runs the complete unit suite on `macos-latest` under its own budget, so the darwin-only unit expectations keep a platform oracle without holding the Seatbelt leg to the unit suite's duration. That job grants `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`, the coverage and serial lanes' key and value, and runs the unit inventory through the `ci-unit` aggregate that consumes it, so cases that inherit Vitest's defaults keep those lanes' budget on the shared macOS runner; under the root config's inline projects only the per-test flag currently reaches the tests, while the hook and `expect.poll` flags stay at Vitest's defaults. Pull-request CI still checks sandbox source through its ordinary unit and coverage inventory; the host-kernel and packed-install proofs report after merge. Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The ci-master and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 4feb6c3ae8..5c105a271f 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -24,7 +24,7 @@ Status: implemented macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上的 Node 24 曾在工作线程中执行 CJS 词法分析器时异常终止;进程边界能够隔离这一外部运行时故障,且无需从聚合流程中删除任何测试,而 Linux 与 Windows 仍使用开销更低的线程池。仓库自身引入的竞态均在相应的观测边界修复:开发构建产物的轮询逻辑每次发布重新扫描结果前,都会先暂存候选表、候选图和候选监视基线映射;构建产物缺失后会一直保持脏状态,直到成功计算内容哈希。PTY 就绪检测会在轮询检查前台进程组归属期间保留提示符候选项;常规静默时限也涵盖从交互式子进程继承而来的标记。真实 PTY fixture 会在运行时拼接同步标记,使就绪等待逻辑不会把交互式 shell 的输入回显误判为子进程已就绪。实时链接场景下的包管理器 e2e 会保留由工作流预先准备的 Corepack 主目录、pnpm 元数据缓存和 store 缓存,同时隔离其他包管理器的可变缓存,因此不会在安装前丢弃可复用的包管理器状态。 -独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。第五个作业 `unit-darwin` 在 `macos-latest` 上以自己的预算运行完整单元测试套件,让只在 darwin 上生效的单元测试预期保有平台判据,同时不让 Seatbelt 作业受制于单元测试套件的时长。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。 +独立的 [Sandbox](../../../../.github/workflows/sandbox.yml) 工作流属于同一职责划分中的参考侧。其 bwrap、Landlock x64/arm64 与 Seatbelt 真实内核矩阵只在向 `master` 推送后运行。这四个作业仅用于诊断:它们既不是分支保护的必需项,也不会跨工作流计入 `all checks passed`。第五个作业 `unit-darwin` 在 `macos-latest` 上以自己的预算运行完整单元测试套件,让只在 darwin 上生效的单元测试预期保有平台判据,同时不让 Seatbelt 作业受制于单元测试套件的时长。该作业授予与覆盖率、串行通道相同键值的 `DSH_COVERAGE_TEST_TIMEOUT_MS=90000`,并通过消费该变量的 `ci-unit` 聚合运行单元测试清单,因此继承 Vitest 默认值的用例在共享的 macOS 运行器上保有这些通道的预算;在根配置的内联 projects 下,目前只有单测试标志到达测试,hook 与 `expect.poll` 标志仍停留在 Vitest 默认值。拉取请求 CI 仍通过常规的单元测试与覆盖率清单检查沙箱源码;宿主内核与 packed-install 验证在合并后报告结果。 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。ci-master 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 diff --git a/.github/workflows/sandbox.yml b/.github/workflows/sandbox.yml index f40be2a778..46c6ac71f2 100644 --- a/.github/workflows/sandbox.yml +++ b/.github/workflows/sandbox.yml @@ -157,5 +157,12 @@ jobs: - name: Install (immutable) run: pnpm install --frozen-lockfile + # The same key and value the coverage and serial lanes grant: on the + # shared 3-vCPU runner, cases that inherit Vitest's 5000 ms default timed + # out in spawn.spec.ts and changes.spec.ts (run 36370945526) while + # passing in under a second on a quiet host. The ci-unit aggregate is + # `pnpm run test` through the path that consumes the variable. - name: Unit tests (darwin parity) - run: pnpm run test + env: + DSH_COVERAGE_TEST_TIMEOUT_MS: '90000' + run: pnpm run check:ci:unit diff --git a/package.json b/package.json index 4317f7dc94..297c3a448f 100644 --- a/package.json +++ b/package.json @@ -82,6 +82,7 @@ "check:ci:static": "tsx scripts/run-gates.ts ci-static", "check:ci:lint:contracts-ready": "tsx scripts/run-gates.ts ci-lint-contracts-ready", "check:ci:coverage": "tsx scripts/run-gates.ts ci-coverage", + "check:ci:unit": "tsx scripts/run-gates.ts ci-unit", "check:ci:bench": "tsx scripts/run-gates.ts ci-bench", "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index db3315e074..d37ce19871 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -407,6 +407,24 @@ describe('CI workflow', () => { expect(observational?.env).toMatchObject({ DSH_GATE_FAIL_FAST: '' }) }) + it('runs the darwin unit parity inventory at the coverage lanes\' test budget', () => { + const coverage = workflowJob(loadWorkflow('.github/workflows/ci.yml'), 'node-24-coverage') + if (!isRecord(coverage.env) || typeof coverage.env.DSH_COVERAGE_TEST_TIMEOUT_MS !== 'string') { + throw new TypeError('node-24-coverage must grant DSH_COVERAGE_TEST_TIMEOUT_MS') + } + const unitDarwin = workflowJob(loadWorkflow('.github/workflows/sandbox.yml'), 'unit-darwin') + if (!Array.isArray(unitDarwin.steps)) throw new TypeError('unit-darwin job must define steps') + const unit = unitDarwin.steps.filter(isRecord).find(step => step.name === 'Unit tests (darwin parity)') + // The whole unit inventory on a shared macos-latest runner pays the same + // scheduling delay the coverage lanes absorb; the ci-unit aggregate is the + // `pnpm run test` path that consumes the variable, and the value is the + // coverage lane's so the two budget classes cannot drift apart. + expect(unit).toMatchObject({ + run: 'pnpm run check:ci:unit', + env: { DSH_COVERAGE_TEST_TIMEOUT_MS: coverage.env.DSH_COVERAGE_TEST_TIMEOUT_MS }, + }) + }) + it('gates standalone keyless blacksmith jobs and benchmark tiers on the failover variables', () => { const expectedFilenames = workflowJob(loadWorkflow('.github/workflows/expected-filenames.yml'), 'expected-filenames') const sandbox = workflowJob(loadWorkflow('.github/workflows/sandbox.yml'), 'sandbox-e2e') diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 9d7a929446..92aa0e4deb 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -183,6 +183,13 @@ describe('CI worker allocation', () => { .not.toHaveProperty('DSH_COVERAGE_PARTITIONS') }) + it('leaves the plain unit inventory with the environment pnpm run test finds', () => { + // A 3-CPU host would otherwise receive DSH_COVERAGE_PARTITIONS=2, which + // the inventory's own coverage-gate tests read as a partitioned lane. + expect(ciWorkerEnvironment('ci-unit', {}, 3)).toEqual({}) + expect(ciWorkerEnvironment('ci-unit', {}, 16)).toEqual({}) + }) + it.each(['0', '-1', 'NaN', '2.5'])('rejects invalid worker budget %s', (raw) => { expect(() => ciWorkerEnvironment('ci-coverage', { DSH_COVERAGE_MAX_WORKERS: raw }, 16)) .toThrow('DSH_COVERAGE_MAX_WORKERS must be a positive integer') @@ -200,6 +207,7 @@ describe('gate graph validation', () => { 'ci-static', 'ci-lint-contracts-ready', 'ci-coverage', + 'ci-unit', 'ci-bench', 'ci-snapshot', 'ci-artifacts', @@ -548,6 +556,31 @@ describe('gate graph validation', () => { .toThrow('DSH_COVERAGE_TEST_TIMEOUT_MS must be a positive integer') }) + it('runs the plain unit inventory after the native build under the same configured budget', () => { + const { scripts } = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { + scripts: Record + } + expect(scripts['check:ci:unit']).toBe('tsx scripts/run-gates.ts ci-unit') + + const gates = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', '15000', () => + withPnpmEntrypoint(() => gatesForMode('ci-unit'))) + expect(gates.map(gate => gate.id)).toEqual(['native-system', 'unit']) + expect(gates[0]).toMatchObject({ displayCommand: 'pnpm run build:native-system' }) + expect(gates[1]).toMatchObject({ + label: 'test', + needs: ['native-system'], + streamOutput: true, + args: ['/private/pnpm.cjs', 'exec', 'vitest', 'run', '--testTimeout=15000', '--expect.poll.timeout=15000', '--hookTimeout=15000'], + }) + expect(gates[1]?.args).not.toContain('--coverage') + }) + + it('keeps Vitest timeout defaults for the plain unit inventory when the override is absent', () => { + const unit = withEnv('DSH_COVERAGE_TEST_TIMEOUT_MS', undefined, () => + withPnpmEntrypoint(() => gatesForMode('ci-unit').find(gate => gate.id === 'unit'))) + expect(unit?.args).toEqual(['/private/pnpm.cjs', 'exec', 'vitest', 'run']) + }) + it('selects partitioned coverage only when explicitly configured', () => { const coverage = withEnv('DSH_COVERAGE_PARTITIONS', '3', () => withPnpmEntrypoint(() => gatesForMode('ci-windows-complete').find(subject => subject.id === 'coverage'))) diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index d0c4773c66..3fc0d6b1b3 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -27,6 +27,7 @@ export type Mode = | 'ci-static' | 'ci-lint-contracts-ready' | 'ci-coverage' + | 'ci-unit' | 'ci-bench' | 'ci-snapshot' | 'ci-artifacts' @@ -141,6 +142,7 @@ function parseMode(raw: string | undefined): Mode { case 'ci-static': case 'ci-lint-contracts-ready': case 'ci-coverage': + case 'ci-unit': case 'ci-bench': case 'ci-snapshot': case 'ci-artifacts': @@ -156,7 +158,7 @@ function parseMode(raw: string | undefined): Mode { return raw default: throw new Error( - `run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint-contracts-ready | ci-coverage | ci-bench | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational-ready | node-compat | check-all | hygiene | doc-sync | doc-quick, got ${JSON.stringify(raw)}.`, + `run-gates: expected mode ci-primary | ci-linux-primary | ci-static | ci-lint-contracts-ready | ci-coverage | ci-unit | ci-bench | ci-snapshot | ci-artifacts | ci-consumers | ci-windows-blocking | ci-windows-complete | ci-windows-observational-ready | node-compat | check-all | hygiene | doc-sync | doc-quick, got ${JSON.stringify(raw)}.`, ) } } @@ -201,7 +203,11 @@ export function ciWorkerEnvironment( env: NodeJS.ProcessEnv, available = availableParallelism(), ): Record { - if (!mode.startsWith('ci-')) return {} + // ci-unit runs the plain Vitest inventory and nothing that reads these + // settings; the inventory's own run-gates tests build coverage gates from + // the ambient coverage variables, so it inherits the environment exactly as + // `pnpm run test` finds it. + if (!mode.startsWith('ci-') || mode === 'ci-unit') return {} const additions: Record = {} const setDefault = (name: string, value: number): void => { if (env[name] === undefined || env[name] === '') additions[name] = String(value) @@ -281,6 +287,8 @@ export function gatesForMode(selected: Mode): Gate[] { ] case 'ci-coverage': return coverageGates() + case 'ci-unit': + return ciUnitGates() case 'ci-bench': return [pnpmScript('bench', 'test:bench', { label: 'performance benchmarks' })] case 'ci-snapshot': @@ -654,8 +662,8 @@ function lintGate(options: { needs?: string[] } = {}): Gate { // total of one (the serial reference jobs) also set DSH_GATE_CONCURRENCY=1, // which keeps the gates from overlapping at all. // DSH_COVERAGE_TEST_TIMEOUT_MS raises Vitest's per-test, expect.poll, and hook -// defaults together for instrumented lanes whose scheduling overhead exceeds -// those defaults. Explicit fixture timeouts remain authoritative. +// defaults together for lanes whose scheduling overhead exceeds those +// defaults. Explicit fixture timeouts remain authoritative. function coverageWorkerArgs(): { instrumented: string[]; exempt: string[] } { const [flag] = positiveIntArg('DSH_COVERAGE_MAX_WORKERS', '--maxWorkers') if (flag === undefined) return { instrumented: [], exempt: [] } @@ -705,6 +713,27 @@ function coverageGates(): Gate[] { ] } +// The uninstrumented unit inventory for a whole-inventory reference lane +// (the Sandbox workflow's darwin parity job). It is `pnpm run test` with the +// same DSH_COVERAGE_TEST_TIMEOUT_MS budget the coverage gates take: a shared +// hosted runner delays cases that inherit Vitest's defaults past them. The +// package script itself has no environment hook. Output streams so the job +// log keeps per-file timestamps for a 15–30 minute run. +function ciUnitGates(): Gate[] { + return [ + pnpmScript('native-system', 'build:native-system'), + pnpmExec('unit', [ + 'vitest', + 'run', + ...coverageTestTimeoutArgs(process.env[COVERAGE_TEST_TIMEOUT_ENV]), + ], { + label: 'test', + needs: ['native-system'], + streamOutput: true, + }), + ] +} + // Recorded-session adapters boot process scenarios in `lib` mode. Callers wait // either on `build` or on a validation gate that transitively owns that build. function snapshotGate(needs: string[] = ['build']): Gate { From daeb299fa1cce9b0c6d9240613691d162674cd2d Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Mon, 28 Sep 2026 13:25:10 +0800 Subject: [PATCH 19/86] test(client): wait for the task detail close after a successful retry The case awaited the empty list's text and then read the panel once. The detail closes through the effect that observes the removed row, one commit after the render that replaces the list with its empty state, so that single read could still see the panel. The assertion now waits for the state it asserts, and a panel that never closes still fails. Refs #5332 --- ...il-close-waits-on-observed-state.i18n.yaml | 19 ++++++++++ ...28-detail-close-waits-on-observed-state.md | 37 +++++++++++++++++++ ...detail-close-waits-on-observed-state.zh.md | 37 +++++++++++++++++++ .../tests/task-manager-page.client.spec.tsx | 8 ++-- 4 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 .agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.i18n.yaml create mode 100644 .agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.md create mode 100644 .agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.zh.md diff --git a/.agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.i18n.yaml b/.agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.i18n.yaml new file mode 100644 index 0000000000..36959faa27 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.i18n.yaml @@ -0,0 +1,19 @@ +# Bilingual-pair consistency record for 2026-09-28-detail-close-waits-on-observed-state.md (docs/i18n/README.md): per heading +# section, a hash of its English and Chinese blocks outside code blocks and generated regions. +# After editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.md +/agent-note-the-task-detail-close-waits-on-the-observed-state: + en: ffb94d33eb1c162c + zh: 32845a4113ab27c1 +/agent-note-the-task-detail-close-waits-on-the-observed-state/problem: + en: 824fc78e18bfc576 + zh: 26a24d5b2e0ae953 +/agent-note-the-task-detail-close-waits-on-the-observed-state/decision: + en: 60526246262bec75 + zh: 7938b252e8734935 +/agent-note-the-task-detail-close-waits-on-the-observed-state/alternatives-considered: + en: 84790efb006dda93 + zh: 1ee2ca953457b732 +/agent-note-the-task-detail-close-waits-on-the-observed-state/consequences: + en: 4c9e3aeb93686221 + zh: 399cd871ce3c46fd diff --git a/.agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.md b/.agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.md new file mode 100644 index 0000000000..3029d37b76 --- /dev/null +++ b/.agents/notes/implemented/testing/2026-09-28-detail-close-waits-on-observed-state.md @@ -0,0 +1,37 @@ +# Agent Note: The task-detail close waits on the observed state + +Status: implemented + +English | [中文](2026-09-28-detail-close-waits-on-observed-state.zh.md) + +## Problem + +`packages/client/ui-schedule/tests/task-manager-page.client.spec.tsx`, case "keeps details after a refresh failure and closes them after a successful retry", failed intermittently on the `node 24 / coverage` lane's `thread-safe` partition with `AssertionError: expected