microsoft / microsoft/vscode-js-debug
macOS: default-browser detection fails silently without Automation permission, so an Edge user is debugged in Chrome
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 2k
- Forks
- 373
- Avg merge
- 1d 9m
- Merged PRs (30d)
- 6
Description
Summary
On macOS, DefaultBrowserProvider.lookup() resolves the default browser through an AppleScript round-trip that requires the "control System Events" Automation permission. When that permission is unavailable the lookup rejects, the rejection is swallowed by a bare catch {}, and the debug type silently falls back to Chrome.
The user-visible effect: a user whose default browser is Edge gets Chrome, with nothing in the UI to indicate why. This was reported in passing in #427 back in 2020 ("My default browser is Edge and Chrome was still opened.. but that may be because I denied the permissions") and never followed up. It came up again while looking at #2378, which is a separate, working-as-designed report.
To Reproduce
I have not reproduced this end-to-end in the product (see the caveat below), so these are the steps that should produce it, followed by the part I did measure directly.
Expected repro:
- On macOS, set the default browser to Microsoft Edge.
- Ensure VS Code does not have Automation permission for System Events (System Settings > Privacy & Security > Automation), either never granted or revoked.
- Open the JavaScript Debug Terminal and click an
http(s)link in it. - Expected: Edge launches with the debugger attached. Actual (predicted): Chrome launches instead, with no indication why.
What I did run, and its exact output, is in the next section.
VS Code Version: not applicable. I did not reproduce this inside the extension host, so I have no product version to report and would rather leave this blank than put a number here that I did not test against. What I exercised is the dependency chain the extension uses, at the versions this repo's package-lock.json pins.
Log File: none, and a trace log would not help here. The rejection is caught and discarded without being logged, so "trace": true produces no record of this failing — that is part of what makes it hard to attribute.
The chain, and where it breaks
lookup() calls default-browser for the local (non-remote) case:
On darwin, default-browser does two things in sequence:
default-browser-id— a plaindefaults read com.apple.LaunchServices/com.apple.launchservices.secure LSHandlers. No permission needed, returns the bundle id.bundle-name— shells out to AppleScript to turn that id into aCFBundleName:
osascript -e tell application "Finder" to set app_path to application file id "<bundle-id>" as string
tell application "System Events" to get value of property list item "CFBundleName" of property list file (app_path & ":Contents:Info.plist")
Step 1 succeeds. Step 2 is the one that needs Automation permission, and without it the whole call rejects.
Measured with the exact versions in this repo's package-lock.json (default-browser@5.2.1, default-browser-id@5.0.0, bundle-name@4.1.0) on macOS 26.6.2 (build 25G83, Apple Silicon), Node 26.8.2, with Firefox as the LaunchServices handler for http/https:
$ node -e "import('default-browser-id').then(m=>m.default()).then(id=>console.log(' ->', id))"
-> org.mozilla.firefox
$ node -e "import('default-browser').then(m=>m.default()).then(console.log).catch(e=>process.stdout.write(e.stderr))"
137:142: execution error: Keine Berechtigung zum Senden von Apple-Events an System Events. (-1743)
(That message is localized to the system UI language — this machine is de-DE. It is errAEEventNotPermitted / -1743, "Not authorized to send Apple events to System Events". The message stays in the system language even with LANG=en_US.UTF-8 set, since osascript follows the system UI language rather than the shell locale.)
So the permission-free half of the chain already produced a correct, unambiguous answer, and the call still fails on the half that only exists to convert that id into a display string.
Why it is silent
Both call sites wrap the lookup in a bare catch {} and leave debugType at its Chrome default:
The same shape is in the Debug: Open Link command:
Nothing is logged and nothing is surfaced, so from the user's side the only observable is "it opened the wrong browser".
Why it is sticky
lookup is wrapped in once():
once() memoises the returned value. Here that value is a promise, and a rejected promise is memoised exactly like a resolved one — there is no rejection-aware reset. So a single failure (or a single "Don't Allow" on the Automation prompt) holds for the entire extension-host session; the only recovery is reloading the window, and nothing tells the user that is what is needed.
Caveat: what I did not observe
I measured the dependency chain in a standalone Node process, not inside the VS Code extension host. VS Code.app carries its own TCC entry for Automation, so a user who has already granted VS Code permission to control System Events will not hit this at all. I am reporting that this path can fail, that the failure is silent, and that it is then cached for the session — not that it is failing for any particular user today. Someone with an Edge default and a fresh/denied Automation permission would be the direct confirmation, and I do not have an Edge install here to close that loop.
The location === 'remote' branch routes through js-debug-companion.defaultBrowser instead and is not affected by this.
Possible direction
Offered as a suggestion, not a proposal — the maintainers know the constraints here better than I do.
defaultBrowser() already returns {name, id}, and the id comes from the permission-free half of the chain. Since lookup() only needs to distinguish two specific browsers, the bundle id identifies them exactly (com.google.Chrome, and Edge's equivalent), whereas the current substring match runs against a localized CFBundleName. Matching on id in addition to name would make detection work whether or not AppleScript is permitted, and would drop a dependency on a display string that can vary by locale.
A rejection-aware memo for lookup (so a denial is retried on the next window rather than cached for the session) may be worth considering separately.
Context
- #427 — where the Edge-gets-Chrome symptom was first mentioned, and where the intended "Edge if it's your default, otherwise Chrome" rule was stated.
- #2378 — the report this was found alongside. That one is working as designed; this is the part of it that is not.
File and line references are against main @ 41bd9bb831a46c2668cab14d8455e8e3bff58477.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with src/common/defaultBrowserProvider.ts and trace its callers in src/ui/terminalLinkHandler.ts and src/ui/debugLinkUI.ts. Read the once() implementation in src/common/objUtils.ts and inspect the pinned default-browser dependencies in package-lock.json. Done should mean an unavailable Automation permission no longer silently selects Chrome for a supported default browser, and the failure behavior is covered by the relevant existing checks.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- macos, node.js, typescript, vscode
- Domain
- desktop, developer-experience, devtools
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100