Skip to content

windows: run Bun inside an AppContainer (lowbox token) - #33119

Merged
dylan-conway merged 98 commits into
mainfrom
dylan/win-appcontainer
Jul 20, 2026
Merged

dylan-conway merged 98 commits into
mainfrom
dylan/win-appcontainer

Conversation

@dylan-conway

@dylan-conway dylan-conway commented Jun 30, 2026 •

Copy link
Copy Markdown
Member

Makes Bun usable inside a Windows AppContainer (lowbox token), the sandbox used by packaged apps and embedders that launch worker processes with CreateAppContainerProfile + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES. Depends on the libuv-side fixes in oven-sh/libuv#7 (cherry-pick of upstream libuv/libuv#5181's AppContainer pipe-namespace fix) and oven-sh/libuv#8 (fs stat/realpath bounds and tty exact-fill correctness fixes), both merged into the bun branch and pinned here.

The four unconditional changes below apply everywhere; the two AppContainer-only changes are gated on bun_sys::windows::is_app_container() (a cached GetTokenInformation(TokenIsAppContainer) probe) and are no-ops outside a container.

Changes

Resolver ancestor-directory tolerance (cross-platform, unconditional). bun run <script> failed with error loading current directory when any ancestor directory on the path to cwd was unreadable: the resolver builds a DirInfo for every ancestor starting at the drive root, and a sandboxed token (or an execute-only 0o111 unix directory, Android /data, etc.) denies that listing. A permission-denied ancestor is now treated as an opaque empty directory, the same treatment the existing ENOTDIR tolerance applies; errors on the requested directory itself stay fatal. Also fixes #28220 and #30859.

Windows O_RDONLY open no longer requests FILE_WRITE_ATTRIBUTES (Windows, unconditional). The openat base access mask unconditionally included FILE_WRITE_ATTRIBUTES, so opening a file O_RDONLY on a tree with an RX-only ACL grant (Program Files, read-only shares, the normal sandbox project-tree shape) failed EPERM. The mask now matches libuv's fs__open (O_RDONLY -> GENERIC_READ only; write modes already include it via GENERIC_WRITE); fs.futimes continues to work via libuv's ReOpenFile(FILE_WRITE_ATTRIBUTES) at futimes time. Unskips test-module-readonly.js.

Windows directory opens no longer request FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY (Windows, unconditional). NtCreateFile with a RootDirectory handle checks the target directory's ACL for child creates and renames, not the handle's access mask, so these bits grant nothing and only narrow where the open is admitted. Dropping them lets Bun.Glob/recursive readdir/fs.opendir descend RX-only directories (Program Files, read-only shares, sandboxed project trees); creating children through the handle still works where the ACL allows it. Removes the now-vestigial WindowsOpenDirOptions.read_only field.

Named-pipe listen failures surface as Node-shaped errors (Windows, unconditional). They were a codeless ERR_INVALID_ARG_TYPE TypeError; now an Error with code/errno/syscall/path set, matching the POSIX unix-socket listen path. Fixes #30265.

AppContainer-only (gated on is_app_container(); no-ops outside):

  • GetFinalPathNameByHandleW(VOLUME_NAME_DOS) is denied on every handle inside an AppContainer because the DOS-name translation opens the mount manager. For handles on the system volume, reconstruct the DOS name as <system-drive>: + the VOLUME_NAME_NT tail (the system directory carries an ALL APPLICATION PACKAGES:(RX) ACE by Windows default, so its device name is resolvable from any lowbox); handles on any other volume surface the original denial. Applies to both the typed bun_sys wrapper and a raw-ABI drop-in. This is what keeps Bun's resolver and bun install working inside a container; user-facing fs.realpath goes through libuv and is left at Node parity (fails EPERM).
  • Bun.Terminal ConPTY internal pipe names: insert LOCAL\ into the \\.\pipe\... name inside a container (the only namespace an AppContainer may create server pipes under), matching libuv's conditional insert.

deps: pins oven-sh/libuv f6e75a7e (= bun branch after #7 and #8). Behavioural delta at this pin: libuv's internal pipe names gain LOCAL\ inside a container (upstream #5181); uv_fs_stat of files the OS holds exclusively at a drive root (the C:\pagefile.sys class) reports the real stats instead of ENOENT; uv_fs_realpath preserves the real error instead of masking as EBADF; console line reads don't tear characters on an exact-fill allocation and report UV_ENOBUFS for allocations too small to convert into.

Known limitations

  • "ignore" stdio opens the NUL device, whose default ACL denies AppContainer tokens; grant the device ACL to the container SIDs from an elevated context per boot, or use "inherit" stdin.
  • fs.realpath (all variants) fails EPERM inside a container, as it does under Node.js; Bun's own module resolution does not go through it.
  • The isolated linker is unsupported in sandboxes (its junctions are quarantined by the kernel); use the default hoisted linker.
  • A package cache primed outside the container is currently re-validated as a miss inside it; prefer letting the sandboxed process populate its own cache.

Tests

test/js/bun/windows/appcontainer.test.ts launches bun inside a real AppContainer in the regular Windows CI lanes (bun:ffi lowbox launcher, no admin needed) and asserts the sandbox-only behaviours (piped-stdio spawn, LOCAL\ pipe namespace, fs.realpath denial, fork + IPC); hosts that cannot run sandboxed children skip visibly. resolver-permission-denied-ancestor.test.ts covers the ancestor tolerance on unix with an execute-only directory. The glob scan.test.ts RX-only case and named-pipe-listen-error.test.ts error-shape assertions cover the unconditional Windows changes.

robobun and others added 10 commits June 29, 2026 16:31
…hNameByHandle

Inside an AppContainer (lowbox token) the DOS volume-name translation is
denied (the mount manager device cannot be opened), so every
GetFinalPathNameByHandleW(VOLUME_NAME_DOS) call fails with
ERROR_ACCESS_DENIED while VOLUME_NAME_NT and VOLUME_NAME_NONE still work.
That single failure breaks running a script by path, bun install, bun
build, bunx, and every relative openat whose path contains a dot.

On that specific failure, rebuild the DOS path from the NT device form
plus a device->drive map learned from paths whose DOS and NT spellings
are both known to the process (the cwd and the executable directory),
verified against VOLUME_NAME_NONE so a junction cannot poison an entry.

Also prefix the ConPTY pipe names with LOCAL\ so Bun.Terminal works in
an AppContainer; outside one the prefix is just part of the name.
The package installer and the isolated-install linker called the raw
GetFinalPathNameByHandleW, so inside an AppContainer they still failed
with EPERM (failed opening node_modules/package dir) after the central
wrapper learned to survive the mount-manager denial. Add a raw-call-
compatible variant that applies the same NT-device fallback and keeps
the \\?\ prefix, and use it at those call sites.
Picks up oven-sh/libuv#5: LOCAL\ internal pipe names with bounded,
randomized access-denied retries, console read cancellation that works
when input injection is denied, sandbox-rewritten junction readback,
EACCES for namespace-denied pipe binds, and assorted error-reporting
fixes (uv_pipe translation, EMFILE, realpath GetLastError, stat
out-of-bounds).
The resolver builds DirInfo for every ancestor of the requested
directory starting at the drive root, and treated any open failure as
fatal for the whole resolution. Sandboxed processes (e.g. a Windows
AppContainer) can read their granted project tree but not the drive
root or profile directories above it, so 'bun run' failed with 'error
loading current directory' even though the cwd itself was readable.

Treat EPERM/EACCES on an ancestor like an opaque, empty directory and
keep walking; nothing above the readable tree can contribute a
package.json or node_modules. Errors on the requested directory itself
remain fatal.
A sandboxed process (e.g. a Windows AppContainer) is denied the DOS
volume-name translation, which broke all four realpath entry points
differently:

- fs.realpathSync.native / fs.realpath.native / fs.promises.realpath
  route through uv_fs_realpath, which always fails EPERM there. On that
  error, resolve off an opened handle instead: open with backup
  semantics and read the final path back via get_fd_path, which carries
  the lowbox-aware GetFinalPathNameByHandle fallback. Other errors and
  the fallback's own failures still report the original realpath error.
- fs.realpathSync / fs.realpath walk the path component by component
  with lstat, starting at the drive root, and drive roots and profile
  directories carry no ACE for a sandboxed token. Treat an
  EPERM/EACCES component as a plain, hard directory (not a link, not a
  pipe or socket) instead of failing the whole walk; everything the
  process can actually traverse still resolves normally.
A failed pipe listen threw a generic invalid-arguments TypeError
('Failed to listen at ...') with no code, errno, or syscall. Callers
need to distinguish EADDRINUSE (name taken; retry with another name)
from EACCES (pipe namespace denied, e.g. a sandboxed process binding
outside \.\pipe\LOCAL\; renaming will never help).

Propagate the libuv error through the named-pipe listening context and
build a Node-shaped SystemError (code/errno/syscall/path) from it,
falling back to the generic error only for failures with no system
error code.
Spawns with an ignored stdio slot substitute an anonymous pipe when the
NUL device is denied (Windows Server AppContainers).
The kernel silently rewrites junctions created by a sandboxed process
(e.g. a Windows AppContainer) into untrusted mount points that nothing
can traverse, while creation still reports success. The isolated
linker's junction fallback then produced a node_modules full of dead
links with a green install summary.

- Probe the junction after creating it in symlink_or_junction; if
  traversal reports ERROR_UNTRUSTED_MOUNT_POINT, remove it and fail
  with EACCES so the installer reports the package instead of
  pretending it linked.
- Map ERROR_UNTRUSTED_MOUNT_POINT (448) to EACCES generally; reads
  through such a junction surfaced EUNKNOWN before.
Covers the host configuration a sandboxed launch needs (ACL grants,
network capabilities, window-station access for services, loopback
exemption) and the platform behaviors that cannot be configured away
(LOCAL\ pipe namespace, no true symlinks, quarantined junctions,
inaccessible home directory, limited process visibility).
@mintlify

mintlify Bot commented Jun 30, 2026 •

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 30, 2026, 12:58 AM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@robobun

robobun commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 6:52 AM PT - Jul 20th, 2026

@robobun, your commit 2304480 is building: #76301

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. node:fs: fs.promises.realpath uses the non-native realpath variant instead of realpath.native #32963 - fs.promises.realpath uses the non-native realpath variant; this PR adds a native-variant (handle-based) fallback path
  2. bun run fails with CouldntReadCurrentDirectory when ancestor directories are not readable #28220 - bun run fails with CouldntReadCurrentDirectory when ancestor directories are not readable; this PR's resolver DirInfo ancestor walk now treats EPERM/EACCES as opaque empty directories instead of aborting
  3. Bun panics with Internal assertion failure on net.listen() to a busy named pipe (Windows) instead of emitting EADDRINUSE #30265 - Bun panics on net.listen() to a busy named pipe instead of emitting EADDRINUSE; this PR surfaces named pipe listen errors as proper Node-shaped SystemError with errno/syscall/path
  4. Android aarch64 (Termux): filesystem access + cwd + install permission issues #30859 - Android Termux filesystem access issues (CouldntReadCurrentDirectory, AccessDenied on ancestor dirs like /data/); same class of resolver ancestor-walk abort on EACCES fixed by the DirInfo change

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #32963
Fixes #28220
Fixes #30265
Fixes #30859

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. windows: fix path canonicalization and ConPTY pipe names inside an AppContainer #33107 - Same AppContainer path canonicalization and ConPTY pipe name fixes; explicitly superseded by this PR
  2. spawn: fix infinite hang on piped stdio in a Windows AppContainer #33085 - AppContainer piped stdio hang fix subsumed by this PR's libuv bump

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Windows AppContainer support across lowbox path resolution, realpath and resolver fallback behavior, named-pipe error shaping, install path handling, and documentation. It also updates the pinned libuv commit and Windows errno mappings.

Changes

Windows AppContainer Support

Layer / File(s) Summary
Win32 error constants and lowbox path fallback
src/windows_sys/externs.rs, src/errno/windows_errno.rs, src/sys/windows/mod.rs
Adds the UNTRUSTED_MOUNT_POINT constants, declares the needed Windows token/last-error APIs, and implements the lowbox GetFinalPathNameByHandleWLowbox path reconstruction plus the GetFinalPathNameByHandle denial fallback.
Realpath and resolver permission handling
src/sys/lib.rs, src/runtime/node/node_fs.rs, src/js/node/fs.ts, src/resolver/resolver.rs, test/js/bun/resolve/resolver-permission-denied-ancestor.test.ts, test/js/node/fs/realpath-denied-component.test.ts
Adds handle-based realpath canonicalization and updates Windows realpath/resolution paths to fall back on EPERM/EACCES, while resolver ancestor lookup keeps permission-denied directories as opaque empty entries and the related tests assert those behaviors.
Install paths and AppContainer symlink checks
src/sys/lib.rs, src/install/PackageInstall.rs, src/install/isolated_install/Installer.rs
Switches Windows install path lookups to the lowbox final-path wrapper and adds the AppContainer token check plus junction probe/removal behavior for UNTRUSTED_MOUNT_POINT.
Windows named-pipe errors and LOCAL prefix note
src/runtime/socket/Listener.rs, src/runtime/api/bun/Terminal.rs, test/js/bun/net/named-pipe-listen-error.test.ts
Changes Windows named-pipe listen failures to surface mapped system errors when available, adds assertions for the surfaced error shape, and documents the LOCAL\\ prefix requirement for AppContainer pipe creation.
AppContainer docs and libuv pin
docs/docs.json, docs/runtime/windows-appcontainer.mdx, scripts/build/deps/libuv.ts
Adds the new AppContainer documentation page to navigation and updates the vendored libuv commit and its accompanying header comment.

Possibly related PRs

  • oven-sh/bun#32538: Also changes Windows errno translation, including explicit handling for additional NTSTATUS/Win32 error values.
  • oven-sh/bun#32643: Also changes Windows symlink/junction behavior in src/sys/lib.rs, with overlapping junction fallback and error handling paths.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title is concise and accurately summarizes the main change: running Bun inside a Windows AppContainer.
Description check ✅ Passed The description is mostly complete and includes the PR purpose plus testing/verification details, even though it doesn't use the exact template headings.

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/build/deps/libuv.ts`:
- Around line 13-19: Shorten the header comment in the libuv dependency note so
it fits the repository’s 3-line limit and stays concise. Condense the long
dependency/context explanation around the branch/SHA details into a brief
summary in the same comment block, and move any extra background into the PR
description. Keep the intent clear while trimming the text near the existing
libuv version note.

In `@src/runtime/socket/Listener.rs`:
- Around line 282-286: The newly added explanatory comments in Listener.rs
exceed the repo’s 3-line comment limit and need to be condensed. Trim the
comment block around the syscall failure handling in the relevant
listener/socket code so it stays within 3 lines while preserving only the
essential note about surfacing coded syscall failures like node:net; if more
rationale is needed, move it to the PR description instead.

In `@src/sys/windows/mod.rs`:
- Around line 3817-3822: The lowbox fallback in the GetFinalPathNameByHandleW
wrapper is missing the required NUL terminator, unlike the raw success path.
Update the fallback path in the Windows path helper (the branch using
lowbox_dos_name_fallback in the GetFinalPathNameByHandleW wrapper) to write a
trailing NUL at the returned length before returning, so callers like
PackageInstall::init_install_dir and install_from_link can safely inspect
buf[returned_len]. Ensure the terminator is written in-bounds after copying PFX
and computing the final length.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 378466a1-ea5b-4945-86ee-61f27fe81b33

📥 Commits

Reviewing files that changed from the base of the PR and between 6c1f36a and bab39c4.

📒 Files selected for processing (14)
  • docs/docs.json
  • docs/runtime/windows-appcontainer.mdx
  • scripts/build/deps/libuv.ts
  • src/errno/windows_errno.rs
  • src/install/PackageInstall.rs
  • src/install/isolated_install/Installer.rs
  • src/js/node/fs.ts
  • src/resolver/resolver.rs
  • src/runtime/api/bun/Terminal.rs
  • src/runtime/node/node_fs.rs
  • src/runtime/socket/Listener.rs
  • src/sys/lib.rs
  • src/sys/windows/mod.rs
  • src/windows_sys/externs.rs

Comment thread scripts/build/deps/libuv.ts Outdated
Comment thread src/runtime/socket/Listener.rs Outdated
Comment thread src/sys/windows/mod.rs

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
docs/runtime/windows-appcontainer.mdx (2)

84-96: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Make the smoke test module-format agnostic.

smoke.js uses require(...), so it fails immediately in any project with "type": "module". That gives a false AppContainer failure before the snippet reaches the paths this page is trying to validate. Rename it to smoke.cjs or switch the snippet to ESM imports.

Suggested doc fix
-// smoke.js — run with: bun smoke.js (inside the container)
-const { spawnSync } = require("child_process");
+// smoke.cjs — run with: bun smoke.cjs (inside the container)
+const { spawnSync } = require("child_process");
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/runtime/windows-appcontainer.mdx` around lines 84 - 96, The smoke test
snippet is not module-format agnostic because it uses CommonJS require calls in
smoke.js, which will fail in projects with "type": "module" before the
AppContainer checks run. Update the example around the smoke.js snippet to
either rename it to smoke.cjs or rewrite it to use ESM imports while preserving
the same spawnSync, realpathSync, and net.createServer behavior.

98-100: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Avoid hard-coding an unverified Bun release floor.

Bun ≥ 1.4.x is not established anywhere in the supplied PR context, and this wording will age badly if the change lands in a different release line or gets backported. Prefer wording this as “a build that includes the patched libuv/AppContainer changes” until the release target is confirmed. As per coding guidelines, "Be humble & honest — NEVER overstate what you got done or what actually works in commits, PRs or in messages to the user."

Suggested doc fix
-If `spawn+pipes` hangs or the pipe server reports `EADDRINUSE` on a fresh
-name, the Bun build predates the AppContainer support (Bun ≥ 1.4.x with the
-patched libuv is required).
+If `spawn+pipes` hangs or the pipe server reports `EADDRINUSE` on a fresh
+name, the Bun build predates the AppContainer support in this PR. Use a build
+that includes the patched libuv/AppContainer changes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/runtime/windows-appcontainer.mdx` around lines 98 - 100, The runtime
docs currently hard-code an unverified Bun version floor in the AppContainer
troubleshooting note, which should be removed. Update the wording in the Windows
AppContainer section to refer to “a build that includes the patched
libuv/AppContainer changes” instead of naming a specific release, and keep the
guidance tied to the spawn+pipes and EADDRINUSE behavior described there.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@docs/runtime/windows-appcontainer.mdx`:
- Around line 84-96: The smoke test snippet is not module-format agnostic
because it uses CommonJS require calls in smoke.js, which will fail in projects
with "type": "module" before the AppContainer checks run. Update the example
around the smoke.js snippet to either rename it to smoke.cjs or rewrite it to
use ESM imports while preserving the same spawnSync, realpathSync, and
net.createServer behavior.
- Around line 98-100: The runtime docs currently hard-code an unverified Bun
version floor in the AppContainer troubleshooting note, which should be removed.
Update the wording in the Windows AppContainer section to refer to “a build that
includes the patched libuv/AppContainer changes” instead of naming a specific
release, and keep the guidance tied to the spawn+pipes and EADDRINUSE behavior
described there.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 7be7de16-651d-410a-a508-f06ebafebfa2

📥 Commits

Reviewing files that changed from the base of the PR and between bab39c4 and ee4faaa.

📒 Files selected for processing (1)
  • docs/runtime/windows-appcontainer.mdx

Comment thread src/sys/windows/mod.rs
Comment thread src/js/node/fs.ts Outdated
robobun and others added 9 commits June 29, 2026 19:06
…ast error

The raw GetFinalPathNameByHandleW always NUL-terminates and returns the
length excluding the terminator, and two of the installer call sites
read buf[len] on a pooled buffer to decide whether to append a path
separator, so the fallback has to write that NUL too or a stale
backslash silently concatenates the package name onto its parent.
Also set the thread error back to ERROR_ACCESS_DENIED before the
raw-shape wrapper returns 0, since the fallback's own successful
queries clobber it and callers map GetLastError into the errno they
report.
…tive path

Treating a component the walk may not lstat as a plain, hard directory
was fail-open: a denied component can hide a junction or symlink, so
realpath would return the unresolved spelling while traversal through
the link still worked - defeating the canonical realpath-plus-prefix
containment pattern for any non-elevated process on Windows, sandboxed
or not.

When a component lstat fails with EPERM/EACCES, resolve the whole path
through the handle-based native realpath instead (which follows the
true chain, including in sandboxes via the lowbox-aware fallback), and
rethrow the original error if the native resolution also fails. Fail
closed, never sideways.
…nction probe

Two follow-ups for sandboxed (and merely read-only) configurations:

- openat requests FILE_WRITE_ATTRIBUTES even for O_RDONLY, so files in
  a tree granted read-execute only - the normal shape for a sandboxed
  process's project tree - failed ACCESS_DENIED even though reading is
  permitted; entry loading failed loudly and package.json resolution
  silently fell back to index.js. Retry pure read-only opens without
  the write-attributes bit when the first attempt is denied.
- The junction traversability probe now runs only when the process
  token is an AppContainer (memoized TokenIsAppContainer query): the
  kernel rewrite it detects cannot happen outside one, so normal
  installs skip the extra open per junction.
Node-shaped errors carry the negated errno (the canonical
fill_system_error_common does the same); the pipe-listen SystemError
carried it positive. Strengthen the named-pipe listen test to pin the
full error shape (code/errno/syscall) instead of swallowing the
exception.
The installer treats EPERM/EACCES rename failures on Windows as benign
in-use collisions, so an untrusted-mount-point failure during the
staging rename would silently discard the staged store entry. ELOOP -
an unresolvable link - is both more accurate for a quarantined
junction and outside the collision heuristic, so the failure
propagates. Reads through a quarantined junction now report ELOOP
instead of EACCES.
The fallback path buffer is large and only needed when uv_fs_realpath
is denied; keeping it inline reserved that stack space on every
realpath call.
The tolerance is cross-platform; an execute-only (0o111) ancestor is
the CI-runnable shape of the sandboxed drive-root case.
- Loopback: same-container processes can reach each other; only
  processes outside the container are isolated.
- Junctions: quarantine applies to client Windows builds (some Server
  builds leave the rewritten junctions traversable), and reads through
  a quarantined junction report ELOOP.
- realpath resolves through inaccessible ancestors via the native
  path rather than treating them as opaque.
- Replace the placeholder version guidance and shorten the sidebar
  title.
- Pin libuv at the head with the exact-fill console read fixes, the
  bind disambiguation hardening, the non-inheritable NUL-fallback pipe
  end, and the drive-root stat fix.
fs.realpath, fs.realpathSync, fs.realpath.native and fs.promises.realpath
now behave exactly as Node does inside an AppContainer: the component
walk's drive-root lstat and uv_fs_realpath's mount-manager query are
denied and surface as-is. Bun's own resolver and install paths resolve
through get_fd_path / the system-volume GetFinalPathNameByHandleW
fallback and are unaffected.

Drops fs.isInsideAppContainer(), sys::realpath_handle, the node_fs
handle-based retry, and the Node-parity test that pinned the removed
gate. The AppContainer integration test now asserts the denial instead.
The T0 fd_path_raw_w in bun_core calls kernel32 directly and cannot
reach the bun_sys lowbox fallback (bun_core sits below bun_sys); its
only callers are bun link / bun unlink, whose global link directory is
outside any container grant anyway.
Comment thread docs/runtime/windows-appcontainer.mdx Outdated
Comment thread test/js/bun/glob/scan.test.ts Outdated
Comment on lines +1130 to +1133
// A directory the user can read but not write (RX-only grant) must still be
// descended by the scanner: read-only directory opens used to also request
// FILE_WRITE_ATTRIBUTES and fail ACCESS_DENIED. Elevated tokens bypass the
// ACL; the precondition is probed and the test skips visibly then.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The 4-line header comment here says "read-only directory opens used to also request FILE_WRITE_ATTRIBUTES" — but glob scans open with O::DIRECTORY, which routes to open_dir_at_windows_nt_path (never the file-open arm where FILE_WRITE_ATTRIBUTES was dropped); what an RX-only grant denies there is FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY, and this test exercises the new retry wrapper (src/sys/lib.rs:6957-6978) that drops those bits — matching the PR description's own "directory opens retry without create bits ... Fixes Bun.Glob" bullet. Separately, this same 4-line block and the 5-line block at test/js/bun/net/named-pipe-listen-error.test.ts:29-33 are both over the CLAUDE.md 3-line cap (neither test file was named in any prior comment-length thread, so these read as missed in the f25f021 pass). Comment accuracy/style only, zero runtime impact.

Extended reasoning...

What the issue is

Two related nits on new-in-PR test comments:

(a) Factual accuracy — the header at test/js/bun/glob/scan.test.ts:1130-1133 reads:

// A directory the user can read but not write (RX-only grant) must still be
// descended by the scanner: read-only directory opens used to also request
// FILE_WRITE_ATTRIBUTES and fail ACCESS_DENIED. Elevated tokens bypass the
// ACL; the precondition is probed and the test skips visibly then.

It names FILE_WRITE_ATTRIBUTES as the access bit that used to fail an RX-only directory open. That's the file-open change in this PR; the glob-scan fix is the directory-open create-bits retry.

(b) Length — CLAUDE.md rule 13 caps code comments at 3 lines. This same block is 4 lines, and test/js/bun/net/named-pipe-listen-error.test.ts:29-33 is 5:

// The collision must surface as a Node-shaped system error, not a
// generic TypeError: code/errno/syscall identify EADDRINUSE so
// callers can react (retry another name) - and distinguish it from
// EACCS (pipe namespace denied, e.g. sandboxed processes binding
// outside \.\pipe\LOCAL\, where renaming never helps).

The specific code path (accuracy claim)

  1. GlobWalker::openat (src/glob/GlobWalker.rs:203) calls bun_sys::openat(fd, path, O::DIRECTORY | O::RDONLY, 0).
  2. openat_windows_impl at src/sys/lib.rs:7260 sees (flags & O::DIRECTORY) != 0 and routes to open_dir_at_windows_nt_path — the file-open arm where FILE_WRITE_ATTRIBUTES was dropped (line ~7277) is never reached for a directory open.
  3. open_dir_at_windows_nt_path_impl (src/sys/lib.rs:6989-7008) builds base_flags = STANDARD_RIGHTS_READ | FILE_READ_ATTRIBUTES | FILE_READ_EA | SYNCHRONIZE | FILE_TRAVERSE and, when !read_only, adds FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY. FILE_WRITE_ATTRIBUTES appears nowhere in the directory-open mask.
  4. The fix that makes this glob test pass is the new retry wrapper at src/sys/lib.rs:6957-6978, whose own comment states: "Plain opens request FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY; a read-only ACL grant ... denies that. Retry read-only so creates fail at create time instead."
  5. The PR description agrees verbatim: "Windows directory opens retry without create bits on denial … Fixes Bun.Glob/recursive readdir descents into read-only directories."

So the test header conflates the two unconditional Windows changes in this PR: it names the access bit from the file-open change while describing the directory-open retry the test actually exercises.

Why this looks like an oversight rather than a deliberate exception

Length: the author has already accepted and applied CLAUDE.md rule 13 six-plus times in this PR's review cycle (0929cea, 6ca60b2, 6139543, f25f021 — the last literally titled "review: trim two 4-line comments to 3"). None of the prior resolved comment-length threads (resolver.rs, Listener.rs, sys/lib.rs openat_windows_impl, node_fs.rs, windows/mod.rs, libuv.ts, fs.ts, sys/lib.rs open_dir_at_windows_nt_path) named either of these two test-file locations, so these read as simply missed rather than deliberate exceptions.

Accuracy: per REVIEW.md "Comments carry only durable non-obvious content" / "Verify semantics empirically, never from names", a future reader auditing why this test exists would grep the named bit (FILE_WRITE_ATTRIBUTES), land in openat_windows_impl's file-open arm (the unrelated change), and misattribute what this test protects — which matters if the open_dir_at_windows_nt_path retry is ever revisited.

Step-by-step proof

  1. CLAUDE.md rule 13: "Keep code comments to 3 lines max — Comments must be concise."
  2. Diff hunk @@ -1125,3 +1126,57 @@ in scan.test.ts: 4 consecutive +// ... lines at 1130-1133.
  3. Diff hunk @@ -26,6 +26,27 @@ in named-pipe-listen-error.test.ts: 5 consecutive +// ... lines at 29-33.
  4. src/sys/lib.rs:7260 — if (flags & O::DIRECTORY) != 0 { return open_dir_at_windows_nt_path(...) } → glob's O::DIRECTORY | O::RDONLY never reaches the FILE_WRITE_ATTRIBUTES change.
  5. src/sys/lib.rs:6959-6961 — retry-wrapper comment names FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY, not FILE_WRITE_ATTRIBUTES.
  6. PR description Windows directory opens retry without create bits bullet: "Fixes Bun.Glob/recursive readdir descents" — the intended attribution for this test.

Impact and fix

Zero runtime impact — comment accuracy/style only. The scan.test.ts block can be rewritten to 3 lines with the correct bit in one edit, e.g.:

// An RX-only directory must still be descended: directory opens used to also
// request FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY (denied under RX) and fail EPERM.
// Elevated tokens bypass the ACL; the precondition is probed and skipped visibly.

The named-pipe-listen-error.test.ts block just needs trimming to 3 lines.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the accuracy fix in 2304480 (FILE_ADD_FILE instead of FILE_WRITE_ATTRIBUTES) addresses (a). The length nit (b) is still outstanding: the scan.test.ts block at :1130-1133 is still 4 lines and the named-pipe-listen-error.test.ts block at :29-33 is still 5. e.g. for scan.test.ts:

// An RX-only directory must still be descended: directory opens used to request
// FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY (denied under RX) and fail ACCESS_DENIED.
// Elevated tokens bypass the ACL; the precondition is probed and skipped visibly.

and for named-pipe-listen-error.test.ts:

// The collision must surface as a Node-shaped system error (code/errno/syscall),
// not a generic TypeError, so callers can distinguish EADDRINUSE (retry another
// name) from EACCES (pipe namespace denied — renaming never helps).

robobun added 2 commits July 20, 2026 13:45
NtCreateFile with a RootDirectory handle checks the target directory's
ACL for child creates and renames, not the handle's access mask, so
requesting these bits on the directory open grants nothing and only
narrows where the open is admitted (RX-only trees, Program Files,
sandboxed project dirs). Verified empirically: a handle opened without
them still creates files, subdirectories, and accepts rename-into.

Removes the read_only option (now a no-op) and the retry-on-denial
wrapper it motivated. Neither libuv nor Zig std request these bits.
Comment thread src/sys/lib.rs
Comment on lines +6956 to +6958
// No FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY: child creates via RootDirectory
// check the directory's ACL, not this handle's access mask, so requesting
// them only narrows where this open is admitted.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Commit c2c66a7 (tip) replaced the retry-with-read_only: true approach with an unconditional drop of FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY and deleted the read_only field from WindowsOpenDirOptions, but four references to the old mechanism were missed: three code comments — src/resolver/fs.rs:1432-1433 ("iterable + read_only so the resulting handle has FILE_LIST_DIRECTORY"), src/libarchive/lib.rs:1421-1423 ("is selected by setting read_only"), src/sys/dir.rs:437-438 ("opened without read_only … unlike the read-only open_dir_* iteration helpers") — and the PR description's third Changes bullet, which still says "On EPERM/EACCES … retry with read_only: true; the original error is preserved if the retry also fails", describing a retry mechanism and struct field that no longer exist. (The still-open comment on scan.test.ts:1133 also references "the new retry wrapper (src/sys/lib.rs:6957-6978)", now gone; its accuracy point stands but the suggested wording should reference the unconditional drop.) Per REVIEW.md "One source of truth; update every consumer atomically" and CLAUDE.md #11. Comment/description accuracy only, zero runtime impact.

Extended reasoning...

What the issue is

Commit c2c66a7 ("win: drop FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY from directory opens") is the tip of this branch and changed the approach for the third unconditional Windows change: instead of retrying with read_only: true on EPERM/EACCES, open_dir_at_windows_nt_path now simply never requests FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY at all (src/sys/lib.rs:6956-6974, comment: "No FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY: child creates via RootDirectory check the directory's ACL, not this handle's access mask"), and the read_only field was deleted from WindowsOpenDirOptions (src/sys/lib.rs:6515). This PR updates every struct-literal call site (nine read_only: deletions across seven files), but four references to the old mechanism were missed in the sweep.

The specific stale references

(a) Three code comments still name the removed read_only field:

  • src/resolver/fs.rs:1432-1433 — "On Windows this must go through open_dir_at_windows_a with iterable + read_only so the resulting handle has FILE_LIST_DIRECTORY". This file is in the diff; read_only: true was removed from the struct literal at line 1446, but the comment 13 lines above it was not updated.
  • src/libarchive/lib.rs:1421-1423 — "Access mask (STANDARD_RIGHTS_READ | FILE_READ_ATTRIBUTES | FILE_READ_EA | SYNCHRONIZE | FILE_TRAVERSE) is selected by setting read_only, and FILE_OPEN_IF via OpenOrCreate". This file is in the diff; read_only: true was removed from the struct literal at line 1425, but the comment immediately above still says the mask is selected by setting it.
  • src/sys/dir.rs:437-438 — "the handle is opened without read_only so the caller may create/rename children — unlike the read-only open_dir_* iteration helpers". Not in the diff, but describes a field and a distinction that no longer exist: after c2c66a7 no directory-open handle requests FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY, so "opened without read_only" no longer implies "may create children", and the "unlike the read-only helpers" contrast is gone.

(b) The PR description's third Changes bullet still reads: "Windows directory opens retry without create bits on denial … On EPERM/EACCES for a read-intent open (OnlyOpen, !can_rename_or_delete), retry with read_only: true; the original error is preserved if the retry also fails." — describing a retry mechanism and a struct field that no longer exist. There is no retry-on-denial wrapper in the final diff; the bits are simply never requested.

(c) Knock-on to a still-open review comment — the still-open inline comment on test/js/bun/glob/scan.test.ts:1133 says the test "exercises the new retry wrapper (src/sys/lib.rs:6957-6978) that drops those bits". That comment's factual-accuracy point (the test header names FILE_WRITE_ATTRIBUTES instead of FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY) still stands, but its suggested wording should now reference the unconditional drop, not a retry.

Why this looks like an oversight rather than a deliberate exception

REVIEW.md states "One source of truth; update every consumer atomically … grep the whole repo", and the author has already accepted and applied comment-accuracy sweeps 6+ times in this PR's review cycle (0929cea, 6ca60b2, 6139543, f25f021, plus the previously-resolved 2026-07-20T10:54:43Z description-accuracy comment covering the 220395f realpath drop). The three code-comment sites are the only remaining WindowsOpenDirOptions-context read_only references (a grep for read_only in src/**/*.rs finds these three plus unrelated hits: GlobalCache::read_only, open_file_read_only, CriticalSection::begin_read_only). The PR-description bullet is the same class as the previously-resolved comment on the 220395f realpath sections, but for a different bullet made stale by a later commit (c2c66a7) that landed after that thread was resolved.

Not a duplicate

  • Previous comment Fix errors in bun bun (broke after threading) #15 covered the FILE_WRITE_ATTRIBUTES comment in PackageInstall.rs (different file, different field, already fixed in this PR's diff).
  • The resolved 2026-07-20T10:54:43Z description-accuracy comment covered the fs.realpath sections made stale by 220395f; this is the directory-opens bullet made stale by c2c66a7, which is the tip and landed after that thread was resolved.
  • The still-open scan.test.ts:1133 comment targets the test-header comment, not the PR description or the three source comments; it is mentioned here only because its own suggested wording now references the removed retry wrapper.

Step-by-step proof

  1. git log --oneline -1 → c2c66a70 win: drop FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY from directory opens (tip commit).
  2. Diff at src/sys/lib.rs:6515 — - pub read_only: bool, removes the field from WindowsOpenDirOptions.
  3. Diff at src/sys/lib.rs:6956-6974 — the read_only_flag = if options.read_only { 0 } else { FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY } branch is replaced by a 3-line comment + unconditional base_flags with no create bits and no retry logic.
  4. src/resolver/fs.rs:1433 — reads verbatim "iterable + read_only so the resulting handle has FILE_LIST_DIRECTORY"; the diff hunk for this file removes read_only: true, from :1446 but leaves the comment.
  5. src/libarchive/lib.rs:1422 — reads verbatim "is selected by setting read_only"; the diff hunk for this file removes read_only: true, from :1425 but leaves the comment.
  6. src/sys/dir.rs:437-438 — reads verbatim "opened without read_only so the caller may create/rename children — unlike the read-only open_dir_* iteration helpers"; not in the diff, so untouched by the sweep.
  7. PR description third Changes bullet — reads verbatim "On EPERM/EACCES for a read-intent open (OnlyOpen, !can_rename_or_delete), retry with read_only: true; the original error is preserved if the retry also fails" — none of read_only, "retry", or "the original error is preserved" exist in the final diff.

Impact and fix

Zero runtime impact — comment and description accuracy only. Suggested edits:

  • src/resolver/fs.rs:1432-1433 — drop "+ read_only": "…with iterable so the resulting handle has FILE_LIST_DIRECTORY + FILE_DIRECTORY_FILE".
  • src/libarchive/lib.rs:1421-1423 — the access-mask sentence is now the unconditional base_flags, so: "Access mask is the read-only base_flags set; FILE_OPEN_IF via OpenOrCreate." (or simply drop the first clause).
  • src/sys/dir.rs:436-439 — the create/rename distinction no longer exists at the directory-open level; drop the "opened without read_only … unlike the read-only helpers" sentence, keeping the "On Windows the flags select the access mask: iterate adds FILE_LIST_DIRECTORY" part.
  • PR description third bullet — replace with: "Windows directory opens no longer request FILE_ADD_FILE | FILE_ADD_SUBDIRECTORY: child creates via RootDirectory check the directory's ACL, not the handle's access mask, so requesting them only narrowed where the open was admitted. Fixes Bun.Glob/recursive readdir descents into RX-only directories."
  • When addressing the still-open scan.test.ts:1133 comment, phrase the fix as "directory opens no longer request FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY" rather than "the new retry wrapper".

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks — the PR description's third bullet and the scan.test.ts header (2304480) are now accurate, but the three code comments in part (a) were missed and still reference the removed read_only field:

  • src/resolver/fs.rs:1433 — still reads "iterable + read_only so the resulting handle has FILE_LIST_DIRECTORY"; drop "+ read_only".
  • src/libarchive/lib.rs:1422 — still reads "is selected by setting read_only"; the mask is now the unconditional base_flags set.
  • src/sys/dir.rs:437-438 — still reads "opened without read_only so the caller may create/rename children — unlike the read-only open_dir_* iteration helpers"; that distinction no longer exists (no directory-open handle requests FILE_ADD_FILE|FILE_ADD_SUBDIRECTORY now, and child creates go by the directory ACL regardless).

Comment accuracy only, zero runtime impact.

@dylan-conway
dylan-conway merged commit 3979cbe into main Jul 20, 2026
62 of 72 checks passed
@dylan-conway
dylan-conway deleted the dylan/win-appcontainer branch July 20, 2026 14:20
robobun added a commit that referenced this pull request Jul 20, 2026
One conflict in src/runtime/socket/Listener.rs: main's #33119 added a
ListenPipeError enum for Windows named-pipe listen errors where this PR
had a uv_errno_out out-param. Took main's enum approach (it uses the
standard jsc::SystemError shape and net.ts formatListenError reshapes
the code/address/port on the JS side regardless).
robobun added a commit that referenced this pull request Jul 20, 2026
My merge 37263b0 took main's #33119 ListenPipeError approach over
this PR's uv_errno_out out-param. Main's path builds jsc::SystemError
with errno = -(SystemErrno as c_int), which is the UV errno on POSIX
but the cross-platform SystemErrno ordinal (-98 for EADDRINUSE) on
Windows, not UV_EADDRINUSE (-4091). RoundRobinHandle extracts
err.errno, passes it through uvTranslateSysError (no-op for n<=0), and
the worker's ExceptionWithHostPort(-98) surfaces
'Unknown system error -98'.

Keep the raw listen_rc.int() alongside the bun_sys::Error in
ListenPipeError::Sys and use that for jsc::SystemError.errno, matching
what the PR's pre-merge out-param carried and what Node reports on
err.errno.

Fixes test-cluster-eaccess.js and cluster.test.ts
'cluster pipe listen error carries no port suffix' on Windows.
fstubner added a commit to fstubner/nvx that referenced this pull request Sep 5, 2026
Measured 2026-09-06 against Bun 1.4.2: contained `bun install` installs,
`bunx` runs, and relative-path reads and writes work. Every one of those
failed on 1.3.1, which is what yesterday's entry was written from.

Bun added AppContainer support in oven-sh/bun#33119, merged 2026-07-20
and shipped from 1.4.0. The mechanism described yesterday was right --
older Bun keeps a working-directory descriptor captured at startup that
an AppContainer will not honour, which is why absolute paths worked and
relative ones did not, and why Node was unaffected. The scope was wrong:
this was never "Bun does not work inside the Windows sandbox at all", it
was "Bun before 1.4.0 does not", and the fix already existed upstream
while I was root-causing it.

Found while checking whether the upstream issue was worth commenting on.
It is closed, and the PR that closed the Windows half is titled "windows:
run Bun inside an AppContainer (lowbox token)" -- which would have been
worth reading before spending an evening on the mechanism.

The CHANGELOG edit that made this correction also deleted forty lines of
the Unreleased section on its first attempt, including today's egress
fix. TestAppVersionMatchesNewestChangelogEntry caught it because the
version headings went with them.
fstubner added a commit to fstubner/nvx that referenced this pull request Sep 14, 2026
Measured 2026-09-06 against Bun 1.4.2: contained `bun install` installs,
`bunx` runs, and relative-path reads and writes work. Every one of those
failed on 1.3.1, which is what yesterday's entry was written from.

Bun added AppContainer support in oven-sh/bun#33119, merged 2026-07-20
and shipped from 1.4.0. The mechanism described yesterday was right --
older Bun keeps a working-directory descriptor captured at startup that
an AppContainer will not honour, which is why absolute paths worked and
relative ones did not, and why Node was unaffected. The scope was wrong:
this was never "Bun does not work inside the Windows sandbox at all", it
was "Bun before 1.4.0 does not", and the fix already existed upstream
while I was root-causing it.

Found while checking whether the upstream issue was worth commenting on.
It is closed, and the PR that closed the Windows half is titled "windows:
run Bun inside an AppContainer (lowbox token)" -- which would have been
worth reading before spending an evening on the mechanism.

The CHANGELOG edit that made this correction also deleted forty lines of
the Unreleased section on its first attempt, including today's egress
fix. TestAppVersionMatchesNewestChangelogEntry caught it because the
version headings went with them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

3 participants