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.
- package/bin/handmux.js +2 -2
- package/dist/bin/handmux.js +51 -21
- package/dist/connectors/bridgeClient.js +30 -14
- package/dist/connectors/claude/index.js +78 -23
- package/dist/hooks/handmux-write.cjs +11 -0
- package/dist/package.json +13 -13
- package/dist/public/assets/index-BjnnFTLC.css +32 -0
- package/dist/public/assets/index-D0tDAqO-.js +356 -0
- package/dist/public/index.html +2 -2
- package/dist/src/agent-runtime/bridgeTransport.js +31 -4
- package/dist/src/agent-runtime/builtinRuntime.js +10 -2
- package/dist/src/agent-runtime/conversation.js +3 -3
- package/dist/src/agent-runtime/conversationStore.js +2 -2
- package/dist/src/agent-runtime/inbox.js +20 -2
- package/dist/src/agent-runtime/run.js +5 -1
- package/dist/src/agents/claudeConversation.js +69 -6
- package/dist/src/agents/claudeLocalCommand.js +54 -0
- package/dist/src/agents/claudeNativeTail.js +271 -0
- package/dist/src/agents/claudePaneInput.js +47 -0
- package/dist/src/agents/codexConversation.js +46 -0
- package/dist/src/agents/piInboxBridge.js +64 -5
- package/dist/src/agents/processIdentity.js +13 -6
- package/dist/src/apiAccountProviders.js +5 -4
- package/dist/src/browser/bootstrap.js +5 -0
- package/dist/src/browser/credentials.js +68 -0
- package/dist/src/browser/manager.js +40 -5
- package/dist/src/browser/publicProxy.js +15 -5
- package/dist/src/browser/routes.js +7 -0
- package/dist/src/browser/workerClient.js +174 -9
- package/dist/src/browser/workerServer.js +13 -1
- package/dist/src/claudeEvents.js +94 -38
- package/dist/src/cli/authCmd.js +202 -0
- package/dist/src/cli/authDefaults.js +18 -0
- package/dist/src/cli/claudeHooks.js +3 -3
- package/dist/src/cli/hookScaffold.js +5 -1
- package/dist/src/cli/i18n/en.js +66 -11
- package/dist/src/cli/i18n/zh.js +66 -11
- package/dist/src/cli/options.js +7 -13
- package/dist/src/cli/pushCmd.js +24 -11
- package/dist/src/cli/setupModel.js +28 -6
- package/dist/src/cli/setupWizard.js +47 -25
- package/dist/src/cli/shortcutEditor.js +20 -2
- package/dist/src/cli/supervisor.js +5 -7
- package/dist/src/codexAppServer.js +25 -15
- package/dist/src/codexTranscriptParse.js +40 -1
- package/dist/src/codexUsageSnapshot.js +1 -1
- package/dist/src/deviceAccess.js +134 -0
- package/dist/src/deviceAuth/control.js +154 -0
- package/dist/src/deviceAuth/http.js +321 -0
- package/dist/src/deviceAuth/service.js +626 -0
- package/dist/src/docs.js +3 -0
- package/dist/src/httpApi.js +8 -3
- package/dist/src/paneInput.js +16 -2
- package/dist/src/previewServer.js +26 -2
- package/dist/src/previews.js +37 -13
- package/dist/src/projectTask/backup.js +28 -0
- package/dist/src/projectTask/lock.js +1 -1
- package/dist/src/projectTask/migrations.js +30 -1
- package/dist/src/projectTask/runtime.js +7 -1
- package/dist/src/projectTask/schema.js +2 -2
- package/dist/src/push.js +44 -13
- package/dist/src/requestAuthority.js +8 -0
- package/dist/src/requestOrigin.js +62 -0
- package/dist/src/routes/agents.js +3 -1
- package/dist/src/routes/files.js +2 -0
- package/dist/src/routes/previews.js +9 -3
- package/dist/src/routes/push.js +55 -58
- package/dist/src/routes/system.js +4 -4
- package/dist/src/routes/terminal.js +9 -1
- package/dist/src/server.js +88 -12
- package/dist/src/terminalStream.js +90 -5
- package/dist/src/tmux/commands.js +2 -0
- package/dist/src/transcriptParse.js +4 -1
- package/dist/src/workspace/environment.js +14 -5
- package/dist/src/workspace/runtime.js +16 -1
- package/package.json +4 -4
- package/dist/public/assets/index-B4pKf3IW.js +0 -356
- 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
|
-
|
|
565
|
+
assertDevice(deviceId);
|
|
566
|
+
return cookieProfiles.configure(profileDevice(deviceId), prefs);
|
|
533
567
|
},
|
|
534
568
|
async clearDeviceProfile(deviceId, { origin }) {
|
|
535
|
-
|
|
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
|
-
|
|
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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', () =>
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
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
|
|
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.
|
|
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();
|