Skip to content

Run the Linux sandbox's command as the user, and say when a browser download is deleted - #187

Merged
fstubner merged 2 commits into
mainfrom
fix/linux-uid-and-browser-downloads
Oct 7, 2026
Merged

fstubner merged 2 commits into
mainfrom
fix/linux-uid-and-browser-downloads

Conversation

@fstubner

@fstubner fstubner commented Oct 7, 2026 •

Copy link
Copy Markdown
Owner

Two Linux and cross-platform sandbox fixes.

A contained install can unpack an archive another user owns (Linux)

The contained process ran as root in a user namespace that maps only the user's own id. Tools that unpack an archive as root (node-tar, tar-fs, GNU tar) give each file the owner the archive records, and any owner the namespace does not hold is EINVAL. Measured on Linux 6.18 in a Debian 13 container, a contained npm install sqlite3@6.0.1 stopped at prebuild-install warn install EINVAL: invalid argument, lchown. The archive of its prebuilt binary is owned by uid 1001.

Two ways out were weighed.

  • Stop the tools restoring owners. npm already passes preserveOwner: false for its own extraction. tar-fs, which prebuild-install uses, and node-tar, which node-gyp uses for headers, decide from process.getuid() === 0 and read no variable or config for it. Only GNU tar has one (TAR_OPTIONS=--no-same-owner). A preload that makes getuid() lie would reach Node alone.
  • Run the command as the user. The supervisor's namespace maps the user to itself, so getuid() is the user's own id and no tool tries. This is the change made here.

What the identity map changes:

  • The supervisor's own operations. A process that is not root keeps no capability across exec, so the supervisor carries every capability the kernel knows across its own exec as ambient capabilities (SysProcAttr.AmbientCaps). Its mounts, the .env watcher's setns, the max_user_namespaces write and the bounding-set drops work as before, and ip and iptables get the capabilities as ambient ones too.
  • The capability drops. On the thread that forks the command, the supervisor clears its inheritable set, which clears the ambient set with it, and empties the bounding set. Without that the command inherited every ambient capability, all 41, the four the old drops removed included. The command now holds none (CapInh, CapPrm, CapEff, CapBnd and CapAmb all zero), where it held all but four. The existing per-feature drops stay as a second layer.
  • Running nvx as root. The map is the identity, so root is still root inside, and an archive owned by another user still fails there. The emptied bounding set means it holds no capabilities either. The limitations page says so.
  • File ownership. Unchanged. Files are created with the same host uid as before, and inside the sandbox the user's files now show the user's id where they showed 0.
  • Things that relied on root inside. Listening on a port below 1024 in the sandbox's own network namespace did. The supervisor now sets net.ipv4.ip_unprivileged_port_start to 0 in that namespace, which is what Docker sets for its containers. Nothing else in the supervisor or the tests depended on uid 0.

After the change the same install exits 0 and a contained node loads the module.

A browser a contained install downloads is deleted without a word (all platforms)

puppeteer's postinstall and playwright install keep their browsers under the home directory, which inside the sandbox is the guest home, deleted when the command ends. With storage.googleapis.com allowed, the puppeteer cache in the guest home reached 856 MB during a contained npm install puppeteer. npm exited 0, nvx said nothing, and no browser was left.

Before it deletes an ephemeral guest home, nvx now checks the folders puppeteer and Playwright use. When one holds 1 MiB or more it prints:

⚠ puppeteer downloaded a browser into the sandbox's home, which nvx deletes when the command ends.
ℹ To install it where puppeteer looks for it, run: nvx --no-sandbox npx puppeteer browsers install chrome

A blocked download leaves only small records (103 bytes for Playwright) and prints nothing. On Windows, Playwright's folder is under the AppContainer package's own LOCALAPPDATA, which a contained node reported as AppData\Local\Packages\nvx.sandbox.<id>\AC. PUPPETEER_CACHE_DIR and PLAYWRIGHT_BROWSERS_PATH join the variables nvx names when it removes them.

A cache variable pointing into the project was not chosen. The browser would land in the project, and puppeteer's own code, which runs uncontained, would still look in the home. Documentation alone would stay silent at the moment the browser is lost.

Verification

  • gofmt -l internal cmd prints nothing. go vet ./... passes on Windows, Linux and macOS.
  • Windows: go test ./internal/nvx -count=1 passes (133.6 s). Run from a nested .claude/worktrees checkout, six tests fail on both this branch and origin/main, and pass from any other path. That is the location, not this change.
  • Linux, privileged Debian 13 container, kernel 6.18: the CI privileged set (-run 'TestNetns|TestReap|TestSupervisorClone|TestContainedProcess|TestNvxTermination|TestGitMetadata') passes as root, the way CI runs it, with 27 passed and 0 skipped. As an ordinary user 26 pass, and the one that lowers a host sysctl skips. The full unit suite passes as an ordinary user. sandbox-smoke.sh, sandbox-smoke-egress.sh and sandbox-enforcement-linux.sh pass as an ordinary user, and nvx doctor reports that the sandbox starts.
  • The new TestContainedProcessRunsAsTheUserWithNoCapabilities fails on origin/main on its uid and capability sets, both as a user and as root. It fails again with the capability drop removed, and on low_port with the port floor removed.
  • TestBrowserDownloadInTheGuestHomeIsNamed fails with the warning removed, and with the size floor set to 1 byte. The env notice test fails on origin/main for the two new names.
  • By hand in the container: sqlite3 as above. network.mode: loopback still reaches a host service over raw TCP. A contained server on port 80 is reachable through --expose 80:18080. Contained bun install and pnpm 10 installs work. Real puppeteer 25.12.0 and Playwright 1.64.0 downloads print the warning, and following the puppeteer line installed Chrome where puppeteer's own code found it.
  • Windows by hand: a contained node writing a browser-sized file into each folder prints the warning, and a 64-byte record does not.
  • The site builds and npm run check:content passes.

The contained process ran as root in a user namespace that maps only the
user's own id. A tool that unpacks an archive as root gives each file the
owner the archive records, and any owner the namespace does not hold is
EINVAL. Measured on Linux 6.18 in a Debian 13 container, a contained
`npm install sqlite3@6.0.1` stopped at "prebuild-install warn install EINVAL:
invalid argument, lchown", because the archive of its prebuilt binary is
owned by uid 1001, and the fallback build from source failed as well.

The supervisor's user namespace now maps the user to itself, so the command
runs with the user's own uid and gid. A process that is not root keeps no
capability across exec, so the supervisor carries its own across its exec as
ambient capabilities. On the thread that forks the command it then clears its
inheritable set, which clears the ambient set with it, and empties the
bounding set. The command now holds no capability at all, where it held every
one but the four the supervisor dropped. A user who runs nvx as root still
runs the command as root, and it holds none there either. The supervisor sets
the namespace's unprivileged port floor to 0, so a contained server can still
listen below 1024, as it could as root.

The same install now exits 0 and a contained node loads the module. The new
test fails on the old code on its uid and capability sets, both as an
ordinary user and as root. The privileged CI set passes as root, and as an
ordinary user apart from the test that needs root to lower the inotify limit.
network.mode loopback, --expose to port 80 inside, and bun and pnpm installs
were also run contained in the same container.
… sandbox

puppeteer's postinstall and `playwright install` keep their browsers under
the home directory. Inside the sandbox that is the guest home, which nvx
deletes when the command ends, so the browser went with it and the install
still exited 0. Measured in a Debian 13 container with storage.googleapis.com
allowed, the puppeteer cache in the guest home reached 856 MB during a
contained `npm install puppeteer`, nvx said nothing, and no browser was left.

Before it deletes an ephemeral guest home, nvx now looks in the folders
puppeteer and Playwright use. When one holds 1 MiB or more it names the tool
and prints the command that installs the browser where that tool looks for
it, such as `nvx --no-sandbox npx puppeteer browsers install chrome`. A
blocked download leaves only small records there, 103 bytes for Playwright,
and says nothing. On Windows Playwright's folder is under the AppContainer
package's own LOCALAPPDATA, which a contained node reported as
AppData\Local\Packages\nvx.sandbox.<id>\AC under the guest home.

PUPPETEER_CACHE_DIR and PLAYWRIGHT_BROWSERS_PATH join the variables nvx names
when it removes them from a contained command's environment.

Checked with real downloads on Linux, puppeteer 25.12.0 and Playwright
1.64.0, and by writing a browser-sized file into the guest home on Linux and
on Windows. Following the printed puppeteer command installed Chrome where
puppeteer's own code then found it.
@fstubner
fstubner merged commit 51ee8f0 into main Oct 7, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant