mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
fix(windows): start Native hosts in LPAC; keep atomic writes beside pins
Windows Native hosts: on02fcc3e0the 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 on02fcc3e0). 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 HEADcf033337and 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. Hosted02fcc3e0: 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:
1 parent
02fcc3e07d
commit
af5fa5c23f
20 files changed
+494
-153
No files matched your search
+32
-6
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"modules": {
|
||||
"harness": "8b40ab8734838da9513106ba576b7140656ff254f67c81c0b52365bc2b497aaa",
|
||||
"harness": "bf685db5e808ab708ec698e1bc038d173db59f2facb6907fdbd689f336123f8f",
|
||||
"mcp": "d5eb38941113934f9768e90ab3f1db021b93980e41be5cdf8836489b7f233b55"
|
||||
}
|
||||
}
|
||||
+14
-4
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
@@ -147,7 +147,7 @@ impl BuiltinModule {
|
||||
pub(crate) const BUILTIN_MODULES: &[BuiltinModule] = &[
|
||||
BuiltinModule {
|
||||
id: "harness",
|
||||
source_sha256: "8b40ab8734838da9513106ba576b7140656ff254f67c81c0b52365bc2b497aaa",
|
||||
source_sha256: "bf685db5e808ab708ec698e1bc038d173db59f2facb6907fdbd689f336123f8f",
|
||||
tools: &[],
|
||||
},
|
||||
BuiltinModule {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -236,7 +236,8 @@
|
||||
"@wuisabel-gif",
|
||||
"@BX166",
|
||||
"@SparkofSpike",
|
||||
"@cenab"
|
||||
"@cenab",
|
||||
"@jayanthvee"
|
||||
]
|
||||
},
|
||||
"screenshot": {
|
||||
|
||||
@@ -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"];
|
||||
Reference in new issue
Block a user