Skip to content

Commit 25170bc

Browse files
dorlugasigalCopilot
andcommitted
fix(tunnel): auto-strip macOS quarantine xattr to prevent Gatekeeper stalls
Brew --cask installs (and every subsequent upgrade) tag the devtunnel binary with com.apple.quarantine. When TermBeam runs as a long-lived service (e.g. under pm2), the next spawn after a brew upgrade is held by Gatekeeper until the user clicks Allow on a system dialog on the host machine. While held, the auth probe fails with no output and the watchdog misclassifies it as auth expiration, logging a misleading 'DevTunnel auth restored' message once the dialog is dismissed. This patch: - Adds stripQuarantine, hasQuarantine, and resolveBinaryPath helpers in src/tunnel/install.js. resolveBinaryPath handles the bare-name PATH lookup case (findDevtunnel returns 'devtunnel' when on PATH), so fs.realpathSync no longer throws on the actual fix path. - stripQuarantine returns 'noop' / 'stripped' / 'failed' so callers can distinguish 'nothing to do' from 'permission refused'. - Calls stripQuarantine after every successful install and once at every TermBeam startup before spawning devtunnel. - isLoggedIn() self-heals: if the spawn fails AND the binary actually carries com.apple.quarantine, strip it and retry once. A 'failed' outcome surfaces a one-time error with the manual remediation command. Warnings are throttled to once per session to avoid log spam. - Adds a troubleshooting section to the site explaining the issue and the auto-recovery behavior. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7ec9f19 commit 25170bc

4 files changed

Lines changed: 316 additions & 9 deletions

File tree

packages/site/src/content/docs/troubleshooting.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,27 @@ If TermBeam is running as a long-lived service (`termbeam service`) and the tunn
8686

8787
You'll see `[WARN] Tunnel paused — waiting for network connectivity` in the logs followed by `[INFO] Network connectivity restored — resuming tunnel` when it recovers. If the logs instead show repeated `Tunnel restart returned no URL` with no final giveup, the watchdog is still cycling through its 10 restart attempts; wait a few minutes for it to settle into network-wait.
8888

89+
### macOS: tunnel paused after a brew upgrade ("DevTunnel auth restored" loop)
90+
91+
On macOS, `brew install --cask devtunnel` (and every subsequent upgrade) tags the binary with the `com.apple.quarantine` extended attribute. The first time TermBeam spawns the new binary from a non-interactive context (e.g. a `pm2`-managed service), Gatekeeper holds the spawn and pops a system dialog — until you click **Open** on the host machine, the auth probe reads as a failure and the watchdog logs:
92+
93+
```text
94+
[INFO] DevTunnel auth restored — resuming tunnel
95+
```
96+
97+
…right after you dismiss the dialog (the misleading message is because the spawn failure looks like an auth failure to the watchdog).
98+
99+
TermBeam **automatically strips the quarantine attribute** on every startup and re-strips it as a self-heal step inside the auth probe, so this should not recur. If you still see the dialog, run:
100+
101+
```bash
102+
xattr -dr com.apple.quarantine "$(brew --prefix)/Caskroom/devtunnel"
103+
spctl --assess --verbose "$(readlink -f "$(which devtunnel)")"
104+
```
105+
106+
:::tip
107+
This is harmless — the binary is still signed by Microsoft (`TeamIdentifier=UBF8T346G9`). Stripping `com.apple.quarantine` only removes the "downloaded from the internet" tag that triggers the Gatekeeper dialog.
108+
:::
109+
89110
---
90111

91112
## Authentication Issues

src/tunnel/index.js

Lines changed: 47 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const os = require('os');
55
const dns = require('dns');
66
const EventEmitter = require('events');
77
const log = require('../utils/logger');
8-
const { promptInstall } = require('./install');
8+
const { promptInstall, stripQuarantine, resolveBinaryPath, hasQuarantine } = require('./install');
99

1010
const TUNNEL_CONFIG_DIR = path.join(os.homedir(), '.termbeam');
1111
const TUNNEL_CONFIG_PATH = path.join(TUNNEL_CONFIG_DIR, 'tunnel.json');
@@ -100,16 +100,53 @@ function isNetworkReachable() {
100100
});
101101
}
102102

103+
// One-time per-session flag so the "stripped quarantine" warning isn't
104+
// spammed if the auth-poll loop trips it repeatedly during a brew upgrade.
105+
let quarantineWarned = false;
106+
103107
function isLoggedIn() {
104-
try {
105-
const out = execFileSync(devtunnelCmd, ['user', 'show'], {
108+
const tryShow = () =>
109+
execFileSync(devtunnelCmd, ['user', 'show'], {
106110
encoding: 'utf-8',
107111
stdio: ['pipe', 'pipe', 'pipe'],
108112
timeout: 10_000,
109113
windowsHide: true,
110114
});
115+
try {
116+
const out = tryShow();
111117
return out && !out.toLowerCase().includes('not logged in');
112-
} catch {
118+
} catch (err) {
119+
// On macOS, brew upgrades silently re-tag the devtunnel binary with
120+
// com.apple.quarantine, which causes Gatekeeper to block our spawn and
121+
// makes this throw with no useful output. If we can prove the binary is
122+
// actually quarantined, strip and retry once before reporting failure.
123+
if (process.platform === 'darwin' && err && err.code !== 'ENOENT') {
124+
const resolved = resolveBinaryPath(devtunnelCmd);
125+
if (resolved && hasQuarantine(resolved)) {
126+
const result = stripQuarantine(devtunnelCmd);
127+
if (result === 'stripped') {
128+
if (!quarantineWarned) {
129+
log.warn(
130+
'devtunnel was quarantined by macOS Gatekeeper (likely after a brew upgrade); ' +
131+
'stripped com.apple.quarantine and retrying',
132+
);
133+
quarantineWarned = true;
134+
}
135+
try {
136+
const out = tryShow();
137+
return out && !out.toLowerCase().includes('not logged in');
138+
} catch {
139+
// fall through and return false
140+
}
141+
} else if (result === 'failed' && !quarantineWarned) {
142+
log.error(
143+
'devtunnel is quarantined by macOS Gatekeeper but quarantine removal failed. ' +
144+
`Run manually: xattr -dr com.apple.quarantine "${resolved}"`,
145+
);
146+
quarantineWarned = true;
147+
}
148+
}
149+
}
113150
return false;
114151
}
115152
}
@@ -638,6 +675,12 @@ async function startTunnel(port, options = {}) {
638675
}
639676
devtunnelCmd = found;
640677

678+
// On macOS, brew --cask installs (and upgrades) tag the binary with
679+
// com.apple.quarantine. The first time we spawn devtunnel from a
680+
// non-interactive context Gatekeeper would block it and prompt the user.
681+
// Strip the attribute on every startup so brew upgrades don't break us.
682+
stripQuarantine(devtunnelCmd);
683+
641684
log.info('Starting devtunnel...');
642685
try {
643686
// Ensure user is logged in. Prefer Entra over GitHub — Entra tokens auto-refresh

src/tunnel/install.js

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,95 @@ function getInstallDir() {
1111
return INSTALL_DIR;
1212
}
1313

14+
/**
15+
* Resolve a binary name or path to an absolute path. Bare names (e.g. just
16+
* "devtunnel") are looked up on `$PATH`. Returns the realpath (symlinks
17+
* resolved) on success, or `null` if the binary couldn't be found.
18+
*/
19+
function resolveBinaryPath(binPathOrName) {
20+
if (!binPathOrName) return null;
21+
if (path.isAbsolute(binPathOrName)) {
22+
try {
23+
return fs.realpathSync(binPathOrName);
24+
} catch {
25+
return null;
26+
}
27+
}
28+
const PATH = process.env.PATH || '';
29+
const sep = process.platform === 'win32' ? ';' : ':';
30+
const exts =
31+
process.platform === 'win32'
32+
? (process.env.PATHEXT || '.EXE').split(';').map((e) => e.toLowerCase())
33+
: [''];
34+
for (const dir of PATH.split(sep)) {
35+
if (!dir) continue;
36+
for (const ext of exts) {
37+
const candidate = path.join(dir, binPathOrName + ext);
38+
try {
39+
const stat = fs.statSync(candidate);
40+
if (stat.isFile()) {
41+
try {
42+
return fs.realpathSync(candidate);
43+
} catch {
44+
return candidate;
45+
}
46+
}
47+
} catch {
48+
// not in this dir
49+
}
50+
}
51+
}
52+
return null;
53+
}
54+
55+
/**
56+
* Returns true when the file at `absPath` carries the macOS
57+
* `com.apple.quarantine` extended attribute. Always false on non-darwin.
58+
*/
59+
function hasQuarantine(absPath) {
60+
if (process.platform !== 'darwin' || !absPath) return false;
61+
try {
62+
execFileSync('xattr', ['-p', 'com.apple.quarantine', absPath], {
63+
stdio: 'pipe',
64+
timeout: 3000,
65+
});
66+
return true;
67+
} catch {
68+
return false;
69+
}
70+
}
71+
72+
/**
73+
* Strip the macOS `com.apple.quarantine` extended attribute from a binary.
74+
* Brew casks tag downloaded binaries with this attribute, which causes
75+
* Gatekeeper to block execution and pop a system dialog ("are you sure you
76+
* want to open…") the first time the binary runs in a non-interactive
77+
* context (e.g. spawned from a service). On the next brew upgrade the new
78+
* binary is re-tagged, so this needs to run after every install and as a
79+
* best-effort self-heal step at runtime.
80+
*
81+
* Accepts an absolute path OR a bare command name (which is resolved via
82+
* `$PATH`). Returns one of:
83+
* - 'noop' — non-darwin, unresolvable, or no quarantine attribute
84+
* - 'stripped' — quarantine was present and successfully removed
85+
* - 'failed' — quarantine was present and removal failed (e.g. EPERM)
86+
*/
87+
function stripQuarantine(binPathOrName) {
88+
if (process.platform !== 'darwin' || !binPathOrName) return 'noop';
89+
const resolved = resolveBinaryPath(binPathOrName);
90+
if (!resolved) return 'noop';
91+
if (!hasQuarantine(resolved)) return 'noop';
92+
try {
93+
execFileSync('xattr', ['-d', 'com.apple.quarantine', resolved], {
94+
stdio: 'pipe',
95+
timeout: 5000,
96+
});
97+
} catch {
98+
// Removal refused (e.g. EPERM). Verify outcome below.
99+
}
100+
return hasQuarantine(resolved) ? 'failed' : 'stripped';
101+
}
102+
14103
function getBinaryName() {
15104
return process.platform === 'win32' ? 'devtunnel.exe' : 'devtunnel';
16105
}
@@ -83,6 +172,10 @@ async function installDevtunnel() {
83172
// Find the installed binary
84173
const found = findInstalledBinary();
85174
if (found) {
175+
// On macOS, brew --cask tags the binary with com.apple.quarantine which
176+
// causes Gatekeeper to block execution from non-interactive contexts.
177+
// Strip it now so the first spawn doesn't trigger a system dialog.
178+
stripQuarantine(found);
86179
log.info(`${green('✔')} DevTunnel CLI installed and verified successfully.`);
87180
return found;
88181
}
@@ -139,4 +232,11 @@ function findInstalledBinary() {
139232
return null;
140233
}
141234

142-
module.exports = { installDevtunnel, promptInstall, getInstallDir };
235+
module.exports = {
236+
installDevtunnel,
237+
promptInstall,
238+
getInstallDir,
239+
stripQuarantine,
240+
resolveBinaryPath,
241+
hasQuarantine,
242+
};

0 commit comments

Comments
 (0)