Repository navigation
fix(process): support running Bun inside macOS App Sandbox #27041
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We鈥檒l occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
1af1095
8a61d6d
e74a65e
f8efc63
17f5be8
20e26a7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,122 @@ | ||
| import { describe, expect, test } from "bun:test"; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 馃煛 Minor: Nit: This PR closes #15661, so per CLAUDE.md convention this test should be placed at Why this is a problemConvention Violation: Test File PlacementThe test file Based on these guidelines, because the PR is tied to issue #15661, the test file should be located at CounterargumentOne reviewer disagreed, arguing this is not purely a regression test for a single bug but rather a feature test for macOS App Sandbox support. The test file itself notes on line 75 that it is "Modeled after Node.js's test/parallel/test-macos-app-sandbox.js", and the test covers general sandbox functionality (executing JavaScript inside a sandbox, verifying the sandbox container path) rather than reproducing a narrow regression scenario. The This is a reasonable interpretation. The test does read more like a feature test than a minimal reproduction of a single bug. However, the CLAUDE.md rule about issue-linked tests is fairly explicit, and the commit message directly ties this work to issue #15661. RecommendationSince this is a nit-level convention issue, the simplest fix would be to move the file to
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Test comment |
||
| import { copyFileSync } from "fs"; | ||
| import { bunEnv, bunExe, isMacOS, tempDir } from "harness"; | ||
| import { join } from "path"; | ||
|
|
||
| // Match Bun's own entitlements from entitlements.plist, plus app-sandbox. | ||
| const entitlementsPlist = `<?xml version="1.0" encoding="UTF-8"?> | ||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| <plist version="1.0"> | ||
| <dict> | ||
| <key>com.apple.security.app-sandbox</key> | ||
| <true/> | ||
| <key>com.apple.security.cs.allow-jit</key> | ||
| <true/> | ||
| <key>com.apple.security.cs.allow-unsigned-executable-memory</key> | ||
| <true/> | ||
| <key>com.apple.security.cs.disable-executable-page-protection</key> | ||
| <true/> | ||
| <key>com.apple.security.cs.allow-dyld-environment-variables</key> | ||
| <true/> | ||
| <key>com.apple.security.cs.disable-library-validation</key> | ||
| <true/> | ||
| <key>com.apple.security.network.client</key> | ||
| <true/> | ||
| </dict> | ||
| </plist>`; | ||
|
|
||
| function makeInfoPlist(bundleId: string) { | ||
| return `<?xml version="1.0" encoding="UTF-8"?> | ||
| <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> | ||
| <plist version="1.0"> | ||
| <dict> | ||
| <key>CFBundleExecutable</key> | ||
| <string>bun</string> | ||
| <key>CFBundleIdentifier</key> | ||
| <string>${bundleId}</string> | ||
| <key>CFBundleInfoDictionaryVersion</key> | ||
| <string>6.0</string> | ||
| <key>CFBundleName</key> | ||
| <string>bun_sandboxed</string> | ||
| <key>CFBundlePackageType</key> | ||
| <string>APPL</string> | ||
| <key>CFBundleShortVersionString</key> | ||
| <string>1.0</string> | ||
| <key>CFBundleSupportedPlatforms</key> | ||
| <array> | ||
| <string>MacOSX</string> | ||
| </array> | ||
| <key>CFBundleVersion</key> | ||
| <string>1</string> | ||
| </dict> | ||
| </plist>`; | ||
| } | ||
|
|
||
| function createSandboxedApp(prefix: string, bundleId: string) { | ||
| const dir = tempDir(prefix, { | ||
| "entitlements.plist": entitlementsPlist, | ||
| "bun_sandboxed.app": { | ||
| "Contents": { | ||
| "Info.plist": makeInfoPlist(bundleId), | ||
| "MacOS": {}, | ||
| }, | ||
| }, | ||
| }); | ||
|
|
||
| const bunPath = join(String(dir), "bun_sandboxed.app", "Contents", "MacOS", "bun"); | ||
| const appBundlePath = join(String(dir), "bun_sandboxed.app"); | ||
| const entitlementsPath = join(String(dir), "entitlements.plist"); | ||
|
|
||
| copyFileSync(bunExe(), bunPath); | ||
|
|
||
| const codesignResult = Bun.spawnSync({ | ||
| cmd: ["/usr/bin/codesign", "--entitlements", entitlementsPath, "--force", "-s", "-", appBundlePath], | ||
| env: bunEnv, | ||
| stderr: "inherit", | ||
| }); | ||
| expect(codesignResult.exitCode).toBe(0); | ||
|
|
||
| return { dir, bunPath, bundleId }; | ||
| } | ||
|
|
||
| // Modeled after Node.js's test/parallel/test-macos-app-sandbox.js | ||
| describe.skipIf(!isMacOS)("macOS App Sandbox", () => { | ||
| test("bun can execute JavaScript inside the app sandbox", async () => { | ||
| const { dir, bunPath } = createSandboxedApp("macos-sandbox-test", "dev.bun.test.sandbox_exec"); | ||
| using _dir = dir; | ||
|
|
||
| await using proc = Bun.spawn({ | ||
| cmd: [bunPath, "-e", "console.log('hello sandbox')"], | ||
| env: bunEnv, | ||
| stdout: "pipe", | ||
| stderr: "inherit", | ||
| }); | ||
|
|
||
| const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); | ||
|
|
||
| expect(stdout.trim()).toBe("hello sandbox"); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
|
|
||
| test("sandboxed bun runs inside the sandbox container", async () => { | ||
| const { dir, bunPath, bundleId } = createSandboxedApp( | ||
| "macos-sandbox-test-container", | ||
| "dev.bun.test.sandbox_container", | ||
| ); | ||
| using _dir = dir; | ||
|
|
||
| // When running inside a macOS App Sandbox, os.homedir() should return | ||
| // the sandbox container path, not the real home directory. | ||
| await using proc = Bun.spawn({ | ||
| cmd: [bunPath, "-e", "console.log(require('os').homedir())"], | ||
| env: bunEnv, | ||
| stdout: "pipe", | ||
| stderr: "inherit", | ||
| }); | ||
|
|
||
| const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); | ||
|
|
||
| expect(stdout.trim()).toContain(`Library/Containers/${bundleId}`); | ||
| expect(exitCode).toBe(0); | ||
| }); | ||
| }); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
馃煛 Minor: When
open("/dev/null")fails,setDevNullFdreturns early without resettingbun_is_stdio_null[target_fd]back to 0, leaving stale state. Thedup2failure path at line 494 correctly resets the flag, but this early-return path does not. While both code paths currently lead to errors downstream, the inconsistency could mislead future code that checksisStdoutNull/isStderrNull/isStdinNull.Why this is a problem
Bug Description
The
setDevNullFdlambda inc-bindings.cpp(line 468) unconditionally setsbun_is_stdio_null[target_fd] = 1at line 469 before attempting to open/dev/null. If theopen("/dev/null")call fails (line 476 check,devNullFd_ < 0), the function returns early at line 479 without resetting the flag back to 0. This leavesbun_is_stdio_null[target_fd]in a semantically incorrect state: it claims the fd was redirected to/dev/nullwhen in fact the fd is still invalid (EBADF).Code Path Analysis
The specific trigger is the macOS App Sandbox scenario that this PR was designed to address. When
isatty(fd)returns 0 anderrnoisEBADFfor any of the stdio fds (0, 1, 2),setDevNullFd(fd)is called. Inside the lambda,bun_is_stdio_null[target_fd]is set to 1 optimistically. The code then attemptsopen("/dev/null", O_RDWR | O_CLOEXEC, 0). In an App Sandbox, this open can fail, causing the early return at line 479.Notably, the
dup2failure path at line 493-495 does properly resetbun_is_stdio_null[target_fd] = 0, creating an asymmetry between the two error paths within the same function. This asymmetry strongly suggests the missing reset on theopenfailure path was an oversight rather than an intentional design choice.Step-by-Step Proof
isatty(1)returns 0 anderrno == EBADF.setDevNullFd(1)is called.bun_is_stdio_null[1] = 1is set unconditionally.devNullFd_is -1, the code attemptsopen("/dev/null", O_RDWR | O_CLOEXEC, 0).openfails because the App Sandbox restricts filesystem access.devNullFd_remains negative.devNullFd_ < 0check is true, so the function returns early.bun_is_stdio_null[1]remains 1, falsely indicating stdout was redirected to/dev/null.Practical Impact
In the current codebase, both code paths (flag=1 and flag=0) lead to errors in the downstream shell interpreter consumers. When the flag is 1,
bun.sys.openNullDevice()is called, which itself callsopen("/dev/null")and also fails in the sandbox. When the flag is 0,ShellSyscall.dup(fd)is called on an EBADF fd, which also fails. Both errors are handled by the same.errbranch.However, the flag's name (
bun_is_stdio_null) and its consumers (isStdoutNull,isStderrNull,isStdinNull) describe the current state of the fd, not the intended state. Setting it to 1 when the redirect did not actually occur misrepresents reality and could mislead future code.Recommended Fix
Add
bun_is_stdio_null[target_fd] = 0;before thereturn;on line 479, mirroring the existing reset on thedup2failure path at line 494: