handmux 0.22.0 → 0.23.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/README.md +2 -2
- package/README.zh-CN.md +2 -2
- package/bin/handmux.js +29 -28
- package/hooks/handmux-notify.sh +2 -4
- package/hooks/handmux-write.cjs +2 -24
- package/package.json +4 -4
- package/public/assets/index-C3N7OkyP.css +32 -0
- package/public/assets/index-D1DWcC5u.js +337 -0
- package/public/index.html +2 -2
- package/src/agents/claude.js +2 -0
- package/src/agents/codex.js +60 -13
- package/src/agents/index.js +2 -3
- package/src/browser/manager.js +37 -11
- package/src/browser/publicProxy.js +23 -1
- package/src/browser/routes.js +9 -0
- package/src/browser/siteVersion.js +124 -0
- package/src/claudeEvents.js +70 -27
- package/src/cli/codexManaged.js +77 -0
- package/src/cli/hookScaffold.js +8 -10
- package/src/cli/i18n/en.js +5 -8
- package/src/cli/i18n/zh.js +5 -8
- package/src/cli/legacyCodexHooks.js +49 -0
- package/src/codexAppServer.js +1620 -0
- package/src/codexDiff.js +108 -0
- package/src/codexTranscriptParse.js +413 -0
- package/{hooks/handmux-codex-usage.cjs → src/codexUsageSnapshot.cjs} +3 -15
- package/src/httpApi.js +4 -1
- package/src/orphans.js +3 -1
- package/src/routes/codex.js +473 -0
- package/src/routes/sessions.js +7 -2
- package/src/routes/system.js +9 -22
- package/src/routes/transcript.js +75 -19
- package/src/server.js +21 -13
- package/src/tmux/commands.js +11 -3
- package/src/transcriptReader.js +5 -5
- package/src/usage.js +4 -5
- package/src/workspace/agentRunner.js +4 -1
- package/src/workspace/capture.js +54 -2
- package/src/workspace/checkpointer.js +6 -2
- package/public/assets/index-CRAlQZDz.css +0 -32
- package/public/assets/index-CzlitgRX.js +0 -335
- package/src/cli/codexHooks.js +0 -123
package/public/index.html
CHANGED
|
@@ -48,8 +48,8 @@
|
|
|
48
48
|
@keyframes bootDot { 0%,100% { opacity: .22; transform: translateY(0); } 40% { opacity: 1; transform: translateY(-3px); } }
|
|
49
49
|
@media (prefers-reduced-motion: reduce) { .boot-glow, .boot-dots i { animation: none; opacity: .8; } }
|
|
50
50
|
</style>
|
|
51
|
-
<script type="module" crossorigin src="/assets/index-
|
|
52
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
51
|
+
<script type="module" crossorigin src="/assets/index-D1DWcC5u.js"></script>
|
|
52
|
+
<link rel="stylesheet" crossorigin href="/assets/index-C3N7OkyP.css" media="print" onload="this.media='all'"><noscript><link rel="stylesheet" crossorigin href="/assets/index-C3N7OkyP.css"></noscript>
|
|
53
53
|
</head>
|
|
54
54
|
<body>
|
|
55
55
|
<div id="boot-splash" aria-hidden="true">
|
package/src/agents/claude.js
CHANGED
|
@@ -12,6 +12,7 @@ import path from 'node:path';
|
|
|
12
12
|
import os from 'node:os';
|
|
13
13
|
import { resolveEncodedDirSession, isSessionUuid } from './scanUtils.js';
|
|
14
14
|
import { resolveByExecutable, executableBasename } from './processIdentity.js';
|
|
15
|
+
import { createTranscriptParser, parseTranscript } from '../transcriptParse.js';
|
|
15
16
|
|
|
16
17
|
// The NATIVE installer names the real binary by version (~/.local/share/claude/versions/2.1.196 —
|
|
17
18
|
// ~/.local/bin/claude is only a symlink to it), and tmux #{pane_current_command} follows that basename
|
|
@@ -142,6 +143,7 @@ export const claude = {
|
|
|
142
143
|
procMatch: /^(\S*\/)?claude(\s|$)/,
|
|
143
144
|
takeoverPrefix: 'cc', // tmux session name prefix for a takeover (cc-<label>-<n>)
|
|
144
145
|
classify: classifyClaude,
|
|
146
|
+
transcript: { createParser: createTranscriptParser, parse: parseTranscript },
|
|
145
147
|
sessions: {
|
|
146
148
|
isId: isSessionUuid,
|
|
147
149
|
dirOptKey: 'projectsDir', // scanOrphans option that overrides `dir` (back-compat + tests)
|
package/src/agents/codex.js
CHANGED
|
@@ -1,15 +1,13 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
// the SAME lifecycle events (UserPromptSubmit / PermissionRequest / Stop …) delivering the SAME stdin JSON
|
|
4
|
-
// fields (prompt / tool_input / last_assistant_message / stop_hook_active …). So handmux wires Codex through
|
|
5
|
-
// its config.toml hooks (see cli/codexHooks.js) reusing the shared Claude hook scripts, and Codex CLASSIFIES
|
|
6
|
-
// with the very same function as Claude — full working / 需要你 / done parity, not just turn-done.
|
|
1
|
+
// Codex process/session discovery. App Server owns chat, approvals, stopping, inbox state and exact thread
|
|
2
|
+
// identity; plain Codex is identified only as a live process until the user opts into managed mode.
|
|
7
3
|
import path from 'node:path';
|
|
8
4
|
import os from 'node:os';
|
|
9
5
|
import { promises as fsp } from 'node:fs';
|
|
10
6
|
import { readHead, readTail, firstCwd, isSessionUuid } from './scanUtils.js';
|
|
11
|
-
import { classifyClaude } from './claude.js';
|
|
12
7
|
import { resolveByExecutable, executableBasename } from './processIdentity.js';
|
|
8
|
+
import {
|
|
9
|
+
createCodexTranscriptParser, isCodexSyntheticUserText, parseCodexTranscript,
|
|
10
|
+
} from '../codexTranscriptParse.js';
|
|
13
11
|
|
|
14
12
|
export const sessionsDir = (home = os.homedir()) => path.join(home, '.codex', 'sessions');
|
|
15
13
|
|
|
@@ -20,10 +18,25 @@ export function rolloutSessionId(name) {
|
|
|
20
18
|
return m ? m[1] : null;
|
|
21
19
|
}
|
|
22
20
|
|
|
21
|
+
const UUID_PARTS = '([0-9a-f]{8})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{4})\\s*-\\s*([0-9a-f]{12})';
|
|
22
|
+
|
|
23
|
+
function lastUuidAfter(text, prefix) {
|
|
24
|
+
const matches = [...String(text || '').matchAll(new RegExp(`${prefix}\\s*${UUID_PARTS}`, 'gi'))];
|
|
25
|
+
const match = matches.at(-1);
|
|
26
|
+
return match ? match.slice(1).join('-').toLowerCase() : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// A normal Codex exit prints the exact command for the session that just exited. Keep this deliberately
|
|
30
|
+
// stricter than a generic `codex resume` search so conversation text or shell history is not mistaken for
|
|
31
|
+
// the current exit notice.
|
|
32
|
+
export function codexExitSessionId(text) {
|
|
33
|
+
return lastUuidAfter(text, 'To continue this session, run\\s+codex\\s+resume');
|
|
34
|
+
}
|
|
35
|
+
|
|
23
36
|
// Last user turn out of a Codex rollout tail, for a recognizable one-line label. Codex records turns as
|
|
24
37
|
// response_item messages: {payload:{type:'message',role:'user',content:[{type:'input_text',text}]}} (and a
|
|
25
|
-
// flatter {type:'message',role:'user',...} in some versions). Skips the synthetic
|
|
26
|
-
// (
|
|
38
|
+
// flatter {type:'message',role:'user',...} in some versions). Skips the synthetic context turns Codex
|
|
39
|
+
// injects (including repository instructions after a resume). Best-effort → '' on miss.
|
|
27
40
|
export function codexUserSnippet(tailText, max = 80) {
|
|
28
41
|
const rows = String(tailText).split('\n');
|
|
29
42
|
for (let i = rows.length - 1; i >= 0; i--) {
|
|
@@ -38,8 +51,9 @@ export function codexUserSnippet(tailText, max = 80) {
|
|
|
38
51
|
let text = '';
|
|
39
52
|
if (typeof c === 'string') text = c;
|
|
40
53
|
else if (Array.isArray(c)) text = c.filter((b) => b && (b.type === 'input_text' || b.type === 'text')).map((b) => b.text).join(' ');
|
|
54
|
+
if (!text || isCodexSyntheticUserText(text)) continue;
|
|
41
55
|
text = text.replace(/\s+/g, ' ').trim();
|
|
42
|
-
if (!text
|
|
56
|
+
if (!text) continue;
|
|
43
57
|
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
44
58
|
}
|
|
45
59
|
return '';
|
|
@@ -82,6 +96,39 @@ async function recentRollouts(dir, limit = 80) {
|
|
|
82
96
|
return out.slice(0, limit);
|
|
83
97
|
}
|
|
84
98
|
|
|
99
|
+
// Resolve one known App Server thread id to its exact rollout. This never falls back by cwd: several
|
|
100
|
+
// Codex panes can share a project, while the UUID suffix is the durable one-to-one session identity.
|
|
101
|
+
const rolloutPathCache = new Map();
|
|
102
|
+
export async function resolveCodexRollout(dir, sessionId) {
|
|
103
|
+
if (!isSessionUuid(sessionId)) return null;
|
|
104
|
+
const key = `${dir}\0${sessionId}`;
|
|
105
|
+
const cached = rolloutPathCache.get(key);
|
|
106
|
+
if (cached) {
|
|
107
|
+
try { await fsp.access(cached); return cached; } catch { rolloutPathCache.delete(key); }
|
|
108
|
+
}
|
|
109
|
+
const suffix = `-${sessionId}.jsonl`;
|
|
110
|
+
let found = null;
|
|
111
|
+
async function descend(current, depth) {
|
|
112
|
+
if (found) return;
|
|
113
|
+
let entries;
|
|
114
|
+
try { entries = await fsp.readdir(current, { withFileTypes: true }); } catch { return; }
|
|
115
|
+
if (depth < 3) {
|
|
116
|
+
for (const entry of entries) {
|
|
117
|
+
if (!entry.isDirectory()) continue;
|
|
118
|
+
await descend(path.join(current, entry.name), depth + 1);
|
|
119
|
+
if (found) return;
|
|
120
|
+
}
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
const match = entries.find((entry) => entry.isFile()
|
|
124
|
+
&& entry.name.startsWith('rollout-') && entry.name.endsWith(suffix));
|
|
125
|
+
if (match) found = path.join(current, match.name);
|
|
126
|
+
}
|
|
127
|
+
await descend(dir, 0);
|
|
128
|
+
if (found) rolloutPathCache.set(key, found);
|
|
129
|
+
return found;
|
|
130
|
+
}
|
|
131
|
+
|
|
85
132
|
// Resolve a live orphan's cwd to its Codex session: the newest rollout whose recorded cwd matches. Same
|
|
86
133
|
// shape as Claude's resolver ({ sessionId, state, snippet, lastActivity }) so the orphan engine is agnostic.
|
|
87
134
|
export async function resolveCodexSession(dir, cwd, { busyMs = 8000, now = Date.now } = {}) {
|
|
@@ -94,7 +141,7 @@ export async function resolveCodexSession(dir, cwd, { busyMs = 8000, now = Date.
|
|
|
94
141
|
if (!sessionId) continue;
|
|
95
142
|
let snippet = '';
|
|
96
143
|
try { snippet = codexUserSnippet(await readTail(file)); } catch { /* best effort */ }
|
|
97
|
-
return { sessionId, state: now() - mtime < busyMs ? 'busy' : 'idle', snippet, lastActivity: Math.round(mtime) };
|
|
144
|
+
return { sessionId, state: now() - mtime < busyMs ? 'busy' : 'idle', snippet, lastActivity: Math.round(mtime), file };
|
|
98
145
|
}
|
|
99
146
|
return {};
|
|
100
147
|
}
|
|
@@ -107,8 +154,7 @@ export const codex = {
|
|
|
107
154
|
procNames: ['codex'],
|
|
108
155
|
procMatch: /^(\S*\/)?codex(\s|$)/,
|
|
109
156
|
takeoverPrefix: 'cx', // tmux session name prefix for a takeover (cx-<label>-<n>)
|
|
110
|
-
|
|
111
|
-
|
|
157
|
+
transcript: { createParser: createCodexTranscriptParser, parse: parseCodexTranscript },
|
|
112
158
|
sessions: {
|
|
113
159
|
isId: isSessionUuid,
|
|
114
160
|
dirOptKey: 'sessionsDir', // scanOrphans option that overrides `dir`
|
|
@@ -117,5 +163,6 @@ export const codex = {
|
|
|
117
163
|
// `codex resume <uuid>` continues the session from its rollout file.
|
|
118
164
|
resumeArgs: (id) => ['codex', 'resume', id],
|
|
119
165
|
resumeCmd: (id) => `codex resume ${id}`,
|
|
166
|
+
managedResumeCmd: (id) => `handmux codex resume ${id}`,
|
|
120
167
|
},
|
|
121
168
|
};
|
package/src/agents/index.js
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
// The agent registry: the one place that lists which coding agents handmux drives. Adding a third agent is a
|
|
2
|
-
// new driver module (see claude.js / codex.js) plus one line here — the inbox pipeline
|
|
3
|
-
//
|
|
4
|
-
// so none of them hard-codes an agent name.
|
|
2
|
+
// new driver module (see claude.js / codex.js) plus one line here — the inbox pipeline and orphan/takeover
|
|
3
|
+
// engine consume drivers through this list, so process and session behavior stays centralized.
|
|
5
4
|
import { claude } from './claude.js';
|
|
6
5
|
import { codex } from './codex.js';
|
|
7
6
|
|
package/src/browser/manager.js
CHANGED
|
@@ -11,6 +11,12 @@ import {
|
|
|
11
11
|
hammerheadRebindHeaders,
|
|
12
12
|
installHammerheadRebindLocationCompat,
|
|
13
13
|
} from './hammerheadRedirectCompat.js';
|
|
14
|
+
import {
|
|
15
|
+
applySiteVersionHeaders,
|
|
16
|
+
normalizeSiteVersion,
|
|
17
|
+
siteVersionIdentity,
|
|
18
|
+
siteVersionNavigatorScript,
|
|
19
|
+
} from './siteVersion.js';
|
|
14
20
|
|
|
15
21
|
const defaultHammerhead = importedHammerhead.default || importedHammerhead;
|
|
16
22
|
const POOL_IDLE_CLOSE_MS = 1_000;
|
|
@@ -32,9 +38,10 @@ function isLoopbackUrl(raw) {
|
|
|
32
38
|
return hostname === 'localhost' || classifyIp(hostname) === 'loopback';
|
|
33
39
|
}
|
|
34
40
|
|
|
35
|
-
function bridgeScript(channel) {
|
|
41
|
+
function bridgeScript(channel, identity) {
|
|
36
42
|
const encoded = JSON.stringify(channel);
|
|
37
|
-
return
|
|
43
|
+
return `${siteVersionNavigatorScript(identity)}
|
|
44
|
+
(() => {
|
|
38
45
|
const channel = ${encoded};
|
|
39
46
|
const hammerhead = window['%hammerhead%'];
|
|
40
47
|
const destinationUrl = (url) => {
|
|
@@ -92,7 +99,7 @@ function bridgeScript(channel) {
|
|
|
92
99
|
|
|
93
100
|
function browserSessionClass(hammerhead) {
|
|
94
101
|
return class BrowserSession extends hammerhead.Session {
|
|
95
|
-
constructor(channel) {
|
|
102
|
+
constructor(channel, identity) {
|
|
96
103
|
super([], {
|
|
97
104
|
disablePageCaching: true,
|
|
98
105
|
allowMultipleWindows: true,
|
|
@@ -101,10 +108,11 @@ function browserSessionClass(hammerhead) {
|
|
|
101
108
|
nativeAutomation: false,
|
|
102
109
|
});
|
|
103
110
|
this.channel = channel;
|
|
111
|
+
this.identity = identity;
|
|
104
112
|
}
|
|
105
113
|
|
|
106
|
-
async getPayloadScript() { return bridgeScript(this.channel); }
|
|
107
|
-
async getIframePayloadScript() { return
|
|
114
|
+
async getPayloadScript() { return bridgeScript(this.channel, this.identity); }
|
|
115
|
+
async getIframePayloadScript() { return siteVersionNavigatorScript(this.identity); }
|
|
108
116
|
getAuthCredentials() { return null; }
|
|
109
117
|
handleAttachment() {}
|
|
110
118
|
handleFileDownload() {}
|
|
@@ -148,6 +156,7 @@ function publicLease(lease) {
|
|
|
148
156
|
url: lease.url,
|
|
149
157
|
originalUrl: lease.originalUrl,
|
|
150
158
|
channel: lease.channel,
|
|
159
|
+
siteVersion: lease.siteVersion,
|
|
151
160
|
};
|
|
152
161
|
}
|
|
153
162
|
|
|
@@ -305,7 +314,9 @@ export async function createBrowserPreviewManager({
|
|
|
305
314
|
if (lease.timer != null) clearTimer(lease.timer);
|
|
306
315
|
lease.timer = setTimer(() => release(lease), leaseTtlMs);
|
|
307
316
|
};
|
|
308
|
-
const createLease = async ({
|
|
317
|
+
const createLease = async ({
|
|
318
|
+
tabId, deviceId, url, origin, channel, siteVersion, sourceUserAgent,
|
|
319
|
+
}) => {
|
|
309
320
|
if (closing) throw new Error('browser manager closing');
|
|
310
321
|
const target = normalizedTarget(url);
|
|
311
322
|
const publicOrigin = normalizedOrigin(origin);
|
|
@@ -316,7 +327,8 @@ export async function createBrowserPreviewManager({
|
|
|
316
327
|
let detachCookies = null;
|
|
317
328
|
try {
|
|
318
329
|
if (closing) throw new Error('browser manager closing');
|
|
319
|
-
|
|
330
|
+
const identity = siteVersionIdentity(siteVersion, sourceUserAgent);
|
|
331
|
+
session = new SessionClass(channel, identity);
|
|
320
332
|
session.id = `_browser-${randomId()}-${encodeURIComponent(tabId)}`;
|
|
321
333
|
const policy = targetPolicyFactory({ topLevelUrl: target, handmuxOrigin });
|
|
322
334
|
const hooks = session.requestHookEventProvider || session;
|
|
@@ -325,6 +337,7 @@ export async function createBrowserPreviewManager({
|
|
|
325
337
|
if (await rehomeNavigation(event, session)) return;
|
|
326
338
|
const result = await policy.check(event._requestInfo.url);
|
|
327
339
|
if (result.allowed) {
|
|
340
|
+
applySiteVersionHeaders(event.requestOptions?.headers, identity);
|
|
328
341
|
if (result.addresses?.length && event.requestOptions) {
|
|
329
342
|
const approved = result.addresses.map(({ address, family }) => ({ address, family }));
|
|
330
343
|
event.requestOptions.autoSelectFamily = true;
|
|
@@ -352,6 +365,8 @@ export async function createBrowserPreviewManager({
|
|
|
352
365
|
url: publicUrl,
|
|
353
366
|
publicOrigin,
|
|
354
367
|
channel,
|
|
368
|
+
siteVersion,
|
|
369
|
+
sourceUserAgent,
|
|
355
370
|
pool,
|
|
356
371
|
session,
|
|
357
372
|
policy,
|
|
@@ -367,14 +382,21 @@ export async function createBrowserPreviewManager({
|
|
|
367
382
|
}
|
|
368
383
|
};
|
|
369
384
|
|
|
370
|
-
const putImpl = async ({
|
|
385
|
+
const putImpl = async ({
|
|
386
|
+
tabId, deviceId, url, origin, siteVersion, sourceUserAgent,
|
|
387
|
+
}, requireExisting = false) => {
|
|
371
388
|
if (!deviceId || !tabId) throw new Error('browser lease identity required');
|
|
372
389
|
const key = leaseKey(deviceId, tabId);
|
|
373
390
|
const existing = leases.get(key);
|
|
374
391
|
if (requireExisting && !existing) return null;
|
|
375
392
|
const target = normalizedTarget(url);
|
|
376
393
|
const publicOrigin = normalizedOrigin(origin);
|
|
377
|
-
|
|
394
|
+
const requestedVersion = normalizeSiteVersion(siteVersion);
|
|
395
|
+
if (!requestedVersion) throw new Error('invalid browser site version');
|
|
396
|
+
if (existing
|
|
397
|
+
&& existing.originalUrl === target
|
|
398
|
+
&& existing.publicOrigin === publicOrigin
|
|
399
|
+
&& existing.siteVersion === requestedVersion) {
|
|
378
400
|
touch(existing);
|
|
379
401
|
return publicLease(existing);
|
|
380
402
|
}
|
|
@@ -384,6 +406,8 @@ export async function createBrowserPreviewManager({
|
|
|
384
406
|
url: target,
|
|
385
407
|
origin: publicOrigin,
|
|
386
408
|
channel: existing?.channel || randomChannel(),
|
|
409
|
+
siteVersion: requestedVersion,
|
|
410
|
+
sourceUserAgent,
|
|
387
411
|
});
|
|
388
412
|
leases.set(key, next);
|
|
389
413
|
touch(next);
|
|
@@ -438,6 +462,8 @@ export async function createBrowserPreviewManager({
|
|
|
438
462
|
url: target,
|
|
439
463
|
origin,
|
|
440
464
|
channel: current.channel,
|
|
465
|
+
siteVersion: current.siteVersion,
|
|
466
|
+
sourceUserAgent: current.sourceUserAgent,
|
|
441
467
|
});
|
|
442
468
|
try {
|
|
443
469
|
const bootstrapUrl = browserBootstrap.issue({
|
|
@@ -471,8 +497,8 @@ export async function createBrowserPreviewManager({
|
|
|
471
497
|
|
|
472
498
|
return {
|
|
473
499
|
putLease: (options) => put(options),
|
|
474
|
-
navigateLease(tabId, url, deviceId, origin) {
|
|
475
|
-
return put({ tabId, url, deviceId, origin }, true);
|
|
500
|
+
navigateLease(tabId, url, deviceId, origin, siteVersion, sourceUserAgent) {
|
|
501
|
+
return put({ tabId, url, deviceId, origin, siteVersion, sourceUserAgent }, true);
|
|
476
502
|
},
|
|
477
503
|
getLease(tabId, deviceId) {
|
|
478
504
|
return publicLease(leases.get(leaseKey(deviceId, tabId)));
|
|
@@ -13,6 +13,18 @@ const SERVICE_PATHS = new Set([
|
|
|
13
13
|
]);
|
|
14
14
|
const DEVICE_COOKIE = 'tw_browser_device';
|
|
15
15
|
|
|
16
|
+
function sessionChannel(pathname) {
|
|
17
|
+
const descriptor = String(pathname || '').split('/')[1] || '';
|
|
18
|
+
return descriptor.match(/\*([A-Za-z0-9_-]{1,128})$/)?.[1] || null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function sessionUnavailableHtml(pathname) {
|
|
22
|
+
const channel = JSON.stringify(sessionChannel(pathname));
|
|
23
|
+
return `<!doctype html><meta charset="utf-8">
|
|
24
|
+
<script>parent.postMessage({source:'handmux-browser',channel:${channel},type:'session-unavailable'},'*')</script>
|
|
25
|
+
<pre>{"error":"browser session unavailable"}</pre>`;
|
|
26
|
+
}
|
|
27
|
+
|
|
16
28
|
export function isBrowserServicePath(pathname) {
|
|
17
29
|
return SERVICE_PATHS.has(String(pathname || '').split('?')[0]);
|
|
18
30
|
}
|
|
@@ -65,6 +77,12 @@ function filteredCookie(raw) {
|
|
|
65
77
|
return values.join('; ');
|
|
66
78
|
}
|
|
67
79
|
|
|
80
|
+
function expectsDocument(req) {
|
|
81
|
+
const destination = String(req.headers['sec-fetch-dest'] || '').toLowerCase();
|
|
82
|
+
const accept = String(req.headers.accept || '').toLowerCase();
|
|
83
|
+
return destination === 'document' || destination === 'iframe' || accept.includes('text/html');
|
|
84
|
+
}
|
|
85
|
+
|
|
68
86
|
function upstreamHeaders(headers, port, token) {
|
|
69
87
|
const out = { ...headers, host: `127.0.0.1:${port}` };
|
|
70
88
|
if (token && out.authorization === `Bearer ${token}`) delete out.authorization;
|
|
@@ -101,7 +119,11 @@ export function createBrowserPublicProxy({
|
|
|
101
119
|
const deviceId = cookieValue(req.headers.cookie, DEVICE_COOKIE);
|
|
102
120
|
const target = browserTarget(browser, req, deviceId);
|
|
103
121
|
if (!target) {
|
|
104
|
-
if (claimedBrowserRequest(req))
|
|
122
|
+
if (claimedBrowserRequest(req)) {
|
|
123
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
124
|
+
if (expectsDocument(req)) return res.status(403).type('html').send(sessionUnavailableHtml(pathname));
|
|
125
|
+
return res.status(403).json({ error: 'browser session unavailable' });
|
|
126
|
+
}
|
|
105
127
|
return next();
|
|
106
128
|
}
|
|
107
129
|
const { port } = target;
|
package/src/browser/routes.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import express from 'express';
|
|
2
2
|
import { browserLabelForOrigin } from './originLabel.js';
|
|
3
3
|
import { browserRequestOrigin } from './publicProxy.js';
|
|
4
|
+
import { normalizeSiteVersion } from './siteVersion.js';
|
|
4
5
|
|
|
5
6
|
const RETENTION_DAYS = new Set([1, 7, 30, null]);
|
|
6
7
|
const DEVICE_ID = /^[A-Za-z0-9_-]{32,128}$/;
|
|
@@ -71,6 +72,8 @@ export function browserRoutes({
|
|
|
71
72
|
if (!TAB_ID.test(req.params.tabId)) return res.status(400).json({ error: 'bad browser tab id' });
|
|
72
73
|
const url = normalizedTarget(req.body?.url);
|
|
73
74
|
if (!url) return res.status(400).json({ error: 'browser URL must use http or https' });
|
|
75
|
+
const siteVersion = normalizeSiteVersion(req.body?.siteVersion);
|
|
76
|
+
if (!siteVersion) return res.status(400).json({ error: 'bad browser site version' });
|
|
74
77
|
try {
|
|
75
78
|
const origin = wildcardOrigin(publicBase, new URL(url).origin);
|
|
76
79
|
const lease = await browser.putLease({
|
|
@@ -78,6 +81,8 @@ export function browserRoutes({
|
|
|
78
81
|
url: req.body.url,
|
|
79
82
|
origin,
|
|
80
83
|
deviceId: req.browserDeviceId,
|
|
84
|
+
siteVersion,
|
|
85
|
+
sourceUserAgent: req.get('user-agent') || '',
|
|
81
86
|
});
|
|
82
87
|
return res.json(responseLease(lease, req.browserDeviceId));
|
|
83
88
|
} catch (error) {
|
|
@@ -90,6 +95,8 @@ export function browserRoutes({
|
|
|
90
95
|
if (!TAB_ID.test(req.params.tabId)) return res.status(400).json({ error: 'bad browser tab id' });
|
|
91
96
|
const url = normalizedTarget(req.body?.url);
|
|
92
97
|
if (!url) return res.status(400).json({ error: 'browser URL must use http or https' });
|
|
98
|
+
const siteVersion = normalizeSiteVersion(req.body?.siteVersion);
|
|
99
|
+
if (!siteVersion) return res.status(400).json({ error: 'bad browser site version' });
|
|
93
100
|
try {
|
|
94
101
|
const origin = wildcardOrigin(publicBase, new URL(url).origin);
|
|
95
102
|
const lease = await browser.navigateLease(
|
|
@@ -97,6 +104,8 @@ export function browserRoutes({
|
|
|
97
104
|
req.body.url,
|
|
98
105
|
req.browserDeviceId,
|
|
99
106
|
origin,
|
|
107
|
+
siteVersion,
|
|
108
|
+
req.get('user-agent') || '',
|
|
100
109
|
);
|
|
101
110
|
if (!lease) return res.status(404).json({ error: 'browser proxy lease not found' });
|
|
102
111
|
return res.json(responseLease(lease, req.browserDeviceId));
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
export const DEFAULT_SITE_VERSION = 'mobile';
|
|
2
|
+
export const SITE_VERSIONS = new Set(['mobile', 'desktop']);
|
|
3
|
+
|
|
4
|
+
export function normalizeSiteVersion(value, fallback = DEFAULT_SITE_VERSION) {
|
|
5
|
+
if (value == null || value === '') return fallback;
|
|
6
|
+
return SITE_VERSIONS.has(value) ? value : null;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function versionFrom(ua, pattern, fallback = '120.0.0.0') {
|
|
10
|
+
return ua.match(pattern)?.[1] || fallback;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function identityPlatform(ua, mobile) {
|
|
14
|
+
if (/iPhone|iPad|iPod/i.test(ua)) return { navigator: 'iPhone', clientHint: 'iOS' };
|
|
15
|
+
if (/Android/i.test(ua)) return { navigator: 'Linux armv8l', clientHint: 'Android' };
|
|
16
|
+
if (/Windows/i.test(ua)) return { navigator: 'Win32', clientHint: 'Windows' };
|
|
17
|
+
if (/Macintosh|Mac OS X/i.test(ua)) return { navigator: 'MacIntel', clientHint: 'macOS' };
|
|
18
|
+
return mobile
|
|
19
|
+
? { navigator: 'Linux armv8l', clientHint: 'Android' }
|
|
20
|
+
: { navigator: 'Linux x86_64', clientHint: 'Linux' };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function isMobileUserAgent(ua) {
|
|
24
|
+
return /Android|Mobile|iPhone|iPad|iPod/i.test(ua);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function transformedUserAgent(source, mobile) {
|
|
28
|
+
const ua = String(source || '');
|
|
29
|
+
if (ua && isMobileUserAgent(ua) === mobile) return ua;
|
|
30
|
+
|
|
31
|
+
const webkit = versionFrom(ua, /AppleWebKit\/([\d.]+)/i, '605.1.15');
|
|
32
|
+
const safari = versionFrom(ua, /Version\/([\d.]+)/i, '18.0');
|
|
33
|
+
if (/Safari\//i.test(ua) && !/(?:Chrome|CriOS|Chromium|Edg|OPR)\//i.test(ua)) {
|
|
34
|
+
return mobile
|
|
35
|
+
? `Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/${webkit} (KHTML, like Gecko) Version/${safari} Mobile/15E148 Safari/${webkit}`
|
|
36
|
+
: `Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/${webkit} (KHTML, like Gecko) Version/${safari} Safari/${webkit}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const firefox = ua.match(/Firefox\/([\d.]+)/i)?.[1];
|
|
40
|
+
if (firefox) {
|
|
41
|
+
return mobile
|
|
42
|
+
? `Mozilla/5.0 (Android 10; Mobile; rv:${firefox}) Gecko/${firefox} Firefox/${firefox}`
|
|
43
|
+
: `Mozilla/5.0 (X11; Linux x86_64; rv:${firefox}) Gecko/20100101 Firefox/${firefox}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const chrome = versionFrom(ua, /(?:Chrome|CriOS|Chromium|Edg|OPR)\/([\d.]+)/i);
|
|
47
|
+
return mobile
|
|
48
|
+
? `Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome} Mobile Safari/537.36`
|
|
49
|
+
: `Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/${chrome} Safari/537.36`;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function siteVersionIdentity(siteVersion, sourceUserAgent = '') {
|
|
53
|
+
const version = normalizeSiteVersion(siteVersion);
|
|
54
|
+
if (!version) throw new Error('invalid browser site version');
|
|
55
|
+
const mobile = version === 'mobile';
|
|
56
|
+
const userAgent = transformedUserAgent(sourceUserAgent, mobile);
|
|
57
|
+
const platform = identityPlatform(userAgent, mobile);
|
|
58
|
+
return {
|
|
59
|
+
version,
|
|
60
|
+
mobile,
|
|
61
|
+
userAgent,
|
|
62
|
+
appVersion: userAgent.replace(/^Mozilla\//, ''),
|
|
63
|
+
platform: platform.navigator,
|
|
64
|
+
clientHintPlatform: platform.clientHint,
|
|
65
|
+
maxTouchPoints: mobile ? 5 : 0,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function headerKey(headers, name) {
|
|
70
|
+
const lower = name.toLowerCase();
|
|
71
|
+
return Object.keys(headers || {}).find((key) => key.toLowerCase() === lower) || lower;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function setHeader(headers, name, value) {
|
|
75
|
+
headers[headerKey(headers, name)] = value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function deleteHeader(headers, name) {
|
|
79
|
+
const key = Object.keys(headers || {}).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
|
|
80
|
+
if (key) delete headers[key];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function applySiteVersionHeaders(headers, identity) {
|
|
84
|
+
if (!headers || !identity) return headers;
|
|
85
|
+
setHeader(headers, 'user-agent', identity.userAgent);
|
|
86
|
+
setHeader(headers, 'sec-ch-ua-mobile', identity.mobile ? '?1' : '?0');
|
|
87
|
+
setHeader(headers, 'sec-ch-ua-platform', `"${identity.clientHintPlatform}"`);
|
|
88
|
+
for (const name of [
|
|
89
|
+
'sec-ch-ua-platform-version', 'sec-ch-ua-arch', 'sec-ch-ua-bitness',
|
|
90
|
+
'sec-ch-ua-model', 'sec-ch-ua-wow64', 'sec-ch-ua-full-version',
|
|
91
|
+
'sec-ch-ua-full-version-list',
|
|
92
|
+
]) deleteHeader(headers, name);
|
|
93
|
+
return headers;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function siteVersionNavigatorScript(identity) {
|
|
97
|
+
const encoded = JSON.stringify(identity).replaceAll('<', '\\u003c');
|
|
98
|
+
return `(() => {
|
|
99
|
+
const profile = ${encoded};
|
|
100
|
+
const define = (name, value) => {
|
|
101
|
+
try { Object.defineProperty(navigator, name, { configurable: true, get: () => value }); }
|
|
102
|
+
catch { /* the native value remains available */ }
|
|
103
|
+
};
|
|
104
|
+
define('userAgent', profile.userAgent);
|
|
105
|
+
define('appVersion', profile.appVersion);
|
|
106
|
+
define('platform', profile.platform);
|
|
107
|
+
define('maxTouchPoints', profile.maxTouchPoints);
|
|
108
|
+
const nativeData = navigator.userAgentData;
|
|
109
|
+
if (nativeData) {
|
|
110
|
+
const data = {
|
|
111
|
+
brands: nativeData.brands || [],
|
|
112
|
+
mobile: profile.mobile,
|
|
113
|
+
platform: profile.clientHintPlatform,
|
|
114
|
+
toJSON() { return { brands: this.brands, mobile: this.mobile, platform: this.platform }; },
|
|
115
|
+
async getHighEntropyValues(hints) {
|
|
116
|
+
let values = {};
|
|
117
|
+
try { values = await nativeData.getHighEntropyValues.call(nativeData, hints); } catch { /* optional API */ }
|
|
118
|
+
return { ...values, mobile: profile.mobile, platform: profile.clientHintPlatform };
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
define('userAgentData', data);
|
|
122
|
+
}
|
|
123
|
+
})();`;
|
|
124
|
+
}
|