termbeam 1.23.1 → 1.23.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.23.2] - 2026-05-01
4
+
5
+ - fix(tunnel): auto-strip macOS quarantine xattr to prevent Gatekeeper stalls (@dorlugasigal)
6
+ - fix(tunnel): handle Windows PATHEXT lookup when binary name already has .exe (@dorlugasigal)
7
+
3
8
  ## [1.23.1] - 2026-05-01
4
9
 
5
10
  - fix(touchbar): sort keys by col so reordered layouts render all keys (@dorlugasigal)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "termbeam",
3
- "version": "1.23.1",
3
+ "version": "1.23.2",
4
4
  "description": "Beam your terminal to any device — mobile-optimized web terminal with multi-session support",
5
5
  "main": "src/server/index.js",
6
6
  "bin": {
@@ -5,7 +5,7 @@ const os = require('os');
5
5
  const dns = require('dns');
6
6
  const EventEmitter = require('events');
7
7
  const log = require('../utils/logger');
8
- const { promptInstall } = require('./install');
8
+ const { promptInstall, stripQuarantine, resolveBinaryPath, hasQuarantine } = require('./install');
9
9
 
10
10
  const TUNNEL_CONFIG_DIR = path.join(os.homedir(), '.termbeam');
11
11
  const TUNNEL_CONFIG_PATH = path.join(TUNNEL_CONFIG_DIR, 'tunnel.json');
@@ -100,16 +100,53 @@ function isNetworkReachable() {
100
100
  });
101
101
  }
102
102
 
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
+
103
107
  function isLoggedIn() {
104
- try {
105
- const out = execFileSync(devtunnelCmd, ['user', 'show'], {
108
+ const tryShow = () =>
109
+ execFileSync(devtunnelCmd, ['user', 'show'], {
106
110
  encoding: 'utf-8',
107
111
  stdio: ['pipe', 'pipe', 'pipe'],
108
112
  timeout: 10_000,
109
113
  windowsHide: true,
110
114
  });
115
+ try {
116
+ const out = tryShow();
111
117
  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
+ }
113
150
  return false;
114
151
  }
115
152
  }
@@ -638,6 +675,12 @@ async function startTunnel(port, options = {}) {
638
675
  }
639
676
  devtunnelCmd = found;
640
677
 
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
+
641
684
  log.info('Starting devtunnel...');
642
685
  try {
643
686
  // Ensure user is logged in. Prefer Entra over GitHub — Entra tokens auto-refresh
@@ -11,6 +11,98 @@ function getInstallDir() {
11
11
  return INSTALL_DIR;
12
12
  }
13
13
 
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
+ // On Windows, PATHEXT lists the extensions to append when the name has no
31
+ // extension. We always try the bare name first so callers passing
32
+ // 'devtunnel.exe' or 'node.exe' don't end up with 'node.exe.exe'.
33
+ const exts =
34
+ process.platform === 'win32'
35
+ ? ['', ...(process.env.PATHEXT || '.EXE').split(';').map((e) => e.toLowerCase())]
36
+ : [''];
37
+ for (const dir of PATH.split(sep)) {
38
+ if (!dir) continue;
39
+ for (const ext of exts) {
40
+ const candidate = path.join(dir, binPathOrName + ext);
41
+ try {
42
+ const stat = fs.statSync(candidate);
43
+ if (stat.isFile()) {
44
+ try {
45
+ return fs.realpathSync(candidate);
46
+ } catch {
47
+ return candidate;
48
+ }
49
+ }
50
+ } catch {
51
+ // not in this dir
52
+ }
53
+ }
54
+ }
55
+ return null;
56
+ }
57
+
58
+ /**
59
+ * Returns true when the file at `absPath` carries the macOS
60
+ * `com.apple.quarantine` extended attribute. Always false on non-darwin.
61
+ */
62
+ function hasQuarantine(absPath) {
63
+ if (process.platform !== 'darwin' || !absPath) return false;
64
+ try {
65
+ execFileSync('xattr', ['-p', 'com.apple.quarantine', absPath], {
66
+ stdio: 'pipe',
67
+ timeout: 3000,
68
+ });
69
+ return true;
70
+ } catch {
71
+ return false;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Strip the macOS `com.apple.quarantine` extended attribute from a binary.
77
+ * Brew casks tag downloaded binaries with this attribute, which causes
78
+ * Gatekeeper to block execution and pop a system dialog ("are you sure you
79
+ * want to open…") the first time the binary runs in a non-interactive
80
+ * context (e.g. spawned from a service). On the next brew upgrade the new
81
+ * binary is re-tagged, so this needs to run after every install and as a
82
+ * best-effort self-heal step at runtime.
83
+ *
84
+ * Accepts an absolute path OR a bare command name (which is resolved via
85
+ * `$PATH`). Returns one of:
86
+ * - 'noop' — non-darwin, unresolvable, or no quarantine attribute
87
+ * - 'stripped' — quarantine was present and successfully removed
88
+ * - 'failed' — quarantine was present and removal failed (e.g. EPERM)
89
+ */
90
+ function stripQuarantine(binPathOrName) {
91
+ if (process.platform !== 'darwin' || !binPathOrName) return 'noop';
92
+ const resolved = resolveBinaryPath(binPathOrName);
93
+ if (!resolved) return 'noop';
94
+ if (!hasQuarantine(resolved)) return 'noop';
95
+ try {
96
+ execFileSync('xattr', ['-d', 'com.apple.quarantine', resolved], {
97
+ stdio: 'pipe',
98
+ timeout: 5000,
99
+ });
100
+ } catch {
101
+ // Removal refused (e.g. EPERM). Verify outcome below.
102
+ }
103
+ return hasQuarantine(resolved) ? 'failed' : 'stripped';
104
+ }
105
+
14
106
  function getBinaryName() {
15
107
  return process.platform === 'win32' ? 'devtunnel.exe' : 'devtunnel';
16
108
  }
@@ -83,6 +175,10 @@ async function installDevtunnel() {
83
175
  // Find the installed binary
84
176
  const found = findInstalledBinary();
85
177
  if (found) {
178
+ // On macOS, brew --cask tags the binary with com.apple.quarantine which
179
+ // causes Gatekeeper to block execution from non-interactive contexts.
180
+ // Strip it now so the first spawn doesn't trigger a system dialog.
181
+ stripQuarantine(found);
86
182
  log.info(`${green('✔')} DevTunnel CLI installed and verified successfully.`);
87
183
  return found;
88
184
  }
@@ -139,4 +235,11 @@ function findInstalledBinary() {
139
235
  return null;
140
236
  }
141
237
 
142
- module.exports = { installDevtunnel, promptInstall, getInstallDir };
238
+ module.exports = {
239
+ installDevtunnel,
240
+ promptInstall,
241
+ getInstallDir,
242
+ stripQuarantine,
243
+ resolveBinaryPath,
244
+ hasQuarantine,
245
+ };