handmux 0.27.2 → 0.29.0

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.
Files changed (78) hide show
  1. package/bin/handmux.js +2 -2
  2. package/dist/bin/handmux.js +51 -21
  3. package/dist/connectors/bridgeClient.js +30 -14
  4. package/dist/connectors/claude/index.js +78 -23
  5. package/dist/hooks/handmux-write.cjs +11 -0
  6. package/dist/package.json +13 -13
  7. package/dist/public/assets/index-BjnnFTLC.css +32 -0
  8. package/dist/public/assets/index-D0tDAqO-.js +356 -0
  9. package/dist/public/index.html +2 -2
  10. package/dist/src/agent-runtime/bridgeTransport.js +31 -4
  11. package/dist/src/agent-runtime/builtinRuntime.js +10 -2
  12. package/dist/src/agent-runtime/conversation.js +3 -3
  13. package/dist/src/agent-runtime/conversationStore.js +2 -2
  14. package/dist/src/agent-runtime/inbox.js +20 -2
  15. package/dist/src/agent-runtime/run.js +5 -1
  16. package/dist/src/agents/claudeConversation.js +69 -6
  17. package/dist/src/agents/claudeLocalCommand.js +54 -0
  18. package/dist/src/agents/claudeNativeTail.js +271 -0
  19. package/dist/src/agents/claudePaneInput.js +47 -0
  20. package/dist/src/agents/codexConversation.js +46 -0
  21. package/dist/src/agents/piInboxBridge.js +64 -5
  22. package/dist/src/agents/processIdentity.js +13 -6
  23. package/dist/src/apiAccountProviders.js +5 -4
  24. package/dist/src/browser/bootstrap.js +5 -0
  25. package/dist/src/browser/credentials.js +68 -0
  26. package/dist/src/browser/manager.js +40 -5
  27. package/dist/src/browser/publicProxy.js +15 -5
  28. package/dist/src/browser/routes.js +7 -0
  29. package/dist/src/browser/workerClient.js +174 -9
  30. package/dist/src/browser/workerServer.js +13 -1
  31. package/dist/src/claudeEvents.js +94 -38
  32. package/dist/src/cli/authCmd.js +202 -0
  33. package/dist/src/cli/authDefaults.js +18 -0
  34. package/dist/src/cli/claudeHooks.js +3 -3
  35. package/dist/src/cli/hookScaffold.js +5 -1
  36. package/dist/src/cli/i18n/en.js +66 -11
  37. package/dist/src/cli/i18n/zh.js +66 -11
  38. package/dist/src/cli/options.js +7 -13
  39. package/dist/src/cli/pushCmd.js +24 -11
  40. package/dist/src/cli/setupModel.js +28 -6
  41. package/dist/src/cli/setupWizard.js +47 -25
  42. package/dist/src/cli/shortcutEditor.js +20 -2
  43. package/dist/src/cli/supervisor.js +5 -7
  44. package/dist/src/codexAppServer.js +25 -15
  45. package/dist/src/codexTranscriptParse.js +40 -1
  46. package/dist/src/codexUsageSnapshot.js +1 -1
  47. package/dist/src/deviceAccess.js +134 -0
  48. package/dist/src/deviceAuth/control.js +154 -0
  49. package/dist/src/deviceAuth/http.js +321 -0
  50. package/dist/src/deviceAuth/service.js +626 -0
  51. package/dist/src/docs.js +3 -0
  52. package/dist/src/httpApi.js +8 -3
  53. package/dist/src/paneInput.js +16 -2
  54. package/dist/src/previewServer.js +26 -2
  55. package/dist/src/previews.js +37 -13
  56. package/dist/src/projectTask/backup.js +28 -0
  57. package/dist/src/projectTask/lock.js +1 -1
  58. package/dist/src/projectTask/migrations.js +30 -1
  59. package/dist/src/projectTask/runtime.js +7 -1
  60. package/dist/src/projectTask/schema.js +2 -2
  61. package/dist/src/push.js +44 -13
  62. package/dist/src/requestAuthority.js +8 -0
  63. package/dist/src/requestOrigin.js +62 -0
  64. package/dist/src/routes/agents.js +3 -1
  65. package/dist/src/routes/files.js +2 -0
  66. package/dist/src/routes/previews.js +9 -3
  67. package/dist/src/routes/push.js +55 -58
  68. package/dist/src/routes/system.js +4 -4
  69. package/dist/src/routes/terminal.js +9 -1
  70. package/dist/src/server.js +88 -12
  71. package/dist/src/terminalStream.js +90 -5
  72. package/dist/src/tmux/commands.js +2 -0
  73. package/dist/src/transcriptParse.js +4 -1
  74. package/dist/src/workspace/environment.js +14 -5
  75. package/dist/src/workspace/runtime.js +16 -1
  76. package/package.json +4 -4
  77. package/dist/public/assets/index-B4pKf3IW.js +0 -356
  78. package/dist/public/assets/index-Cdrh2CUp.css +0 -32
@@ -0,0 +1,68 @@
1
+ import { Transform } from 'node:stream';
2
+ export const BROWSER_DEVICE_COOKIE = 'tw_browser_device';
3
+ export function browserCookie(raw) {
4
+ const cookies = String(raw || '').split(';').map((part) => part.trim());
5
+ const matches = cookies.filter((part) => part.startsWith(`${BROWSER_DEVICE_COOKIE}=`));
6
+ return matches.length === 1 ? matches[0].slice(BROWSER_DEVICE_COOKIE.length + 1) : null;
7
+ }
8
+ function mainCookie(name) {
9
+ return /^(?:__Host-)?handmux_/i.test(name);
10
+ }
11
+ export function stripMainCookies(headers, stripBrowser = false) {
12
+ const out = { ...headers };
13
+ if (out.cookie) {
14
+ out.cookie = out.cookie.split(';').map((part) => part.trim()).filter((part) => {
15
+ const name = part.split('=', 1)[0] || '';
16
+ return !mainCookie(name) && (!stripBrowser || !['tw_preview', BROWSER_DEVICE_COOKIE].includes(name));
17
+ }).join('; ');
18
+ if (!out.cookie)
19
+ delete out.cookie;
20
+ }
21
+ return out;
22
+ }
23
+ export function stripForgedCookies(headers, stripBrowser = false) {
24
+ const out = { ...headers };
25
+ if (out['set-cookie']) {
26
+ out['set-cookie'] = out['set-cookie'].filter((value) => {
27
+ const name = value.split('=', 1)[0]?.trim() || '';
28
+ return !mainCookie(name) && (!stripBrowser || !['tw_preview', BROWSER_DEVICE_COOKIE].includes(name));
29
+ });
30
+ if (!out['set-cookie'].length)
31
+ delete out['set-cookie'];
32
+ }
33
+ return out;
34
+ }
35
+ // The raw WebSocket tunnel must enforce the same Set-Cookie boundary as HTTP responses.
36
+ export function createBrowserUpgradeFilter() {
37
+ let pending = Buffer.alloc(0);
38
+ let complete = false;
39
+ return new Transform({
40
+ transform(chunk, _encoding, callback) {
41
+ if (complete) {
42
+ callback(null, chunk);
43
+ return;
44
+ }
45
+ pending = Buffer.concat([pending, chunk]);
46
+ const end = pending.indexOf('\r\n\r\n');
47
+ if (end < 0) {
48
+ if (pending.length > 64 * 1024)
49
+ callback(new Error('browser upgrade headers too large'));
50
+ else
51
+ callback();
52
+ return;
53
+ }
54
+ const lines = pending.subarray(0, end).toString('latin1').split('\r\n');
55
+ const safe = lines.filter((line) => {
56
+ if (!/^set-cookie\s*:/i.test(line))
57
+ return true;
58
+ const value = line.slice(line.indexOf(':') + 1).trim();
59
+ return Boolean(stripForgedCookies({ 'set-cookie': [value] }, true)['set-cookie']?.length);
60
+ });
61
+ complete = true;
62
+ this.push(Buffer.from(`${safe.join('\r\n')}\r\n\r\n`, 'latin1'));
63
+ this.push(pending.subarray(end + 4));
64
+ pending = Buffer.alloc(0);
65
+ callback();
66
+ },
67
+ });
68
+ }
@@ -171,6 +171,14 @@ export async function createBrowserPreviewManager({ hammerhead = defaultHammerhe
171
171
  const pools = new Map();
172
172
  const pendingPools = new Map();
173
173
  const leases = new Map();
174
+ const revokedDevices = new Set();
175
+ // Runtime access tokens rotate on restart; encrypted website profiles retain stable auth ownership.
176
+ const profileDevices = new Map();
177
+ const profileDevice = (deviceId) => profileDevices.get(deviceId) || deviceId;
178
+ const assertDevice = (deviceId) => {
179
+ if (revokedDevices.has(deviceId))
180
+ throw new Error('browser device authorization revoked');
181
+ };
174
182
  const leaseQueues = new Map();
175
183
  const publicOriginClaims = new Map();
176
184
  let poolCount = 0;
@@ -286,7 +294,7 @@ export async function createBrowserPreviewManager({ hammerhead = defaultHammerhe
286
294
  lease.pool.proxy.closeSession(lease.session);
287
295
  releasePool(lease.pool);
288
296
  };
289
- const setDeviceActive = (deviceId) => cookieProfiles.setActive?.(deviceId, [...leases.values()].some((lease) => lease.deviceId === deviceId));
297
+ const setDeviceActive = (deviceId) => cookieProfiles.setActive?.(profileDevice(deviceId), [...leases.values()].some((lease) => lease.deviceId === deviceId));
290
298
  const release = (lease) => {
291
299
  if (!lease || leases.get(lease.key) !== lease)
292
300
  return false;
@@ -303,6 +311,7 @@ export async function createBrowserPreviewManager({ hammerhead = defaultHammerhe
303
311
  const createLease = async ({ tabId, deviceId, url, origin, channel, siteVersion, sourceUserAgent, }) => {
304
312
  if (closing)
305
313
  throw new Error('browser manager closing');
314
+ assertDevice(deviceId);
306
315
  const target = normalizedTarget(url);
307
316
  const publicOrigin = normalizedOrigin(origin);
308
317
  const targetOrigin = new URL(target).origin;
@@ -313,6 +322,7 @@ export async function createBrowserPreviewManager({ hammerhead = defaultHammerhe
313
322
  try {
314
323
  if (closing)
315
324
  throw new Error('browser manager closing');
325
+ assertDevice(deviceId);
316
326
  const identity = siteVersionIdentity(siteVersion, sourceUserAgent);
317
327
  const createdSession = new SessionClass(channel, identity);
318
328
  session = createdSession;
@@ -327,6 +337,10 @@ export async function createBrowserPreviewManager({ hammerhead = defaultHammerhe
327
337
  if (await rehomeNavigation(event, createdSession))
328
338
  return;
329
339
  const result = await policy.check(event._requestInfo.url);
340
+ if (revokedDevices.has(deviceId)) {
341
+ await event.setMock(new hammerhead.ResponseMock('browser device authorization revoked', 403, { 'content-type': 'text/plain' }));
342
+ return;
343
+ }
330
344
  if (result.allowed) {
331
345
  if (event.requestOptions?.headers) {
332
346
  applySiteVersionHeaders(event.requestOptions.headers, identity);
@@ -353,7 +367,7 @@ export async function createBrowserPreviewManager({ hammerhead = defaultHammerhe
353
367
  await event.setMock(new hammerhead.ResponseMock(JSON.stringify({ error: 'browser target blocked', reason: result.reason }), 403, { 'content-type': 'application/json; charset=utf-8' }));
354
368
  },
355
369
  }, () => { });
356
- detachCookies = cookieProfiles.attach(deviceId, createdSession.cookies);
370
+ detachCookies = cookieProfiles.attach(profileDevice(deviceId), createdSession.cookies);
357
371
  const publicUrl = pool.proxy.openSession(target, createdSession);
358
372
  return {
359
373
  key: leaseKey(deviceId, tabId),
@@ -384,6 +398,7 @@ export async function createBrowserPreviewManager({ hammerhead = defaultHammerhe
384
398
  const putImpl = async ({ tabId, deviceId, url, origin, siteVersion, sourceUserAgent, }, requireExisting = false) => {
385
399
  if (!deviceId || !tabId)
386
400
  throw new Error('browser lease identity required');
401
+ assertDevice(deviceId);
387
402
  const key = leaseKey(deviceId, tabId);
388
403
  const existing = leases.get(key);
389
404
  if (requireExisting && !existing)
@@ -409,6 +424,10 @@ export async function createBrowserPreviewManager({ hammerhead = defaultHammerhe
409
424
  siteVersion: requestedVersion,
410
425
  sourceUserAgent,
411
426
  });
427
+ if (revokedDevices.has(deviceId)) {
428
+ disposeLease(next);
429
+ assertDevice(deviceId);
430
+ }
412
431
  leases.set(key, next);
413
432
  touch(next);
414
433
  if (existing)
@@ -475,6 +494,7 @@ export async function createBrowserPreviewManager({ hammerhead = defaultHammerhe
475
494
  redirectStatus: 307,
476
495
  });
477
496
  await event.setMock(new hammerhead.ResponseMock('', 307, hammerheadRebindHeaders(bootstrapUrl)));
497
+ assertDevice(current.deviceId);
478
498
  leases.set(current.key, next);
479
499
  touch(next);
480
500
  disposeLease(current);
@@ -492,6 +512,19 @@ export async function createBrowserPreviewManager({ hammerhead = defaultHammerhe
492
512
  return sessionId.startsWith('_browser-') ? sessionId : null;
493
513
  };
494
514
  return {
515
+ setProfileDevice(capability, deviceId) {
516
+ assertDevice(capability);
517
+ const existing = profileDevices.get(capability);
518
+ if (existing && existing !== deviceId)
519
+ throw new Error('browser profile ownership cannot change');
520
+ profileDevices.set(capability, deviceId);
521
+ },
522
+ revokeDevice(deviceId) {
523
+ revokedDevices.add(deviceId);
524
+ for (const lease of [...leases.values()])
525
+ if (lease.deviceId === deviceId)
526
+ release(lease);
527
+ },
495
528
  putLease: (options) => put(options),
496
529
  navigateLease(tabId, url, deviceId, origin, siteVersion, sourceUserAgent) {
497
530
  return put({ tabId, url, deviceId, origin, siteVersion, sourceUserAgent }, true);
@@ -529,13 +562,15 @@ export async function createBrowserPreviewManager({ hammerhead = defaultHammerhe
529
562
  return { port: lease.pool.ports[0], origin: lease.publicOrigin };
530
563
  },
531
564
  configureDeviceProfile(deviceId, prefs) {
532
- return cookieProfiles.configure(deviceId, prefs);
565
+ assertDevice(deviceId);
566
+ return cookieProfiles.configure(profileDevice(deviceId), prefs);
533
567
  },
534
568
  async clearDeviceProfile(deviceId, { origin }) {
535
- await cookieProfiles.clear(deviceId, origin === null
569
+ assertDevice(deviceId);
570
+ await cookieProfiles.clear(profileDevice(deviceId), origin === null
536
571
  ? {}
537
572
  : { hostname: new URL(origin).hostname });
538
- await cookieProfiles.flush?.(deviceId);
573
+ await cookieProfiles.flush?.(profileDevice(deviceId));
539
574
  return { closedTabIds: [] };
540
575
  },
541
576
  async close() {
@@ -2,6 +2,7 @@ import http from 'node:http';
2
2
  import net from 'node:net';
3
3
  import { isBrowserBootstrapPath } from './bootstrap.js';
4
4
  import { classifyIp } from './targetPolicy.js';
5
+ import { stripMainCookies, stripForgedCookies, createBrowserUpgradeFilter } from './credentials.js';
5
6
  const SERVICE_PATHS = new Set([
6
7
  '/hammerhead.js',
7
8
  '/task.js',
@@ -82,10 +83,12 @@ function expectsDocument(req) {
82
83
  return destination === 'document' || destination === 'iframe' || accept.includes('text/html');
83
84
  }
84
85
  function upstreamHeaders(headers, port, token) {
85
- const out = { ...headers, host: `127.0.0.1:${port}` };
86
+ const out = { ...stripMainCookies(headers, true), host: `127.0.0.1:${port}` };
86
87
  if (token && out.authorization === `Bearer ${token}`)
87
88
  delete out.authorization;
88
89
  delete out['proxy-authorization'];
90
+ delete out['x-handmux-browser-device'];
91
+ delete out['x-handmux-browser-profile-device'];
89
92
  if (out.cookie) {
90
93
  out.cookie = filteredCookie(out.cookie);
91
94
  if (!out.cookie)
@@ -103,9 +106,14 @@ export function createBrowserPublicProxy({ browser, browserBootstrap, token, req
103
106
  const bootstrap = browserBootstrap?.consume(pathname, origin);
104
107
  if (!bootstrap)
105
108
  return res.status(403).json({ error: 'browser bootstrap unavailable' });
106
- const secure = origin?.startsWith('https://') ? '; Secure' : '';
109
+ // The generated preview origin is embedded in the handmux page. Strict cookies are
110
+ // withheld from that cross-site iframe, so the redirected session loses its capability
111
+ // before the first document request. HTTPS is required for SameSite=None by browsers;
112
+ // keep the local HTTP fallback strict because it cannot safely opt into cross-site cookies.
113
+ const crossSite = origin?.startsWith('https://') === true;
114
+ const cookiePolicy = crossSite ? 'SameSite=None; Secure' : 'SameSite=Strict';
107
115
  res.setHeader('Cache-Control', 'no-store');
108
- res.setHeader('Set-Cookie', `${DEVICE_COOKIE}=${bootstrap.deviceId}; Path=/; HttpOnly; SameSite=Strict${secure}`);
116
+ res.setHeader('Set-Cookie', `${DEVICE_COOKIE}=${bootstrap.deviceId}; Path=/; HttpOnly; ${cookiePolicy}`);
109
117
  return res.redirect(bootstrap.preserveMethod ? (bootstrap.redirectStatus || 307) : 302, bootstrap.url);
110
118
  }
111
119
  const deviceId = cookieValue(req.headers.cookie, DEVICE_COOKIE);
@@ -127,7 +135,7 @@ export function createBrowserPublicProxy({ browser, browserBootstrap, token, req
127
135
  path: req.originalUrl || req.url,
128
136
  headers: upstreamHeaders(req.headers, port, token),
129
137
  }, (incoming) => {
130
- res.writeHead(incoming.statusCode || 502, incoming.headers);
138
+ res.writeHead(incoming.statusCode || 502, stripForgedCookies(incoming.headers, true));
131
139
  incoming.pipe(res);
132
140
  res.once('close', () => { if (!res.writableEnded)
133
141
  incoming.destroy(); });
@@ -166,7 +174,9 @@ export function createBrowserPublicProxy({ browser, browserBootstrap, token, req
166
174
  upstream.write(`${lines.join('\r\n')}\r\n\r\n`);
167
175
  if (head?.length)
168
176
  upstream.write(head);
169
- socket.pipe(upstream).pipe(socket);
177
+ const filter = createBrowserUpgradeFilter();
178
+ filter.once('error', () => { socket.destroy(); upstream.destroy(); });
179
+ socket.pipe(upstream).pipe(filter).pipe(socket);
170
180
  });
171
181
  upstream.once('error', () => socket.destroy());
172
182
  socket.once('error', () => upstream.destroy());
@@ -81,6 +81,10 @@ export function browserRoutes({ browser, previewDomain = null, browserBootstrap
81
81
  return res.status(400).json({ error: 'bad browser site version' });
82
82
  try {
83
83
  const origin = wildcardOrigin(publicBase, new URL(url).origin);
84
+ // Ports do not isolate cookies. Proxy documents must never share the app hostname.
85
+ if (new URL(origin).hostname === new URL(browserRequestOrigin(req) || 'http://invalid').hostname) {
86
+ return res.status(400).json({ error: 'browser preview must use a different hostname from handmux' });
87
+ }
84
88
  const lease = await browser.putLease({
85
89
  tabId: req.params.tabId,
86
90
  url,
@@ -109,6 +113,9 @@ export function browserRoutes({ browser, previewDomain = null, browserBootstrap
109
113
  return res.status(400).json({ error: 'bad browser site version' });
110
114
  try {
111
115
  const origin = wildcardOrigin(publicBase, new URL(url).origin);
116
+ if (new URL(origin).hostname === new URL(browserRequestOrigin(req) || 'http://invalid').hostname) {
117
+ return res.status(400).json({ error: 'browser preview must use a different hostname from handmux' });
118
+ }
112
119
  const lease = await browser.navigateLease(req.params.tabId, url, deviceId(req), origin, siteVersion, req.get('user-agent') || '');
113
120
  if (!lease)
114
121
  return res.status(404).json({ error: 'browser proxy lease not found' });
@@ -4,17 +4,41 @@ import { randomBytes } from 'node:crypto';
4
4
  import { fork } from 'node:child_process';
5
5
  import { fileURLToPath } from 'node:url';
6
6
  import { claimedBrowserRequest } from './publicProxy.js';
7
+ import { isBrowserBootstrapPath } from './bootstrap.js';
8
+ import { browserCookie, stripMainCookies, stripForgedCookies } from './credentials.js';
7
9
  import { BROWSER_INTERNAL_HEADER } from './protocol.js';
8
10
  import { createBrowserCoordinator } from './coordinator.js';
9
11
  const WORKER_FILE = fileURLToPath(new URL('./worker.js', import.meta.url));
10
12
  function forwardedHeaders(headers, internalToken, appToken, api) {
11
- const out = { ...headers, [BROWSER_INTERNAL_HEADER]: internalToken };
13
+ const out = { ...stripMainCookies(headers), [BROWSER_INTERNAL_HEADER]: internalToken };
12
14
  if (api || out.authorization === `Bearer ${appToken}`)
13
15
  delete out.authorization;
14
16
  delete out['proxy-authorization'];
15
17
  return out;
16
18
  }
17
- export function createBrowserWorkerClient({ appToken, previewDomain = null, handmuxOrigin = 'http://127.0.0.1', forkWorker = fork, request = http.request, connect = net.connect, randomToken = () => randomBytes(32).toString('base64url'), parentEnv = process.env, setTimer = setTimeout, clearTimer = clearTimeout, readyTimeoutMs = 10_000, stableAfterMs = 30_000, requestTimeoutMs = 15_000, stopTimeoutMs = 3_000, } = {}) {
19
+ function previewHostnameFor(raw) {
20
+ if (!raw)
21
+ return null;
22
+ try {
23
+ const value = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
24
+ return new URL(value).hostname.toLowerCase().replace(/\.$/, '');
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ }
30
+ function isGeneratedPreviewHostname(hostname, previewHostname) {
31
+ if (!hostname || !previewHostname)
32
+ return false;
33
+ const suffix = `.${previewHostname}`;
34
+ if (!hostname.endsWith(suffix))
35
+ return false;
36
+ const label = hostname.slice(0, -suffix.length);
37
+ // browserLabelForOrigin() emits exactly this opaque, lower-case label. Keep
38
+ // arbitrary subdomains on the main-host path so they cannot bypass the guard.
39
+ return /^b-[0-9a-z]{13}$/i.test(label);
40
+ }
41
+ export function createBrowserWorkerClient({ appToken, previewDomain = null, handmuxOrigin = 'http://127.0.0.1', forkWorker = fork, request = http.request, connect = net.connect, randomToken = () => randomBytes(32).toString('base64url'), parentEnv = process.env, setTimer = setTimeout, clearTimer = clearTimeout, readyTimeoutMs = 10_000, stableAfterMs = 30_000, requestTimeoutMs = 15_000, stopTimeoutMs = 3_000, deviceAuthorization, } = {}) {
18
42
  const internalToken = randomToken();
19
43
  let child = null;
20
44
  let port = null;
@@ -25,6 +49,56 @@ export function createBrowserWorkerClient({ appToken, previewDomain = null, hand
25
49
  let readyTimer = null;
26
50
  let stableTimer = null;
27
51
  const activeSockets = new Set();
52
+ // These are bearer capabilities, not user-supplied IDs. They die with this process/worker.
53
+ const browserDevices = new Map();
54
+ const deviceBrowsers = new Map();
55
+ const deviceConnections = new Map();
56
+ const appHostnames = new Set([new URL(handmuxOrigin).hostname]);
57
+ const previewHostname = previewHostnameFor(previewDomain);
58
+ const hostname = (req) => {
59
+ try {
60
+ return new URL(`http://${req.headers.host}`).hostname;
61
+ }
62
+ catch {
63
+ return null;
64
+ }
65
+ };
66
+ const ownerFor = (capability) => {
67
+ const owner = capability ? browserDevices.get(capability) : undefined;
68
+ return owner && deviceAuthorization?.isActive(owner) ? owner : null;
69
+ };
70
+ const track = (owner, stop) => {
71
+ if (!owner)
72
+ return () => { };
73
+ let connections = deviceConnections.get(owner);
74
+ if (!connections)
75
+ deviceConnections.set(owner, connections = new Set());
76
+ connections.add(stop);
77
+ return () => { connections.delete(stop); if (!connections.size)
78
+ deviceConnections.delete(owner); };
79
+ };
80
+ const revokeDevice = (deviceId) => {
81
+ const capabilities = [];
82
+ for (const [key, capability] of deviceBrowsers) {
83
+ if (key === deviceId || key.startsWith(`${deviceId}:`)) {
84
+ capabilities.push(capability);
85
+ deviceBrowsers.delete(key);
86
+ browserDevices.delete(capability);
87
+ }
88
+ }
89
+ for (const stop of deviceConnections.get(deviceId) || [])
90
+ stop();
91
+ deviceConnections.delete(deviceId);
92
+ if (capabilities.length && port) {
93
+ // The worker blocks pending async creations as well as disposing existing leases.
94
+ const revoke = request({ hostname: '127.0.0.1', port, method: 'POST',
95
+ path: `/_browser-worker/revoke/${encodeURIComponent(deviceId)}`,
96
+ headers: { [BROWSER_INTERNAL_HEADER]: internalToken } }, (incoming) => incoming.resume());
97
+ revoke.once('error', () => { });
98
+ revoke.setTimeout?.(requestTimeoutMs, () => revoke.destroy());
99
+ revoke.end();
100
+ }
101
+ };
28
102
  const workerEnv = {};
29
103
  for (const name of [
30
104
  'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'TMP', 'TEMP',
@@ -108,6 +182,8 @@ export function createBrowserWorkerClient({ appToken, previewDomain = null, hand
108
182
  for (const socket of activeSockets)
109
183
  socket.destroy();
110
184
  activeSockets.clear();
185
+ browserDevices.clear();
186
+ deviceBrowsers.clear();
111
187
  scheduleRestart();
112
188
  };
113
189
  spawned.once('error', () => {
@@ -123,12 +199,34 @@ export function createBrowserWorkerClient({ appToken, previewDomain = null, hand
123
199
  spawned.once('close', finalize);
124
200
  };
125
201
  const unavailable = (res, status) => res.status(status).json({ error: 'browser unavailable' });
202
+ const isMainRequest = (req) => {
203
+ const requestHostname = hostname(req);
204
+ // The auth-origin resolver also accepts generated preview origins. Those
205
+ // requests belong to the isolated browser worker and must not be treated
206
+ // as requests to the handmux app itself.
207
+ if (isGeneratedPreviewHostname(requestHostname, previewHostname))
208
+ return false;
209
+ return Boolean(deviceAuthorization && (appHostnames.has(requestHostname || '') || deviceAuthorization.isMainRequest?.(req)));
210
+ };
126
211
  const proxyHttp = (api) => (req, res, next) => {
127
212
  if (!api && !claimedBrowserRequest(req))
128
213
  return next();
129
214
  const targetPort = port;
130
215
  if (!targetPort)
131
216
  return unavailable(res, api ? 503 : 502);
217
+ // Hammerhead's /task.js endpoint identifies the active session from the
218
+ // proxied document's Referer. The parent Handmux response policy is
219
+ // `no-referrer`, which otherwise strips that session URL and leaves the
220
+ // embedded page blank after the first HTML response.
221
+ if (!api)
222
+ res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
223
+ if (isMainRequest(req)) {
224
+ return res.status(403).json({ error: 'browser preview must use a different hostname from handmux' });
225
+ }
226
+ const bootstrap = isBrowserBootstrapPath((req.originalUrl || req.url).split('?')[0]);
227
+ let owner = ownerFor(browserCookie(req.headers.cookie));
228
+ if (deviceAuthorization && !api && !bootstrap && !owner)
229
+ return res.status(403).json({ error: 'browser device authorization expired' });
132
230
  const upstream = request({
133
231
  hostname: '127.0.0.1',
134
232
  port: targetPort,
@@ -136,14 +234,30 @@ export function createBrowserWorkerClient({ appToken, previewDomain = null, hand
136
234
  path: req.originalUrl || req.url,
137
235
  headers: forwardedHeaders(req.headers, internalToken, appToken, api),
138
236
  }, (incoming) => {
139
- res.writeHead(incoming.statusCode || 502, incoming.headers);
237
+ if (deviceAuthorization) {
238
+ if (bootstrap && incoming.statusCode && incoming.statusCode >= 300 && incoming.statusCode < 400) {
239
+ owner = ownerFor(browserCookie((incoming.headers['set-cookie'] || []).map((value) => value.split(';')[0]).join(';')));
240
+ }
241
+ if (!owner || !deviceAuthorization.isActive(owner)) {
242
+ incoming.destroy();
243
+ if (!res.headersSent)
244
+ res.status(403).json({ error: 'browser device authorization expired' });
245
+ return;
246
+ }
247
+ }
248
+ const untrackResponse = track(owner, () => { incoming.destroy(); res.destroy(); });
249
+ res.once('close', untrackResponse);
250
+ res.writeHead(incoming.statusCode || 502, stripForgedCookies(incoming.headers));
140
251
  incoming.pipe(res);
252
+ incoming.once('aborted', () => res.destroy());
141
253
  res.once('close', () => { if (!res.writableEnded)
142
254
  incoming.destroy(); });
143
255
  });
144
256
  if (api)
145
257
  upstream.setTimeout?.(requestTimeoutMs, () => upstream.destroy(new Error('browser worker request timeout')));
146
258
  const abort = () => { upstream.destroy(); };
259
+ const untrack = track(owner, () => { upstream.destroy(); res.destroy(); });
260
+ res.once('close', untrack);
147
261
  req.once('aborted', abort);
148
262
  res.once('close', () => { if (!res.writableEnded)
149
263
  abort(); });
@@ -163,14 +277,30 @@ export function createBrowserWorkerClient({ appToken, previewDomain = null, hand
163
277
  socket.destroy();
164
278
  return true;
165
279
  }
280
+ if (isMainRequest(req)) {
281
+ socket.destroy();
282
+ return true;
283
+ }
284
+ const owner = ownerFor(browserCookie(req.headers.cookie));
285
+ if (deviceAuthorization && !owner) {
286
+ socket.destroy();
287
+ return true;
288
+ }
166
289
  const upstream = connect({ host: '127.0.0.1', port: targetPort });
167
290
  activeSockets.add(socket);
168
291
  activeSockets.add(upstream);
292
+ const untrack = track(owner, () => { socket.destroy(); upstream.destroy(); });
169
293
  const cleanup = () => {
170
294
  activeSockets.delete(socket);
171
295
  activeSockets.delete(upstream);
296
+ untrack();
172
297
  };
173
298
  upstream.once('connect', () => {
299
+ if (owner && !deviceAuthorization?.isActive(owner)) {
300
+ socket.destroy();
301
+ upstream.destroy();
302
+ return;
303
+ }
174
304
  const headers = forwardedHeaders(req.headers, internalToken, appToken, false);
175
305
  const lines = [`${req.method || 'GET'} ${req.url} HTTP/${req.httpVersion || '1.1'}`];
176
306
  for (const [name, value] of Object.entries(headers)) {
@@ -195,6 +325,9 @@ export function createBrowserWorkerClient({ appToken, previewDomain = null, hand
195
325
  const targetPort = port;
196
326
  if (!targetPort)
197
327
  return resolve(null);
328
+ const owner = deviceAuthorization ? ownerFor(String(req.headers['x-handmux-browser-device'] || '')) : null;
329
+ if (deviceAuthorization && !owner)
330
+ return resolve({ status: 401, body: Buffer.from('{"error":"device authorization expired"}') });
198
331
  const payload = body === undefined ? null : Buffer.from(JSON.stringify(body));
199
332
  const headers = forwardedHeaders(req.headers, internalToken, appToken, true);
200
333
  if (payload) {
@@ -207,22 +340,53 @@ export function createBrowserWorkerClient({ appToken, previewDomain = null, hand
207
340
  }
208
341
  const upstream = request({ hostname: '127.0.0.1', port: targetPort, method, path, headers }, (incoming) => {
209
342
  const chunks = [];
343
+ incoming.once('aborted', () => resolve(null));
210
344
  incoming.on('data', (chunk) => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)));
211
- incoming.on('end', () => resolve({
212
- status: incoming.statusCode || 502,
213
- headers: incoming.headers,
214
- body: Buffer.concat(chunks),
215
- }));
345
+ incoming.on('end', () => {
346
+ if (owner && !deviceAuthorization?.isActive(owner)) {
347
+ revokeDevice(owner);
348
+ return resolve({ status: 401, body: Buffer.from('{"error":"device authorization expired"}') });
349
+ }
350
+ resolve({ status: incoming.statusCode || 502, headers: stripForgedCookies(incoming.headers), body: Buffer.concat(chunks) });
351
+ });
216
352
  });
217
353
  upstream.setTimeout?.(requestTimeoutMs, () => upstream.destroy(new Error('browser worker request timeout')));
218
354
  upstream.once('error', () => resolve(null));
355
+ const untrack = track(owner, () => { upstream.destroy(); resolve({ status: 401, body: Buffer.from('{"error":"device authorization expired"}') }); });
356
+ upstream.once('close', untrack);
219
357
  req.once('aborted', () => upstream.destroy());
220
358
  upstream.end(payload || undefined);
221
359
  });
222
- const apiHandler = createBrowserCoordinator({
360
+ const coordinator = createBrowserCoordinator({
223
361
  proxyRequest,
224
362
  getStatus: () => ({ ready: port != null, generation }),
225
363
  });
364
+ const apiHandler = Object.assign(((req, res, next) => {
365
+ delete req.headers['x-handmux-browser-profile-device'];
366
+ if (deviceAuthorization) {
367
+ const principal = res.locals.deviceAuth;
368
+ const deviceId = principal && typeof principal === 'object' && 'deviceId' in principal && typeof principal.deviceId === 'string'
369
+ ? principal.deviceId : deviceAuthorization.getDeviceId(req);
370
+ if (!deviceId || !deviceAuthorization.isActive(deviceId))
371
+ return res.status(401).json({ error: 'device authorization expired' });
372
+ const appHostname = hostname(req);
373
+ if (appHostname)
374
+ appHostnames.add(appHostname);
375
+ // Token-only principals are shared by all browsers. Include the browser's
376
+ // auth/session cookie in the profile key so separate browsers never share
377
+ // a website cookie jar while retaining a stable key across requests.
378
+ const sessionKey = `${deviceId}:${typeof req.headers.cookie === 'string' ? req.headers.cookie : ''}`;
379
+ let capability = deviceBrowsers.get(sessionKey);
380
+ if (!capability) {
381
+ capability = randomBytes(32).toString('base64url');
382
+ deviceBrowsers.set(sessionKey, capability);
383
+ browserDevices.set(capability, deviceId);
384
+ }
385
+ req.headers['x-handmux-browser-device'] = capability;
386
+ req.headers['x-handmux-browser-profile-device'] = deviceId;
387
+ }
388
+ return coordinator(req, res, next);
389
+ }), { close: () => coordinator.close() });
226
390
  if (previewDomain)
227
391
  start();
228
392
  let closePromise = null;
@@ -230,6 +394,7 @@ export function createBrowserWorkerClient({ appToken, previewDomain = null, hand
230
394
  apiHandler,
231
395
  publicHandler: proxyHttp(false),
232
396
  onUpgrade,
397
+ revokeDevice,
233
398
  health() {
234
399
  if (!previewDomain)
235
400
  return { status: 'disabled', detail: null };
@@ -36,7 +36,19 @@ export async function createBrowserWorkerServer({ internalToken, previewDomain =
36
36
  return next();
37
37
  });
38
38
  app.get('/_browser-worker/health', (_req, res) => res.json({ ok: true }));
39
- app.use('/api/browser-proxy', express.json(), workerBrowserRoutes({ browser, previewDomain, browserBootstrap }));
39
+ app.post('/_browser-worker/revoke/:deviceId', (req, res) => {
40
+ browserBootstrap.revokeDevice(req.params.deviceId);
41
+ browser.revokeDevice?.(req.params.deviceId);
42
+ res.status(204).end();
43
+ });
44
+ app.use('/api/browser-proxy', express.json(), (req, _res, next) => {
45
+ const capability = req.headers['x-handmux-browser-device'];
46
+ const owner = req.headers['x-handmux-browser-profile-device'];
47
+ if (typeof capability === 'string' && typeof owner === 'string')
48
+ browser.setProfileDevice?.(capability, owner);
49
+ delete req.headers['x-handmux-browser-profile-device'];
50
+ next();
51
+ }, workerBrowserRoutes({ browser, previewDomain, browserBootstrap }));
40
52
  app.use(browserPublic.handler);
41
53
  const server = http.createServer(app);
42
54
  const sockets = new Set();