fix(windows): start Native hosts in LPAC; keep atomic writes beside pins

Windows Native hosts: on 02fcc3e0 the LPAC token check and the data-directory
share conflict were fixed, and the isolation probe ran inside the LPAC for the
first time. Node then aborted at startup (WSAStartup 10107): an LPAC cannot
open any registry key without the registryRead capability, and Winsock reads
its catalog from the registry. Native hosts now receive exactly that one
capability (derived by name), and the token check requires exactly it; it
grants no file, network or COM access, and socket creation stays denied,
which the probe checks. Bun failed opening the NUL device (EPERM) for
--config=NUL: the plain Bun host now reads an empty config from the granted
runtime-copy directory, and the Windows compiled host relies on
--no-compile-autoload-bunfig instead of baking --config=NUL. Unprotected file
ACL edits now pass an explicit-only DACL to SetSecurityInfo with
UNPROTECTED_DACL_SECURITY_INFORMATION so Windows re-derives inherited entries
from the parent (a file has no children to propagate to); the kernel write had
turned a file's inherited entries explicit. ACL pointer reads use checked
NonNull access (CodeQL rust/access-invalid-pointer on 02fcc3e0).

Windows atomic writes: Fleet's WorkspaceFile/WindowsDirectory pins, new in
0.10.1, keep every ancestor directory of a Fleet file open with read-only
sharing. MoveFileExW (tempfile::persist) reopens the destination directory
with FILE_ADD_FILE, so while a Fleet ledger is alive (a whole `codewhale
fleet` run, or a Runtime API request) Codewhale's own atomic writes into the
workspace root or its ancestors failed with os error 32. That includes
file-tool edits; hosted Windows showed it as a bare 500 writing mcp.json. On
Windows, write_atomic now creates its temp with DELETE access and renames
through that handle with a bare file name (the rename Fleet publication
already uses), so the parent is never reopened and no pin is relaxed. Names
Windows would rewrite (trailing dot or space, device names) are refused
before any temp exists, and every failure path closes the delete-denying
handle before removing the temp. MCP config write errors now name the file.
A Windows test writes and replaces a file beside live Fleet pins and checks
refused names leave no temp behind.

Wire JSON: the extension-host `isJson` guard used Array.every and
Object.values, so it accepted sparse arrays, extra array properties,
accessors and symbol keys that serialize differently from what was checked
(Copilot review on #6815). It now accepts only dense arrays and plain objects
whose own keys are enumerable string data properties, the rule storage.ts
already enforced; storage's duplicate check is removed. A new test fails on
the old guard and passes on the new one. Bundles that include the guard were
rebuilt with the pinned esbuild; the harness builtin digest pin in tier.rs
moves with it.

Release notes: credits and entries for #6820, #6829, #6830 and #6831, which
landed in 0.10.1 without CHANGELOG coverage, and for #6827 (Windows node.exe
kills are held by the built-in safety floor even in Full Access), crediting
@jayanthvee in the CHANGELOG, docs/CONTRIBUTORS.md, website credits and
public-surface facts.

Fixes #6827

Source15 verification (3,792 rows; owning HEAD cf033337 and index 5983aa54
unchanged):
- npm test and npm run check:web: 1,185 passed / 0 failed / 7 explicit skips
  (the website credit-parity test caught the first draft missing the new
  reporter credit);
  extension-host suite 390 tests, 383 passed, 0 failed, 7 skipped.
- macOS, compiled host rebuilt from the new bundle: extension_host 177/0,
  plugins::install::dsh 28/0, mcp 407/0, utils 38/0, tools::file 190/0,
  runtime_store_convergence 14/0, builtin digest table 2/0.
- Windows: cargo check --target x86_64-pc-windows-gnu of codewhale-tui lib
  and tests clean under deny(warnings); write_atomic change reviewed
  adversarially (FIX_FIRST findings applied: early-error temp cleanup, single
  retry layer, rewritten-name refusal). The LPAC capability, Bun config and
  file-ACL changes are type-checked only; their runtime proof is this head's
  hosted Windows run.

Hosted 02fcc3e0: Safety, Lint, npm wrapper smoke and Test (ubuntu-latest)
passed. Windows 17,952 passed / 91 failed: LPAC runtime startup (Node
WSAStartup, Bun NUL config), one file-ACL hygiene test, the mcp.json write
conflict and one DSH HTTP case behind the probe, all addressed above. CodeQL
flagged five pointer reads, addressed above. Not proven here: Windows runtime
of these repairs.
This is source integration, not release, deploy, provider or install
approval.

Evidence: artifacts/gpt61-0101-takeover-20261002/parent-source15-application,
parent-source15-local, parent-source15-npm-web-actual, windows-gnu-check.

Signed-off-by: CodeWhale Bot <[email protected]>
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
CodeWhale BotandClaude Opus 5.5 committed 2026-10-03 22:14:57 -07:00
1 parent 02fcc3e07d
commit af5fa5c23f
20 files changed
+494 -153

No files matched your search

+32 -6
View File
@@ -36,8 +36,9 @@ note below before upgrading.
refused with the concrete error. Codewhale's own pinned built-in host modules
keep a diagnosed exception, and every effect still needs a Rust operation
ticket. On Windows a Native extension starts only in a freshly created Less
Privileged AppContainer with no capabilities, after Codewhale checks its
token and a real file and network probe; otherwise activation is refused.
Privileged AppContainer whose only capability is `registryRead` (Winsock
cannot start without it), after Codewhale checks its token and a real file
and network probe; otherwise activation is refused.
Other files you
can read, such as project `.env` files, stay readable, and plugins sharing the
host can interfere with each other, so enable only plugins you have reviewed
@@ -199,6 +200,10 @@ note below before upgrading.
### Changed
- The pinned prompt header follows the turn at the top of the viewport,
handing over to the previous prompt as you scroll, and clicking it jumps back
to the message it names
([#6830](https://github.com/Hmbown/Codewhale/pull/6830), thanks @SparkofSpike).
- On Windows, every PowerShell command the shell tool starts now passes
`-ExecutionPolicy Bypass` for that process only, so a local `Restricted` or
`AllSigned` policy no longer blocks multi-line commands. (`.ps1` script tools
@@ -254,6 +259,13 @@ note below before upgrading.
### Fixed
- Diff lines and tool output wrap at grapheme boundaries, so emoji families,
skin tones and variation selectors no longer split across lines
([#6829](https://github.com/Hmbown/Codewhale/pull/6829), thanks @Lstarsky0).
- Twelve translated packs now translate the context inspector's making-room
and anchors rows, the Ctrl+O hint and the `/turn inspect` and `/advisor`
descriptions instead of showing English
([#6831](https://github.com/Hmbown/Codewhale/pull/6831), thanks @Lstarsky0).
- Optional MCP servers can be found before they connect. `tool_search` matches
the query against configured, enabled server names (or an `mcp_<server>_`
prefix), connects up to eight matches within the existing boot wait, and
@@ -767,6 +779,19 @@ note below before upgrading.
### Security
- On Windows, a broad Node kill (`Stop-Process -Name node`,
`Get-Process node | Stop-Process`, `taskkill /IM node.exe`, and wrapped,
aliased or nested-shell forms) is held by a built-in safety floor: it is
refused in Full Access, Auto-Review and Never, and asks in other modes. With
the npm launcher such a command ended this and every other npm-launched
Codewhale session without cleanup. Stopping a server by PID or port still
works, and the Windows installer and archives have no Node launcher
([#6827](https://github.com/Hmbown/Codewhale/issues/6827), thanks @jayanthvee).
- `code_execution` and `js_execution` now start their Python and Node
interpreters through the same permission-aware launcher as other commands, so
the session's execution policy applies to them. Before, an approved call ran
the interpreter directly, and its working directory was the only boundary
([#6820](https://github.com/Hmbown/Codewhale/pull/6820), thanks @Guan0923).
- A saved task without an `auto_approve` field is no longer treated as
auto-approved. Updates accept HTTPS URLs only from the release-host
allow-list, and the npm release-asset check bounds every request.
@@ -1175,10 +1200,10 @@ note below before upgrading.
### Contributors
Sixteen contributors and issue reporters are credited below, including
Seventeen contributors and issue reporters are credited below, including
@cenab's provider report.
- **[@Guan0923](https://github.com/Guan0923)** — accepted case-insensitive HTTP(S) schemes in `config doctor` without rewriting the configured URL ([#6819](https://github.com/Hmbown/Codewhale/pull/6819)).
- **[@Guan0923](https://github.com/Guan0923)** — accepted case-insensitive HTTP(S) schemes in `config doctor` without rewriting the configured URL ([#6819](https://github.com/Hmbown/Codewhale/pull/6819)), and routed the Python and JavaScript execution tools through the session's execution policy ([#6820](https://github.com/Hmbown/Codewhale/pull/6820)).
- **[@harryvgiunta](https://github.com/harryvgiunta)** — added Yolo-Auto as a bundled OpenAI-compatible host, starting on the vendor's recommended `qwen3.8-flash` model ([#6408](https://github.com/Hmbown/Codewhale/pull/6408)).
- **[@asto18089](https://github.com/asto18089)** — contributed the integrated runtime liveness, context, search, JavaScript execution, stopship scout and pet repairs, preserving their original contributor commits ([#6799](https://github.com/Hmbown/Codewhale/pull/6799)); made context rule and chain-segment source labels repository-relative ([#6739](https://github.com/Hmbown/Codewhale/pull/6739)).
- **[@qiuYliangM](https://github.com/qiuYliangM)** — made provider-bound project instruction and constitution labels stable across directory moves and kept their absolute paths in operator reports ([#6799](https://github.com/Hmbown/Codewhale/pull/6799)).
@@ -1186,14 +1211,15 @@ Sixteen contributors and issue reporters are credited below, including
- **[@Andrea-Bruno](https://github.com/Andrea-Bruno)** — designed the Superfast Decision Gate and contributed its off-by-default shadow classifier ([#6604](https://github.com/Hmbown/Codewhale/pull/6604), [#6603](https://github.com/Hmbown/Codewhale/issues/6603)).
- **[@aiapienthusiast](https://github.com/aiapienthusiast)** — added Cheaper Inference to the bundled provider catalog ([#6761](https://github.com/Hmbown/Codewhale/pull/6761)).
- **[@gaord](https://github.com/gaord)** — let a client fork a thread at a named turn ([#6580](https://github.com/Hmbown/Codewhale/pull/6580)), let undo roll back files for the turn it is undoing ([#6483](https://github.com/Hmbown/Codewhale/pull/6483)), stopped resume and fork from duplicating threads and sessions ([#6406](https://github.com/Hmbown/Codewhale/pull/6406)), exposed user-defined provider routes to native clients ([#6404](https://github.com/Hmbown/Codewhale/pull/6404)), and kept a fork going when a turn lost its tool call ([#6664](https://github.com/Hmbown/Codewhale/pull/6664)).
- **[@Lstarsky0](https://github.com/Lstarsky0)** — moved the docs/work, legal, digest and FAQ pages onto the dictionary spine ([#6405](https://github.com/Hmbown/Codewhale/pull/6405), [#6417](https://github.com/Hmbown/Codewhale/pull/6417), [#6499](https://github.com/Hmbown/Codewhale/pull/6499), [#6574](https://github.com/Hmbown/Codewhale/pull/6574)), tightened the Chinese-branching ceiling to 18 ([#6403](https://github.com/Hmbown/Codewhale/pull/6403)), and made Fleet publish without a two-link window ([#6431](https://github.com/Hmbown/Codewhale/pull/6431)). Also moved the constitution page onto the dictionary spine and kept its install link in the selected locale ([#6733](https://github.com/Hmbown/Codewhale/pull/6733)).
- **[@Lstarsky0](https://github.com/Lstarsky0)** — moved the docs/work, legal, digest and FAQ pages onto the dictionary spine ([#6405](https://github.com/Hmbown/Codewhale/pull/6405), [#6417](https://github.com/Hmbown/Codewhale/pull/6417), [#6499](https://github.com/Hmbown/Codewhale/pull/6499), [#6574](https://github.com/Hmbown/Codewhale/pull/6574)), tightened the Chinese-branching ceiling to 18 ([#6403](https://github.com/Hmbown/Codewhale/pull/6403)), and made Fleet publish without a two-link window ([#6431](https://github.com/Hmbown/Codewhale/pull/6431)). Also moved the constitution page onto the dictionary spine and kept its install link in the selected locale ([#6733](https://github.com/Hmbown/Codewhale/pull/6733)), wrapped diff and tool output at grapheme boundaries ([#6829](https://github.com/Hmbown/Codewhale/pull/6829)), and translated the context inspector rows twelve packs still shipped in English ([#6831](https://github.com/Hmbown/Codewhale/pull/6831)).
- **[@aboimpinto](https://github.com/aboimpinto)** — restored a green Linux full-workspace test gate without loosening any test, twice ([#6581](https://github.com/Hmbown/Codewhale/pull/6581), [#6666](https://github.com/Hmbown/Codewhale/pull/6666)). Completed the seventeen-command portable session group, including `/structcopy` ([#6793](https://github.com/Hmbown/Codewhale/pull/6793)).
- **[@dajiaohuang](https://github.com/dajiaohuang)** — `codewhale config set` checks a known setting's value against its schema type before saving it ([#6568](https://github.com/Hmbown/Codewhale/pull/6568)).
- **[@jayanthvee](https://github.com/jayanthvee)** — reported and diagnosed that killing the npm launcher's `node.exe` ends Windows sessions without cleanup, with reproductions and fix directions ([#6827](https://github.com/Hmbown/Codewhale/issues/6827)).
- **[@cenab](https://github.com/cenab)** — requested the Tsubasa provider row and supplied its endpoint, key and model values ([#6695](https://github.com/Hmbown/Codewhale/issues/6695)).
- **[@BX166](https://github.com/BX166)** — reported the AICraft provider row missing its key console, docs link and guidance, and supplied the values ([#6616](https://github.com/Hmbown/Codewhale/issues/6616)).
- **[@Water-Run](https://github.com/Water-Run)** — ingested namespaced model-only catalog entries so models present only in the canonical `models` map reach the offering list ([#6400](https://github.com/Hmbown/Codewhale/pull/6400)), and retired the blanket dead-code allowance with its unused feature stages, tightening the budget to match ([#6402](https://github.com/Hmbown/Codewhale/pull/6402)).
- **[@wuisabel-gif](https://github.com/wuisabel-gif)** — designed the `tool_call_after` execution-receipt contract and its tests on a reference branch, which landed re-implemented on the current hook seam ([#6689](https://github.com/Hmbown/Codewhale/issues/6689), [#6713](https://github.com/Hmbown/Codewhale/pull/6713)).
- **[@SparkofSpike](https://github.com/SparkofSpike)** — let making room survive a provider request-body limit (HTTP 413) by shrinking, then replacing, inline images for that one summary pass ([#6642](https://github.com/Hmbown/Codewhale/pull/6642)). Translated seventeen Tier-2 guides and thirteen developer and internal docs into Simplified Chinese, and connected the localized documentation ([#6662](https://github.com/Hmbown/Codewhale/pull/6662), [#6663](https://github.com/Hmbown/Codewhale/pull/6663)); added regression coverage for rejecting unknown website locales before dictionary lookup ([#6786](https://github.com/Hmbown/Codewhale/pull/6786)).
- **[@SparkofSpike](https://github.com/SparkofSpike)** — let making room survive a provider request-body limit (HTTP 413) by shrinking, then replacing, inline images for that one summary pass ([#6642](https://github.com/Hmbown/Codewhale/pull/6642)). Translated seventeen Tier-2 guides and thirteen developer and internal docs into Simplified Chinese, and connected the localized documentation ([#6662](https://github.com/Hmbown/Codewhale/pull/6662), [#6663](https://github.com/Hmbown/Codewhale/pull/6663)); added regression coverage for rejecting unknown website locales before dictionary lookup ([#6786](https://github.com/Hmbown/Codewhale/pull/6786)); made the pinned prompt header follow the viewport's turn and jump on click ([#6830](https://github.com/Hmbown/Codewhale/pull/6830)).
## [0.10.0] - 2026-09-22
+32 -6
View File
@@ -36,8 +36,9 @@ note below before upgrading.
refused with the concrete error. Codewhale's own pinned built-in host modules
keep a diagnosed exception, and every effect still needs a Rust operation
ticket. On Windows a Native extension starts only in a freshly created Less
Privileged AppContainer with no capabilities, after Codewhale checks its
token and a real file and network probe; otherwise activation is refused.
Privileged AppContainer whose only capability is `registryRead` (Winsock
cannot start without it), after Codewhale checks its token and a real file
and network probe; otherwise activation is refused.
Other files you
can read, such as project `.env` files, stay readable, and plugins sharing the
host can interfere with each other, so enable only plugins you have reviewed
@@ -199,6 +200,10 @@ note below before upgrading.
### Changed
- The pinned prompt header follows the turn at the top of the viewport,
handing over to the previous prompt as you scroll, and clicking it jumps back
to the message it names
([#6830](https://github.com/Hmbown/Codewhale/pull/6830), thanks @SparkofSpike).
- On Windows, every PowerShell command the shell tool starts now passes
`-ExecutionPolicy Bypass` for that process only, so a local `Restricted` or
`AllSigned` policy no longer blocks multi-line commands. (`.ps1` script tools
@@ -254,6 +259,13 @@ note below before upgrading.
### Fixed
- Diff lines and tool output wrap at grapheme boundaries, so emoji families,
skin tones and variation selectors no longer split across lines
([#6829](https://github.com/Hmbown/Codewhale/pull/6829), thanks @Lstarsky0).
- Twelve translated packs now translate the context inspector's making-room
and anchors rows, the Ctrl+O hint and the `/turn inspect` and `/advisor`
descriptions instead of showing English
([#6831](https://github.com/Hmbown/Codewhale/pull/6831), thanks @Lstarsky0).
- Optional MCP servers can be found before they connect. `tool_search` matches
the query against configured, enabled server names (or an `mcp_<server>_`
prefix), connects up to eight matches within the existing boot wait, and
@@ -767,6 +779,19 @@ note below before upgrading.
### Security
- On Windows, a broad Node kill (`Stop-Process -Name node`,
`Get-Process node | Stop-Process`, `taskkill /IM node.exe`, and wrapped,
aliased or nested-shell forms) is held by a built-in safety floor: it is
refused in Full Access, Auto-Review and Never, and asks in other modes. With
the npm launcher such a command ended this and every other npm-launched
Codewhale session without cleanup. Stopping a server by PID or port still
works, and the Windows installer and archives have no Node launcher
([#6827](https://github.com/Hmbown/Codewhale/issues/6827), thanks @jayanthvee).
- `code_execution` and `js_execution` now start their Python and Node
interpreters through the same permission-aware launcher as other commands, so
the session's execution policy applies to them. Before, an approved call ran
the interpreter directly, and its working directory was the only boundary
([#6820](https://github.com/Hmbown/Codewhale/pull/6820), thanks @Guan0923).
- A saved task without an `auto_approve` field is no longer treated as
auto-approved. Updates accept HTTPS URLs only from the release-host
allow-list, and the npm release-asset check bounds every request.
@@ -1175,10 +1200,10 @@ note below before upgrading.
### Contributors
Sixteen contributors and issue reporters are credited below, including
Seventeen contributors and issue reporters are credited below, including
@cenab's provider report.
- **[@Guan0923](https://github.com/Guan0923)** — accepted case-insensitive HTTP(S) schemes in `config doctor` without rewriting the configured URL ([#6819](https://github.com/Hmbown/Codewhale/pull/6819)).
- **[@Guan0923](https://github.com/Guan0923)** — accepted case-insensitive HTTP(S) schemes in `config doctor` without rewriting the configured URL ([#6819](https://github.com/Hmbown/Codewhale/pull/6819)), and routed the Python and JavaScript execution tools through the session's execution policy ([#6820](https://github.com/Hmbown/Codewhale/pull/6820)).
- **[@harryvgiunta](https://github.com/harryvgiunta)** — added Yolo-Auto as a bundled OpenAI-compatible host, starting on the vendor's recommended `qwen3.8-flash` model ([#6408](https://github.com/Hmbown/Codewhale/pull/6408)).
- **[@asto18089](https://github.com/asto18089)** — contributed the integrated runtime liveness, context, search, JavaScript execution, stopship scout and pet repairs, preserving their original contributor commits ([#6799](https://github.com/Hmbown/Codewhale/pull/6799)); made context rule and chain-segment source labels repository-relative ([#6739](https://github.com/Hmbown/Codewhale/pull/6739)).
- **[@qiuYliangM](https://github.com/qiuYliangM)** — made provider-bound project instruction and constitution labels stable across directory moves and kept their absolute paths in operator reports ([#6799](https://github.com/Hmbown/Codewhale/pull/6799)).
@@ -1186,14 +1211,15 @@ Sixteen contributors and issue reporters are credited below, including
- **[@Andrea-Bruno](https://github.com/Andrea-Bruno)** — designed the Superfast Decision Gate and contributed its off-by-default shadow classifier ([#6604](https://github.com/Hmbown/Codewhale/pull/6604), [#6603](https://github.com/Hmbown/Codewhale/issues/6603)).
- **[@aiapienthusiast](https://github.com/aiapienthusiast)** — added Cheaper Inference to the bundled provider catalog ([#6761](https://github.com/Hmbown/Codewhale/pull/6761)).
- **[@gaord](https://github.com/gaord)** — let a client fork a thread at a named turn ([#6580](https://github.com/Hmbown/Codewhale/pull/6580)), let undo roll back files for the turn it is undoing ([#6483](https://github.com/Hmbown/Codewhale/pull/6483)), stopped resume and fork from duplicating threads and sessions ([#6406](https://github.com/Hmbown/Codewhale/pull/6406)), exposed user-defined provider routes to native clients ([#6404](https://github.com/Hmbown/Codewhale/pull/6404)), and kept a fork going when a turn lost its tool call ([#6664](https://github.com/Hmbown/Codewhale/pull/6664)).
- **[@Lstarsky0](https://github.com/Lstarsky0)** — moved the docs/work, legal, digest and FAQ pages onto the dictionary spine ([#6405](https://github.com/Hmbown/Codewhale/pull/6405), [#6417](https://github.com/Hmbown/Codewhale/pull/6417), [#6499](https://github.com/Hmbown/Codewhale/pull/6499), [#6574](https://github.com/Hmbown/Codewhale/pull/6574)), tightened the Chinese-branching ceiling to 18 ([#6403](https://github.com/Hmbown/Codewhale/pull/6403)), and made Fleet publish without a two-link window ([#6431](https://github.com/Hmbown/Codewhale/pull/6431)). Also moved the constitution page onto the dictionary spine and kept its install link in the selected locale ([#6733](https://github.com/Hmbown/Codewhale/pull/6733)).
- **[@Lstarsky0](https://github.com/Lstarsky0)** — moved the docs/work, legal, digest and FAQ pages onto the dictionary spine ([#6405](https://github.com/Hmbown/Codewhale/pull/6405), [#6417](https://github.com/Hmbown/Codewhale/pull/6417), [#6499](https://github.com/Hmbown/Codewhale/pull/6499), [#6574](https://github.com/Hmbown/Codewhale/pull/6574)), tightened the Chinese-branching ceiling to 18 ([#6403](https://github.com/Hmbown/Codewhale/pull/6403)), and made Fleet publish without a two-link window ([#6431](https://github.com/Hmbown/Codewhale/pull/6431)). Also moved the constitution page onto the dictionary spine and kept its install link in the selected locale ([#6733](https://github.com/Hmbown/Codewhale/pull/6733)), wrapped diff and tool output at grapheme boundaries ([#6829](https://github.com/Hmbown/Codewhale/pull/6829)), and translated the context inspector rows twelve packs still shipped in English ([#6831](https://github.com/Hmbown/Codewhale/pull/6831)).
- **[@aboimpinto](https://github.com/aboimpinto)** — restored a green Linux full-workspace test gate without loosening any test, twice ([#6581](https://github.com/Hmbown/Codewhale/pull/6581), [#6666](https://github.com/Hmbown/Codewhale/pull/6666)). Completed the seventeen-command portable session group, including `/structcopy` ([#6793](https://github.com/Hmbown/Codewhale/pull/6793)).
- **[@dajiaohuang](https://github.com/dajiaohuang)** — `codewhale config set` checks a known setting's value against its schema type before saving it ([#6568](https://github.com/Hmbown/Codewhale/pull/6568)).
- **[@jayanthvee](https://github.com/jayanthvee)** — reported and diagnosed that killing the npm launcher's `node.exe` ends Windows sessions without cleanup, with reproductions and fix directions ([#6827](https://github.com/Hmbown/Codewhale/issues/6827)).
- **[@cenab](https://github.com/cenab)** — requested the Tsubasa provider row and supplied its endpoint, key and model values ([#6695](https://github.com/Hmbown/Codewhale/issues/6695)).
- **[@BX166](https://github.com/BX166)** — reported the AICraft provider row missing its key console, docs link and guidance, and supplied the values ([#6616](https://github.com/Hmbown/Codewhale/issues/6616)).
- **[@Water-Run](https://github.com/Water-Run)** — ingested namespaced model-only catalog entries so models present only in the canonical `models` map reach the offering list ([#6400](https://github.com/Hmbown/Codewhale/pull/6400)), and retired the blanket dead-code allowance with its unused feature stages, tightening the budget to match ([#6402](https://github.com/Hmbown/Codewhale/pull/6402)).
- **[@wuisabel-gif](https://github.com/wuisabel-gif)** — designed the `tool_call_after` execution-receipt contract and its tests on a reference branch, which landed re-implemented on the current hook seam ([#6689](https://github.com/Hmbown/Codewhale/issues/6689), [#6713](https://github.com/Hmbown/Codewhale/pull/6713)).
- **[@SparkofSpike](https://github.com/SparkofSpike)** — let making room survive a provider request-body limit (HTTP 413) by shrinking, then replacing, inline images for that one summary pass ([#6642](https://github.com/Hmbown/Codewhale/pull/6642)). Translated seventeen Tier-2 guides and thirteen developer and internal docs into Simplified Chinese, and connected the localized documentation ([#6662](https://github.com/Hmbown/Codewhale/pull/6662), [#6663](https://github.com/Hmbown/Codewhale/pull/6663)); added regression coverage for rejecting unknown website locales before dictionary lookup ([#6786](https://github.com/Hmbown/Codewhale/pull/6786)).
- **[@SparkofSpike](https://github.com/SparkofSpike)** — let making room survive a provider request-body limit (HTTP 413) by shrinking, then replacing, inline images for that one summary pass ([#6642](https://github.com/Hmbown/Codewhale/pull/6642)). Translated seventeen Tier-2 guides and thirteen developer and internal docs into Simplified Chinese, and connected the localized documentation ([#6662](https://github.com/Hmbown/Codewhale/pull/6662), [#6663](https://github.com/Hmbown/Codewhale/pull/6663)); added regression coverage for rejecting unknown website locales before dictionary lookup ([#6786](https://github.com/Hmbown/Codewhale/pull/6786)); made the pinned prompt header follow the viewport's turn and jump on click ([#6830](https://github.com/Hmbown/Codewhale/pull/6830)).
## [0.10.0] - 2026-09-22
+3 -1
View File
@@ -38,7 +38,9 @@ const run = (argv) => {
}
run([
'--no-install', '--no-env-file', `--config=${process.platform === 'win32' ? 'NUL' : '/dev/null'}`, 'build', '--compile', `--compile-executable-path=${binary}`,
`--compile-exec-argv=--no-install --no-env-file --config=${process.platform === 'win32' ? 'NUL' : '/dev/null'} --no-addons`,
// A Windows Native host runs in an LPAC, which cannot open the NUL device;
// --no-compile-autoload-bunfig below already keeps bunfig.toml unread there.
`--compile-exec-argv=--no-install --no-env-file${process.platform === 'win32' ? '' : ' --config=/dev/null'} --no-addons`,
'--no-compile-autoload-dotenv', '--no-compile-autoload-bunfig',
'--no-compile-autoload-tsconfig', '--no-compile-autoload-package-json',
'--define=CODEWHALE_COMPILED_HOST=true',
+14 -4
View File
@@ -5983,10 +5983,20 @@ function isJson(value, depth = 0) {
return true;
case "number":
return Number.isFinite(value);
case "object":
if (Array.isArray(value)) return value.every((v) => isJson(v, depth + 1));
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
return Object.values(value).every((v) => isJson(v, depth + 1));
case "object": {
const array = Array.isArray(value);
if (!array && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
const keys = Reflect.ownKeys(value);
if (array && keys.length !== value.length + 1) return false;
for (const key of keys) {
if (array && key === "length") continue;
if (typeof key !== "string") return false;
if (array && (!/^(0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length)) return false;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor.enumerable || !("value" in descriptor) || !isJson(descriptor.value, depth + 1)) return false;
}
return true;
}
default:
return false;
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"modules": {
"harness": "8b40ab8734838da9513106ba576b7140656ff254f67c81c0b52365bc2b497aaa",
"harness": "bf685db5e808ab708ec698e1bc038d173db59f2facb6907fdbd689f336123f8f",
"mcp": "d5eb38941113934f9768e90ab3f1db021b93980e41be5cdf8836489b7f233b55"
}
}
+14 -4
View File
@@ -164,10 +164,20 @@ function isJson(value, depth = 0) {
return true;
case "number":
return Number.isFinite(value);
case "object":
if (Array.isArray(value)) return value.every((v) => isJson(v, depth + 1));
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
return Object.values(value).every((v) => isJson(v, depth + 1));
case "object": {
const array2 = Array.isArray(value);
if (!array2 && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
const keys = Reflect.ownKeys(value);
if (array2 && keys.length !== value.length + 1) return false;
for (const key of keys) {
if (array2 && key === "length") continue;
if (typeof key !== "string") return false;
if (array2 && (!/^(0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length)) return false;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor.enumerable || !("value" in descriptor) || !isJson(descriptor.value, depth + 1)) return false;
}
return true;
}
default:
return false;
}
+16 -21
View File
@@ -19,7 +19,7 @@ var __export = (target, all) => {
var define_BUILTIN_MODULE_DIGESTS_default;
var init_define_BUILTIN_MODULE_DIGESTS = __esm({
"<define:__BUILTIN_MODULE_DIGESTS__>"() {
define_BUILTIN_MODULE_DIGESTS_default = { harness: "8b40ab8734838da9513106ba576b7140656ff254f67c81c0b52365bc2b497aaa", mcp: "d5eb38941113934f9768e90ab3f1db021b93980e41be5cdf8836489b7f233b55" };
define_BUILTIN_MODULE_DIGESTS_default = { harness: "bf685db5e808ab708ec698e1bc038d173db59f2facb6907fdbd689f336123f8f", mcp: "d5eb38941113934f9768e90ab3f1db021b93980e41be5cdf8836489b7f233b55" };
}
});
@@ -6188,10 +6188,20 @@ function isJson(value, depth = 0) {
return true;
case "number":
return Number.isFinite(value);
case "object":
if (Array.isArray(value)) return value.every((v) => isJson(v, depth + 1));
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
return Object.values(value).every((v) => isJson(v, depth + 1));
case "object": {
const array = Array.isArray(value);
if (!array && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
const keys = Reflect.ownKeys(value);
if (array && keys.length !== value.length + 1) return false;
for (const key of keys) {
if (array && key === "length") continue;
if (typeof key !== "string") return false;
if (array && (!/^(0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length)) return false;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor.enumerable || !("value" in descriptor) || !isJson(descriptor.value, depth + 1)) return false;
}
return true;
}
default:
return false;
}
@@ -13648,23 +13658,8 @@ function checkKey(key) {
throw new StorageError("invalid", `storage key must contain 1 to ${STORAGE_LIMITS.keyBytes} UTF-8 bytes and no NUL`);
}
}
function plainJson(value, depth = 0) {
if (depth > 64) return false;
if (value === null || typeof value !== "object") return true;
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
const keys = Reflect.ownKeys(value);
if (Array.isArray(value) && keys.length !== value.length + 1) return false;
for (const key of keys) {
if (Array.isArray(value) && key === "length") continue;
if (typeof key !== "string") return false;
if (Array.isArray(value) && (!/^(0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length)) return false;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor.enumerable || !("value" in descriptor) || !plainJson(descriptor.value, depth + 1)) return false;
}
return true;
}
function snapshot2(value) {
if (!plainJson(value) || !isJson(value)) throw new StorageError("invalid", "storage value must be plain JSON");
if (!isJson(value)) throw new StorageError("invalid", "storage value must be plain JSON");
const encoded = JSON.stringify(value);
if (Buffer.byteLength(encoded) > STORAGE_LIMITS.valueBytes) throw new StorageError("limit", "storage value exceeds its byte limit");
return JSON.parse(encoded);
+14 -4
View File
@@ -5927,10 +5927,20 @@ function isJson(value, depth = 0) {
return true;
case "number":
return Number.isFinite(value);
case "object":
if (Array.isArray(value)) return value.every((v) => isJson(v, depth + 1));
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
return Object.values(value).every((v) => isJson(v, depth + 1));
case "object": {
const array = Array.isArray(value);
if (!array && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
const keys = Reflect.ownKeys(value);
if (array && keys.length !== value.length + 1) return false;
for (const key of keys) {
if (array && key === "length") continue;
if (typeof key !== "string") return false;
if (array && (!/^(0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length)) return false;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor.enumerable || !("value" in descriptor) || !isJson(descriptor.value, depth + 1)) return false;
}
return true;
}
default:
return false;
}
+14 -4
View File
@@ -444,10 +444,20 @@ function isJson(value, depth = 0) {
return true;
case "number":
return Number.isFinite(value);
case "object":
if (Array.isArray(value)) return value.every((v) => isJson(v, depth + 1));
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
return Object.values(value).every((v) => isJson(v, depth + 1));
case "object": {
const array2 = Array.isArray(value);
if (!array2 && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
const keys = Reflect.ownKeys(value);
if (array2 && keys.length !== value.length + 1) return false;
for (const key of keys) {
if (array2 && key === "length") continue;
if (typeof key !== "string") return false;
if (array2 && (!/^(0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length)) return false;
const descriptor = Object.getOwnPropertyDescriptor(value, key);
if (!descriptor.enumerable || !("value" in descriptor) || !isJson(descriptor.value, depth + 1)) return false;
}
return true;
}
default:
return false;
}
+22 -5
View File
@@ -1,4 +1,11 @@
/** A value that survives `JSON.stringify` unchanged and is accepted on the wire. */
/**
* A value that survives `JSON.stringify` unchanged and is accepted on the wire:
* `null`, booleans, strings, finite numbers, dense arrays, and plain objects
* whose own keys are enumerable string data properties. Holes, extra array
* properties, accessors and symbol keys would serialize to something other
* than what was checked (a hole becomes `null`, a getter can answer twice), so
* they are refused rather than passed to Rust as a different value.
*/
import type { Json } from './protocol.ts'
export function isJson(value: unknown, depth = 0): value is Json {
@@ -10,10 +17,20 @@ export function isJson(value: unknown, depth = 0): value is Json {
return true
case 'number':
return Number.isFinite(value)
case 'object':
if (Array.isArray(value)) return value.every((v) => isJson(v, depth + 1))
if (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false
return Object.values(value as object).every((v) => isJson(v, depth + 1))
case 'object': {
const array = Array.isArray(value)
if (!array && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false
const keys = Reflect.ownKeys(value as object)
if (array && keys.length !== (value as unknown[]).length + 1) return false
for (const key of keys) {
if (array && key === 'length') continue
if (typeof key !== 'string') return false
if (array && (!/^(0|[1-9]\d*)$/u.test(key) || Number(key) >= (value as unknown[]).length)) return false
const descriptor = Object.getOwnPropertyDescriptor(value, key)!
if (!descriptor.enumerable || !('value' in descriptor) || !isJson(descriptor.value, depth + 1)) return false
}
return true
}
default:
return false
}
+1 -17
View File
@@ -43,24 +43,8 @@ function checkKey(key: string): void {
}
/** Reject accessors, symbols, sparse arrays and extra properties before isJson reads values. */
function plainJson(value: unknown, depth = 0): boolean {
if (depth > 64) return false
if (value === null || typeof value !== 'object') return true
if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false
const keys = Reflect.ownKeys(value)
if (Array.isArray(value) && keys.length !== value.length + 1) return false
for (const key of keys) {
if (Array.isArray(value) && key === 'length') continue
if (typeof key !== 'string') return false
if (Array.isArray(value) && (!/^(0|[1-9]\d*)$/u.test(key) || Number(key) >= value.length)) return false
const descriptor = Object.getOwnPropertyDescriptor(value, key)!
if (!descriptor.enumerable || !('value' in descriptor) || !plainJson(descriptor.value, depth + 1)) return false
}
return true
}
function snapshot(value: unknown): Json {
if (!plainJson(value) || !isJson(value)) throw new StorageError('invalid', 'storage value must be plain JSON')
if (!isJson(value)) throw new StorageError('invalid', 'storage value must be plain JSON')
const encoded = JSON.stringify(value)
if (Buffer.byteLength(encoded) > STORAGE_LIMITS.valueBytes) throw new StorageError('limit', 'storage value exceeds its byte limit')
return JSON.parse(encoded)
@@ -81,3 +81,19 @@ test('bad magic, oversized length, and non-JSON payloads are framing errors', ()
bad.write('{', 8)
assert.throws(() => new FrameDecoder().push(bad), /not JSON/)
})
test('wire JSON guard refuses values that would serialize differently', async () => {
const { isJson } = await import('../src/json.ts')
assert.equal(isJson({ a: [1, 'two', null, { b: true }] }), true)
// A hole serializes as null, a non-index array property is dropped, a symbol
// key is ignored, and a getter can answer differently when serialized.
assert.equal(isJson(new Array(2)), false)
const extra = [1]
extra.note = 'dropped'
assert.equal(isJson(extra), false)
assert.equal(isJson({ [Symbol('s')]: 1 }), false)
let reads = 0
assert.equal(isJson({ get value() { reads += 1; return reads } }), false)
assert.equal(isJson(Object.defineProperty({}, 'hidden', { value: 1, enumerable: false })), false)
assert.equal(isJson(new Date(0)), false)
})
+1 -1
View File
@@ -147,7 +147,7 @@ impl BuiltinModule {
pub(crate) const BUILTIN_MODULES: &[BuiltinModule] = &[
BuiltinModule {
id: "harness",
source_sha256: "8b40ab8734838da9513106ba576b7140656ff254f67c81c0b52365bc2b497aaa",
source_sha256: "bf685db5e808ab708ec698e1bc038d173db59f2facb6907fdbd689f336123f8f",
tools: &[],
},
BuiltinModule {
+191 -48
View File
@@ -1,4 +1,5 @@
//! Native-only Windows launch boundary. LPAC with no capabilities supplies
//! Native-only Windows launch boundary. LPAC with only the `registryRead`
//! capability (Winsock cannot initialize without it) supplies
//! filesystem/network denial; the existing Job supplies lifetime and memory.
//! A fresh profile never inherits ACLs from a retired host. Profiles/assets
//! created here are disposed by their exact owner after the process ends;
@@ -30,7 +31,7 @@ use windows_sys::Win32::Security::Isolation::{
};
use windows_sys::Win32::Security::{
ACL, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, EqualSid, FreeSid, GetTokenInformation,
OBJECT_INHERIT_ACE, PSID, SECURITY_ATTRIBUTES, SECURITY_CAPABILITIES,
OBJECT_INHERIT_ACE, PSID, SECURITY_ATTRIBUTES, SECURITY_CAPABILITIES, SID_AND_ATTRIBUTES,
TOKEN_APPCONTAINER_INFORMATION, TOKEN_GROUPS, TOKEN_QUERY, TokenAppContainerSid,
TokenCapabilities, TokenIsAppContainer, TokenIsLessPrivilegedAppContainer,
};
@@ -60,6 +61,8 @@ const PROBE_DEADLINE: Duration = Duration::from_secs(15);
const PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT: u32 = 0x1;
const MAX_GRANT_ENTRIES: usize = 65_536;
const MAX_GRANT_SCOPES: usize = 1024;
/// Empty Bun config in the granted runtime-copy directory (see spawn_inner).
const EMPTY_BUN_CONFIG: &str = "empty-bunfig.toml";
// Serialize Core's read/merge/write ACL operations across old-profile cleanup
// and a new host admission. Never overwrite a concurrently admitted profile.
static ACL_EDITS: Mutex<()> = Mutex::new(());
@@ -381,6 +384,7 @@ impl NativeSandbox {
.tempdir_in(&parent)?,
);
let program = assets.path().join("runtime.exe");
fs::write(assets.path().join(EMPTY_BUN_CONFIG), b"")?;
// The selected runtime is copied, never granted access in an
// installation/user directory. Pin its opened bytes while copying.
let original = runtime.path.canonicalize()?;
@@ -648,10 +652,15 @@ impl NativeSandbox {
let (stdout, child_stdout) = pipe(true, overlapped)?;
let (stderr, child_stderr) = pipe(true, overlapped)?;
let mut attrs = Attributes::new(3)?;
let registry_read = CapabilitySid::registry_read()?;
let mut capability = SID_AND_ATTRIBUTES {
Sid: registry_read.sid(),
Attributes: windows_sys::Win32::System::SystemServices::SE_GROUP_ENABLED as u32,
};
let capabilities = SECURITY_CAPABILITIES {
AppContainerSid: self.profile.sid,
Capabilities: null_mut(),
CapabilityCount: 0,
Capabilities: &mut capability,
CapabilityCount: 1,
Reserved: 0,
};
let lpac = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT;
@@ -671,7 +680,23 @@ impl NativeSandbox {
startup.StartupInfo.hStdError = child_stderr.as_raw_handle();
startup.lpAttributeList = attrs.ptr();
let application = wide(self.program.as_os_str())?;
let mut command = command_line(self.program.as_os_str(), args)?;
// An LPAC cannot open the NUL device, so a Bun host reads its empty
// config from the granted runtime-copy directory instead.
let empty_config = format!(
"--config={}",
self._assets.path().join(EMPTY_BUN_CONFIG).display()
);
let args: Vec<String> = args
.iter()
.map(|arg| {
if arg == "--config=NUL" {
empty_config.clone()
} else {
arg.clone()
}
})
.collect();
let mut command = command_line(self.program.as_os_str(), &args)?;
let directory = wide(self.data.as_os_str())?;
let mut env = env.to_vec();
let temp = self.data.join("tmp").into_os_string();
@@ -711,7 +736,7 @@ impl NativeSandbox {
&process,
)?);
tree.limit_process_memory(memory_cap)?;
verify_token(&process, self.profile.sid)?;
verify_token(&process, self.profile.sid, registry_read.sid())?;
// No Native byte executes until Job, memory and actual LPAC token
// identity/capabilities have all been checked by Rust.
if unsafe { ResumeThread(thread.as_raw_handle()) } != 1 {
@@ -820,7 +845,7 @@ fn wait(process: &OwnedHandle, timeout: u32) -> io::Result<std::process::ExitSta
}
}
fn verify_token(process: &OwnedHandle, sid: PSID) -> io::Result<()> {
fn verify_token(process: &OwnedHandle, sid: PSID, capability: PSID) -> io::Result<()> {
let mut token = null_mut();
if unsafe { OpenProcessToken(process.as_raw_handle(), TOKEN_QUERY, &mut token) } == 0 {
return Err(io::Error::last_os_error());
@@ -918,7 +943,9 @@ fn verify_token(process: &OwnedHandle, sid: PSID) -> io::Result<()> {
let value = &*buffer.as_ptr().cast::<TOKEN_APPCONTAINER_INFORMATION>();
!value.TokenAppContainer.is_null() && EqualSid(value.TokenAppContainer, sid) != 0
} else {
(*buffer.as_ptr().cast::<TOKEN_GROUPS>()).GroupCount == 0
// Exactly the one granted capability, and nothing else.
let groups = &*buffer.as_ptr().cast::<TOKEN_GROUPS>();
groups.GroupCount == 1 && EqualSid(groups.Groups[0].Sid, capability) != 0
}
};
if !valid {
@@ -1049,6 +1076,73 @@ fn token_has_lpac_attribute(token: &OwnedHandle) -> io::Result<bool> {
Ok(false)
}
/// The only capability a Native LPAC host receives: `registryRead`. Without
/// it an LPAC cannot open any registry key, so Winsock initialization, which
/// reads its catalog under HKLM, fails and Node aborts at startup. It grants
/// no file, network or COM access; socket creation stays denied, which the
/// isolation probe checks.
struct CapabilitySid {
sids: *mut PSID,
count: u32,
groups: *mut PSID,
group_count: u32,
}
impl CapabilitySid {
fn registry_read() -> io::Result<Self> {
use windows_sys::Win32::Security::DeriveCapabilitySidsFromName;
let name = wide(OsStr::new("registryRead"))?;
let mut value = Self {
sids: null_mut(),
count: 0,
groups: null_mut(),
group_count: 0,
};
// SAFETY: nul-terminated name and initialized out pointers; the
// returned arrays and SIDs are freed by Drop with LocalFree.
if unsafe {
DeriveCapabilitySidsFromName(
name.as_ptr(),
&mut value.groups,
&mut value.group_count,
&mut value.sids,
&mut value.count,
)
} == 0
{
return Err(io::Error::last_os_error());
}
if value.count != 1 || value.sids.is_null() {
return Err(io::Error::other(
"registryRead did not derive exactly one capability SID",
));
}
Ok(value)
}
fn sid(&self) -> PSID {
// SAFETY: registry_read verified exactly one non-null entry.
unsafe { *self.sids }
}
}
impl Drop for CapabilitySid {
fn drop(&mut self) {
// SAFETY: each SID and each array came from DeriveCapabilitySidsFromName.
unsafe {
for (array, count) in [(self.sids, self.count), (self.groups, self.group_count)] {
if array.is_null() {
continue;
}
for index in 0..count as usize {
LocalFree(*array.add(index));
}
LocalFree(array.cast());
}
}
}
}
fn set_acl(file: &File, sid: PSID, access: u32, inheritance: u32) -> io::Result<()> {
edit_acl(file, sid, access, inheritance, GRANT_ACCESS)
}
@@ -1109,8 +1203,17 @@ fn edit_acl(
return Err(io::Error::last_os_error());
}
let preserved = control & (SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED);
let (acl_revision, acl_size, ace_count) = unsafe {
let acl = &*old_acl;
// An unprotected file has no children, so SetSecurityInfo cannot
// propagate from it; with UNPROTECTED it re-derives the inherited entries
// from the parent. Writing a file's inherited entries back (either API)
// turned them into explicit ones on hosted Windows. Directories and
// protected DACLs keep the exact kernel write, which never propagates.
let derive_inherited = preserved & SE_DACL_PROTECTED == 0 && !file.metadata()?.is_dir();
let old = std::ptr::NonNull::new(old_acl)
.ok_or_else(|| io::Error::other("refusing to replace an unrestricted DACL"))?;
let (acl_revision, acl_size, ace_count) = {
// SAFETY: GetSecurityInfo returned this DACL inside `descriptor`.
let acl = unsafe { old.as_ref() };
(
u32::from(acl.AclRevision),
usize::from(acl.AclSize),
@@ -1146,7 +1249,10 @@ fn edit_acl(
if unsafe { GetAce(old_acl, index, &mut ace) } == 0 {
return Err(io::Error::last_os_error());
}
let header = unsafe { &*ace.cast::<ACE_HEADER>() };
let ace = std::ptr::NonNull::new(ace)
.ok_or_else(|| io::Error::other("GetAce returned no entry"))?;
// SAFETY: GetAce returned a pointer to an entry inside the live DACL.
let header = unsafe { ace.cast::<ACE_HEADER>().as_ref() };
let flags = u32::from(header.AceFlags);
let inherited = flags & INHERITED_ACE != 0;
// Only ACCESS_ALLOWED_ACE carries its SID at SidStart.
@@ -1154,13 +1260,18 @@ fn edit_acl(
&& usize::from(header.AceSize) >= size_of::<ACCESS_ALLOWED_ACE>()
&& unsafe {
EqualSid(
std::ptr::addr_of_mut!((*ace.cast::<ACCESS_ALLOWED_ACE>()).SidStart).cast(),
std::ptr::addr_of_mut!((*ace.cast::<ACCESS_ALLOWED_ACE>().as_ptr()).SidStart)
.cast(),
sid,
)
} != 0;
if ours && grant && !inherited && flags == inheritance {
// GRANT_ACCESS semantics: combine with the existing explicit grant.
merged |= unsafe { (*ace.cast::<ACCESS_ALLOWED_ACE>()).Mask };
merged |= unsafe { ace.cast::<ACCESS_ALLOWED_ACE>().as_ref().Mask };
continue;
}
if derive_inherited && inherited {
// Windows re-derives these from the parent on write.
continue;
}
if ours && !grant {
@@ -1182,7 +1293,7 @@ fn edit_acl(
new_acl,
acl_revision,
u32::MAX,
ace,
ace.as_ptr(),
u32::from(header.AceSize),
)
} == 0
@@ -1195,40 +1306,67 @@ fn edit_acl(
{
return Err(io::Error::last_os_error());
}
let mut security: SECURITY_DESCRIPTOR = unsafe { zeroed() };
let security_ptr = (&mut security as *mut SECURITY_DESCRIPTOR).cast();
// NTFS stores this descriptor as given, with no inheritance merge. The
// kernel keeps SE_DACL_AUTO_INHERITED only when SE_DACL_AUTO_INHERIT_REQ
// accompanies it, so request it exactly when the object already had it;
// otherwise the object would silently become a legacy DACL.
let requested = preserved
| if preserved & SE_DACL_AUTO_INHERITED != 0 {
SE_DACL_AUTO_INHERIT_REQ
} else {
0
};
if unsafe { InitializeSecurityDescriptor(security_ptr, SECURITY_DESCRIPTOR_REVISION) } == 0
|| unsafe { SetSecurityDescriptorDacl(security_ptr, 1, new_acl, 0) } == 0
|| unsafe {
SetSecurityDescriptorControl(
security_ptr,
SE_DACL_AUTO_INHERIT_REQ | SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED,
requested,
)
} == 0
|| unsafe {
SetKernelObjectSecurity(
if derive_inherited {
use windows_sys::Win32::Security::Authorization::SetSecurityInfo;
use windows_sys::Win32::Security::UNPROTECTED_DACL_SECURITY_INFORMATION;
// SAFETY: the pinned handle and the explicit-only ACL outlive the call.
let result = unsafe {
SetSecurityInfo(
file.as_raw_handle(),
DACL_SECURITY_INFORMATION,
security_ptr,
SE_FILE_OBJECT,
DACL_SECURITY_INFORMATION | UNPROTECTED_DACL_SECURITY_INFORMATION,
null_mut(),
null_mut(),
new_acl,
null(),
)
} == 0
{
return Err(io::Error::last_os_error());
};
if result != 0 {
return Err(io::Error::from_raw_os_error(result as i32));
}
} else {
let mut security: SECURITY_DESCRIPTOR = unsafe { zeroed() };
let security_ptr = (&mut security as *mut SECURITY_DESCRIPTOR).cast();
// NTFS stores this descriptor as given, with no inheritance merge. The
// kernel keeps SE_DACL_AUTO_INHERITED only when SE_DACL_AUTO_INHERIT_REQ
// accompanies it, so request it exactly when the object already had it;
// otherwise the object would silently become a legacy DACL.
let requested = preserved
| if preserved & SE_DACL_AUTO_INHERITED != 0 {
SE_DACL_AUTO_INHERIT_REQ
} else {
0
};
if unsafe { InitializeSecurityDescriptor(security_ptr, SECURITY_DESCRIPTOR_REVISION) } == 0
|| unsafe { SetSecurityDescriptorDacl(security_ptr, 1, new_acl, 0) } == 0
|| unsafe {
SetSecurityDescriptorControl(
security_ptr,
SE_DACL_AUTO_INHERIT_REQ | SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED,
requested,
)
} == 0
|| unsafe {
SetKernelObjectSecurity(
file.as_raw_handle(),
DACL_SECURITY_INFORMATION,
security_ptr,
)
} == 0
{
return Err(io::Error::last_os_error());
}
}
drop(descriptor);
let (still_granted, control) = read_back_dacl(file, sid)?;
if control & (SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED) != preserved {
// A re-derived file DACL is auto-inherited by construction; it must stay
// unprotected. A kernel-written DACL keeps both bits exactly.
let checked = if derive_inherited {
SE_DACL_PROTECTED
} else {
SE_DACL_AUTO_INHERITED | SE_DACL_PROTECTED
};
if control & checked != preserved & checked {
return Err(io::Error::other(
"edited DACL changed its inheritance control bits",
));
@@ -1271,20 +1409,25 @@ fn read_back_dacl(file: &File, sid: PSID) -> io::Result<(bool, u16)> {
if unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) } == 0 {
return Err(io::Error::last_os_error());
}
if acl.is_null() {
let Some(dacl) = std::ptr::NonNull::new(acl) else {
return Ok((false, control));
}
for index in 0..u32::from(unsafe { (*acl).AceCount }) {
};
// SAFETY: GetSecurityInfo returned this DACL inside `descriptor`.
for index in 0..u32::from(unsafe { dacl.as_ref().AceCount }) {
let mut ace = null_mut();
if unsafe { GetAce(acl, index, &mut ace) } == 0 {
return Err(io::Error::last_os_error());
}
let header = unsafe { &*ace.cast::<ACE_HEADER>() };
let ace = std::ptr::NonNull::new(ace)
.ok_or_else(|| io::Error::other("GetAce returned no entry"))?;
// SAFETY: GetAce returned a pointer to an entry inside the live DACL.
let header = unsafe { ace.cast::<ACE_HEADER>().as_ref() };
if u32::from(header.AceType) == ACCESS_ALLOWED_ACE_TYPE
&& usize::from(header.AceSize) >= size_of::<ACCESS_ALLOWED_ACE>()
&& unsafe {
EqualSid(
std::ptr::addr_of_mut!((*ace.cast::<ACCESS_ALLOWED_ACE>()).SidStart).cast(),
std::ptr::addr_of_mut!((*ace.cast::<ACCESS_ALLOWED_ACE>().as_ptr()).SidStart)
.cast(),
sid,
)
} != 0
+5 -1
View File
@@ -769,7 +769,11 @@ impl WorkspaceFile {
}
#[cfg(windows)]
fn rename_windows_opened(file: &File, filename: &std::ffi::OsStr, replace: bool) -> io::Result<()> {
pub(crate) fn rename_windows_opened(
file: &File,
filename: &std::ffi::OsStr,
replace: bool,
) -> io::Result<()> {
use std::mem::{offset_of, size_of};
use std::os::windows::ffi::OsStrExt;
use std::os::windows::io::AsRawHandle;
+2 -1
View File
@@ -7231,7 +7231,8 @@ pub fn mutate_config<T>(
if rendered.len() as u64 > MAX_MCP_CONFIG_BYTES {
anyhow::bail!("MCP config exceeds the 1 MiB limit");
}
write_atomic(path, rendered.as_bytes())?;
write_atomic(path, rendered.as_bytes())
.with_context(|| format!("Failed to write MCP config {}", path.display()))?;
Ok((result, config_revision(Some(&rendered))))
})
}
+112 -27
View File
@@ -469,6 +469,31 @@ pub fn write_atomic_batch(files: &[(PathBuf, Vec<u8>)]) -> std::io::Result<()> {
Ok(())
}
/// The file name Windows will create for `path`. The native rename used by
/// `write_atomic_scoped` takes the name literally, while Win32 path APIs drop
/// trailing dots and spaces and map device names (`CON`); refuse a name that
/// Windows would rewrite instead of creating a file nothing else can open.
#[cfg(windows)]
fn windows_atomic_target_name(path: &Path) -> std::io::Result<std::ffi::OsString> {
let invalid = || {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!(
"Windows would not write this file name as given: {}",
path.display()
),
)
};
let name = path.file_name().ok_or_else(invalid)?;
let absolute = std::path::absolute(path)?;
if absolute.file_name() != Some(name)
|| absolute.as_os_str().to_string_lossy().starts_with(r"\\.\")
{
return Err(invalid());
}
Ok(name.to_owned())
}
fn write_atomic_scoped(
path: &Path,
contents: &[u8],
@@ -529,41 +554,69 @@ fn write_atomic_scoped(
sweep_stale_atomic_write_temps(parent);
}
// Validated before any temp exists, so a refused name leaves nothing.
#[cfg(windows)]
let target_name = windows_atomic_target_name(path)?;
#[cfg(not(windows))]
let mut tmp = builder.tempfile_in(parent)?;
std::io::Write::write_all(&mut tmp, contents)?;
// DELETE on the temp handle lets Windows rename through that handle.
#[cfg(windows)]
let mut tmp = {
use std::os::windows::fs::OpenOptionsExt;
use windows_sys::Win32::Storage::FileSystem::{
DELETE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_SHARE_READ,
};
builder.make_in(parent, |temp| {
fs::OpenOptions::new()
.write(true)
.create_new(true)
.access_mode(FILE_GENERIC_READ | FILE_GENERIC_WRITE | DELETE)
.share_mode(FILE_SHARE_READ)
.open(temp)
})?
};
// Atomic replacement creates a new inode. Restore ordinary access /
// executable bits of an existing workspace file before persisting.
#[cfg(unix)]
if let Some(mode) = existing_workspace_mode {
use std::os::unix::fs::PermissionsExt;
tmp.as_file()
.set_permissions(fs::Permissions::from_mode(mode))?;
#[cfg(not(windows))]
{
std::io::Write::write_all(&mut tmp, contents)?;
// Atomic replacement creates a new inode. Restore ordinary access /
// executable bits of an existing workspace file before persisting.
#[cfg(unix)]
if let Some(mode) = existing_workspace_mode {
use std::os::unix::fs::PermissionsExt;
tmp.as_file()
.set_permissions(fs::Permissions::from_mode(mode))?;
}
tmp.as_file().sync_all()?;
tmp.persist(path)?;
}
tmp.as_file().sync_all()?;
#[cfg(windows)]
{
// Keep the already-synced tempfile and retry only the transient Win32
// sharing/lock failures; permanent permission errors still surface.
let mut pending = tmp;
let mut attempt = 0;
loop {
match pending.persist(path) {
Ok(_) => break,
Err(err) => {
let Some(backoff) = windows_publish_retry_delay(&err.error, attempt) else {
return Err(err.error);
};
pending = err.file;
std::thread::sleep(backoff);
attempt += 1;
}
// MoveFileExW (tempfile::persist) reopens the destination directory
// with FILE_ADD_FILE, which conflicts with Fleet's read-only-shared
// ancestor pins (fleet/files.rs). Rename through the synced temp handle
// with a bare file name, so the parent is never reopened; the rename
// retries transient sharing/lock failures itself.
let result = (|| {
std::io::Write::write_all(&mut tmp, contents)?;
tmp.as_file().sync_all()?;
crate::fleet::files::rename_windows_opened(tmp.as_file(), &target_name, true)
})();
match result {
// The temp name is vacant now; never unlink a later entry.
Ok(()) => tmp.disable_cleanup(true),
Err(err) => {
// Close the delete-denying handle before deleting the temp.
let _ = tmp.into_temp_path().close();
return Err(std::io::Error::new(
err.kind(),
format!("replace {}: {err}", path.display()),
));
}
}
}
#[cfg(not(windows))]
tmp.persist(path)?;
// Fsync the parent directory so the rename (the new directory entry) is
// itself durable — otherwise a power loss right after the rename can lose
// it even though the file data was synced, silently dropping a
@@ -1219,6 +1272,38 @@ mod atomic_write_tests {
assert_eq!(fs::read(&path).expect("read replacement"), b"new content");
}
#[cfg(windows)]
#[test]
fn write_atomic_writes_beside_live_fleet_pins_and_refuses_rewritten_names() {
// A live Fleet ledger keeps every ancestor of its files open with
// read-only sharing, so MoveFileExW could not add an entry there
// (hosted os error 32 writing mcp.json).
let workspace = tempdir().expect("tempdir");
let _pins = crate::fleet::files::WorkspaceFile::open(
workspace.path(),
Path::new(".codewhale/fleet.jsonl"),
true,
)
.expect("pin workspace ancestors");
let created = workspace.path().join("created.json");
write_atomic(&created, b"new").expect("create beside pins");
assert_eq!(fs::read(&created).expect("read created"), b"new");
write_atomic(&created, b"replaced").expect("replace beside pins");
assert_eq!(fs::read(&created).expect("read replaced"), b"replaced");
for name in ["trailing.", "trailing ", "CON"] {
assert!(
write_atomic(&workspace.path().join(name), b"x").is_err(),
"{name}"
);
}
let strays: Vec<_> = fs::read_dir(workspace.path())
.expect("read_dir")
.filter_map(Result::ok)
.filter(|entry| entry.file_name().to_string_lossy().starts_with(".tmp"))
.collect();
assert!(strays.is_empty(), "{strays:?}");
}
#[test]
fn write_atomic_no_temp_left_behind_on_success() {
let tmp = tempdir().expect("tempdir");
+1
View File
@@ -58,6 +58,7 @@ notes, and relevant issue/PR comments.
- **[cenab](https://github.com/cenab)** — requested the Tsubasa provider row and supplied its endpoint, key and model values ([#6695](https://github.com/Hmbown/Codewhale/issues/6695)).
- **[BX166](https://github.com/BX166)** — reported the AICraft provider row missing its key console, docs link and guidance, and supplied the values ([#6616](https://github.com/Hmbown/Codewhale/issues/6616)); the report also surfaced that no bundled descriptor's links reached the setup form.
- **[jayanthvee](https://github.com/jayanthvee)** — reported and diagnosed that killing the npm launcher's `node.exe` ends Windows sessions without cleanup, with reproductions and fix directions ([#6827](https://github.com/Hmbown/Codewhale/issues/6827)).
</details>
+2 -1
View File
@@ -236,7 +236,8 @@
"@wuisabel-gif",
"@BX166",
"@SparkofSpike",
"@cenab"
"@cenab",
"@jayanthvee"
]
},
"screenshot": {
+1 -1
View File
@@ -47,4 +47,4 @@ export const UNRELEASED_CONTRIBUTORS: string[] = [];
* Contributors who helped with reports, reproductions, and verification.
* Credit covers the 0.10.1 reports recorded in docs/CONTRIBUTORS.md.
*/
export const RELEASE_HELPERS: string[] = ["@BX166", "@cenab"];
export const RELEASE_HELPERS: string[] = ["@BX166", "@cenab", "@jayanthvee"];