[BUG] Claude Code app bundle lacks NSBluetoothAlwaysUsageDescription — child processes get hard-killed instead of prompted with Routines
Preflight Checklist
- [x] I have searched existing issues and this hasn't been reported yet
- [x] This is a single bug report (please file separate reports for different bugs)
- [x] I am using the latest version of Claude Code
What's Wrong?
When Claude Code (or a process it spawns, e.g. via the Bash tool) triggers a macOS Bluetooth permission check (kTCCServiceBluetoothAlways), the request is refused outright instead of showing the normal user permission dialog - especially in Claude Code Routines!
This happens because Claude Code's app bundle Info.plist does not declare an NSBluetoothAlwaysUsageDescription key. Without this key, macOS's TCC daemon cannot show a permission prompt at all — it silently denies the request. Confirmed via log show:
Refusing authorization request for service kTCCServiceBluetoothAlways and subject
Sub:{com.anthropic.claude-code}Resp:{TCCDProcess: identifier=com.anthropic.claude-code, ...}
without NSBluetoothAlwaysUsageDescription key
For comparison, other apps that do declare this key (e.g. Terminal.app) get a normal prompt (AUTHREQ_PROMPTING → user clicks Allow → Granting), and everything proceeds normally.
Impact: Any child process launched from Claude Code (e.g. a browser automated via Bash/Playwright) that happens to trigger a Bluetooth-related API call — even indirectly, e.g. via a website's device-fingerprinting/bot-detection script — gets hard-killed by macOS (SIGABRT) instead of just being denied Bluetooth access gracefully. The parent Claude Code session survives, but the spawned process crashes outright, which can silently break long-running automation tasks with no clear error message pointing to the real cause (it just looks like a random crash).
Expected behavior: Either (a) Claude Code's Info.plist should declare NSBluetoothAlwaysUsageDescription so macOS can prompt the user normally instead of hard-refusing, or (b) at minimum, child processes should be able to fail gracefully (Bluetooth API call returns "denied") rather than being terminated.
Workaround found: Running the same script via a user LaunchAgent (launchd) instead of through Claude Code avoids the issue entirely, because the "responsible process" TCC attributes the Bluetooth request to is then the script's own interpreter, not Claude Code.
What Should Happen?
Workaround found: Running the same script via a user LaunchAgent (launchd) instead of through Claude Code avoids the issue entirely, because the "responsible process" TCC attributes the Bluetooth request to is then the script's own interpreter, not Claude Code.
Error Messages/Logs
Steps to Reproduce
Workaround found: Running the same script via a user LaunchAgent (launchd) instead of through Claude Code avoids the issue entirely, because the "responsible process" TCC attributes the Bluetooth request to is then the script's own interpreter, not Claude Code.
Claude Model
Opus
Is this a regression?
Yes, this worked in a previous version
Last Working Version
_No response_
Claude Code Version
Claude 1.20186.0 (d7731e)
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
_No response_
4 Comments
Independent confirmation with additional evidence, from driving Bluetooth serial (SPP) lab hardware through Claude Code desktop's Bash tool on macOS.
Symptoms match exactly: any child process spawned from Claude Code that touches the Bluetooth stack (pyserial/IOBluetooth in our case) is hard-killed with SIGABRT (shell sees exit 134) — silently, with no TCC prompt and no entry in System Settings → Privacy & Security → Bluetooth to grant after the fact. Attribution goes to the embedded
com.anthropic.claude-codebundle, which lacksNSBluetoothAlwaysUsageDescription, so tccd refuses without prompting.Additional data points beyond the original report:
com.anthropic.claudefordesktop) does nothing for these child processes — the responsible-process attribution lands on the embeddedcom.anthropic.claude-codebundle, not the outer app. There is no user-visible path to authorize it.NSBluetoothAlwaysUsageDescriptionhosts the Bluetooth-touching daemon; Claude Code talks to it over a local socket. Two gotchas for anyone reproducing:pydantic_core) — the wrapper must exec witharch -arm64.Fix request (same as OP): declare
NSBluetoothAlwaysUsageDescriptionin the embedded bundle's Info.plist so tccd can show the normal prompt.Environment: macOS (Apple Silicon, Darwin 25.x), Claude Code desktop app, processes spawned via the Bash tool.
Thanks for the detailed report and the log evidence. Confirmed / reproduced on 2.1.233 (macOS, regression since 2.1.143 for background sessions).
What I tried: started a background agent session with
claude --bgon 2.1.233 and had it run a small CoreBluetooth program (creates aCBCentralManager). No Bluetooth permission prompt ever appeared, the authorization status stayed "not determined", and nothing showed up under System Settings > Privacy & Security > Bluetooth to grant after the fact. The same program in a normal terminal gets the standard prompt.Root cause matches your analysis: background/desktop-launched Claude Code sessions run as their own macOS "responsible process" (so permission grants stick across upgrades), and that app bundle declares usage descriptions for microphone, Apple Events and local network but not Bluetooth, so macOS silently refuses instead of prompting. Fix is to declare
NSBluetoothAlwaysUsageDescriptionin that bundle, same as was done for local network in 2.1.198.I did not see the SIGABRT crash with CoreBluetooth in my test, only the silent denial. If you have a minimal script (e.g. the pyserial/IOBluetooth call) that crashes, please attach it so we can verify the crash goes away with the fix too.
We'll follow up here when the fix ships. Note the Claude desktop app bundles its own copy, so it may pick up the fix on a separate release cadence.
🤖 Generated with Claude Code
Follow-up from the reporter: minimal crashing script below, plus the crash
report. Short answer to your open question -- macOS terminates it. The crash
report records termination namespace "TCC", so this is enforcement, not Chrome
aborting itself.
What triggers it
----------------
navigator.bluetooth.requestDevice(), which starts a real scan and therefore
needs kTCCServiceBluetoothAlways. Worth noting: getAvailability() is NOT a
trigger -- it only reports whether an adapter exists and never reaches TCC (I
tested it, returns true cleanly, in both an interactive session and a scheduled
one). A CoreBluetooth probe that only checks state may therefore miss the crash.
Minimal reproduction
--------------------
# pip install playwright && playwright install chrome ; Bluetooth switched on
import pathlib, tempfile
from playwright.sync_api import sync_playwright
# file:// because Web Bluetooth needs a secure context (about:blank has an
# opaque origin); the button because requestDevice() needs user activation.
HTML = """<!doctype html><html><body>
<button id="go">scan</button>
<script>
window.__done = null;
document.getElementById('go').addEventListener('click', () => {
navigator.bluetooth.requestDevice({acceptAllDevices: true})
.then(d => { window.__done = 'RESULT:' + d.name; })
.catch(e => { window.__done = 'ERROR:' + e.name + ':' + e.message; });
});
</script></body></html>"""
path = pathlib.Path(tempfile.mkdtemp()) / "probe.html"
path.write_text(HTML)
with sync_playwright() as pw:
browser = pw.chromium.launch(channel="chrome", headless=False)
page = browser.new_page()
page.goto(path.as_uri())
print(f"chrome {browser.version}, clicking ...", flush=True)
try:
page.click("#go", timeout=10_000)
for _ in range(20):
done = page.evaluate("() => window.__done")
if done:
print("OUTCOME:", done, flush=True); break
page.wait_for_timeout(1_000)
else:
print("OUTCOME: still scanning (chooser open)", flush=True)
except Exception as e:
print("BROWSER DIED:", type(e).__name__, e, flush=True)
finally:
browser.close()
From a normal Terminal: prompt, then a normal OUTCOME line.
From a Claude Code Bash call, Chrome dies during the click:
BROWSER DIED: TargetClosedError: Page.click: Target page, context or browser
has been closed
Reproduced twice, 23:01:37 and 23:02:48 local time.
tccd log, three consecutive lines 6 ms apart
---------------------------------------------
23:01:37.819 AUTHREQ_PROMPTING: msgID=43368.22,
service=kTCCServiceBluetoothAlways,
subject=Sub:{com.anthropic.claude-code}Resp:{TCCDProcess:
identifier=com.anthropic.claude-code, pid=42216,
responsible_path=.../Claude/claude-code/2.1.229/claude.app/Contents/MacOS/claude}
23:01:37.824 Refusing authorization request for service
kTCCServiceBluetoothAlways and subject Sub:{com.anthropic.claude-code}...
without NSBluetoothAlwaysUsageDescription key
23:01:37.825 Google Chrome[43368]: This app has crashed because it attempted
to access privacy-sensitive data without a usage description.
Crash report (this is the SIGABRT you asked about)
----------------------------------------------------
~/Library/Logs/DiagnosticReports/Google Chrome-2026-08-17-230249.ips
"exception": {"type": "EXC_CRASH", "signal": "SIGABRT",
"codes": "0x0000000000000000, 0x0000000000000000"}
"termination": {"namespace": "TCC", "code": 0, "flags": 518,
"details": ["This app has crashed because it attempted to access
privacy-sensitive data without a usage description. The app's Info.plist
must contain an NSBluetoothAlwaysUsageDescription key with a string value
explaining to the user how the app uses this data."]}
namespace "TCC" means the kill comes from the TCC enforcement path against the
responsible process, so declaring the key should resolve the crash and the
silent denial in one go. I can attach the full .ips if useful.
Two things that may widen your repro scope
-------------------------------------------
Bash tool calls in the Claude Desktop app, not
claude --bg. I also ran thegetAvailability() variant as a scheduled task and it behaved identically to
the interactive one, so the session type does not seem to be the variable --
the API call is.
My CLI is on 2.1.233, but the responsible process above is 2.1.229 from
~/Library/Application Support/Claude/claude-code/. One log line showed the
accessing process as ~/.local/share/claude/versions/2.1.233 while the
responsible one was the 2.1.229 bundle -- a CLI-only fix will not reach
Desktop users.
$ /usr/libexec/PlistBuddy -c 'Print :NSBluetoothAlwaysUsageDescription' \
~/Library/Application\ Support/Claude/claude-code/2.1.229/claude.app/Contents/Info.plist
Print: Entry, ":NSBluetoothAlwaysUsageDescription", Does Not Exist
# NSAppleEvents / NSLocalNetwork / NSMicrophone usage descriptions: all present
Environment: macOS 15.7.9 (24G830), iMac19,x (Intel Core i5-8600),
Chrome 151.0.7922.138, Claude Desktop with embedded claude-code 2.1.229,
CLI 2.1.233.
Follow-up from the reporter, answering the open question above: here is a minimal
program that reproduces the hard kill. No pip install, no browser — just Swift,
which ships with the Xcode Command Line Tools.
Correction to my previous comment. I assumed a CoreBluetooth probe that only
checks state would not reach TCC. That is wrong. Merely constructing a
CBCentralManageris enough: the manager issues a state query that hitskTCCServiceBluetoothAlways, and the process is terminated. So the crash is mucheasier to reproduce than the
navigator.bluetooth.requestDevice()path I postedearlier.
Still present in 2.1.246 (embedded bundle, signature timestamp 2026-08-25).
Usage descriptions in that bundle:
NSAppleEventsUsageDescription,NSLocalNetworkUsageDescription,NSMicrophoneUsageDescription.NSBluetoothAlwaysUsageDescriptionis still absent, and the entitlements listcom.apple.security.device.audio-inputbut nocom.apple.security.device.bluetooth.Minimal reproduction
bt_test.swift:Run it from a Bash tool call inside a Claude Code session on macOS, Bluetooth
switched on. It never prints anything:
swiftc -O bt_test.swift -o bt_test && ./bt_test→
Abort trap: 6, shell exit 134. That is the SIGABRT from the original report.swift bt_test.swift→ the kill lands on
swift-frontend, shell exit 137, and the stack trace namesthe killer explicitly:
So it is macOS enforcement, not the program aborting itself.
Log at the moment of the crash
Environment