camofox-browser 2.4.1 → 2.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [2.4.4] - 2026-05-23
6
+
7
+ ### Fixed
8
+ - First tab creation now reuses the browser engine's initial untracked `about:blank` page when safe, preventing headed and virtual-display sessions from opening an extra empty window beside the requested page.
9
+
10
+ ### Security
11
+ - Refreshed Express/body-parser/qs and CI reporter dependency locks so `npm audit --audit-level=moderate` reports zero vulnerabilities.
12
+
13
+ ### Tests
14
+ - Added unit and real browser regression coverage for the first-tab initial blank-page reuse path.
15
+
16
+ ## [2.4.3] - 2026-05-13
17
+
18
+ ### Fixed
19
+ - Session-level `proxyProfile` and raw `proxy` settings now reach the browser context launch path, so proxy egress intent is applied instead of only being validated/stored.
20
+ - Session-profile contexts now use delimiter-safe runtime keys derived from `userId + sessionKey + profile signature` and profile-keyed persistent directories, preventing sibling proxy profiles for the same user from sharing one browser context or `userDataDir`.
21
+ - Session/user ownership checks no longer use raw `userId::sessionKey` prefix matching, so `userId` or `sessionKey` values containing `::` cannot collide with another user's sessions, tab index, or cleanup path.
22
+ - First-create rollback now closes staged profile-keyed contexts by user/generation and always releases the canonical mutex, so a failed proxy-profile first tab cannot wedge future retries.
23
+ - Rejected core/OpenClaw requests no longer persist provisional session proxy profiles or leave allocated profile-key sessions/contexts behind after runtime allocation failures.
24
+ - Concurrent core/OpenClaw requests for the same new session profile now wait for the profile-create attempt to commit or rollback, so a failed creator cannot delete a sibling request that already returned success.
25
+ - Idle lifecycle cleanup now closes and removes only the exact zero-tab profile-key session, preserving active sibling profile sessions for the same user.
26
+ - Display-mode toggles now prewarm the existing single profile-key context for VNC with its profile launch settings while avoiding stale default-context prelaunches before first tab create.
27
+ - Cookie import now rejects ambiguous user-level requests when multiple active browser contexts exist, requiring `tabId` targeting instead of importing into an arbitrary sibling context.
28
+ - Eviction, timeout, and shutdown cleanup now resolve encoded session/profile keys back to their raw owner user IDs for trace/download/VNC cleanup.
29
+ - Internal session/profile/trace ownership tokens now preserve UTF-16 code-unit identity, so malformed Unicode user/session IDs cannot collapse into replacement-character aliases or cross profile/trace ownership boundaries.
30
+ - Legacy UTF-8 trace artifact lookup now accepts only collision-free owner tokens, so a crafted user ID cannot use a legacy token that is also another user's UTF-16LE artifact token.
31
+ - Explicit session close now treats `userId` as an external owner ID only, so raw internal `u:`, `o:`, or `p:` session/profile keys cannot close another user's runtime state through `/sessions/:userId`.
32
+ - Default profile directory compatibility now applies only to well-formed non-internal user IDs; raw IDs that look like internal `u:`, `s:`, `p:`, or `o:` keys, or contain malformed UTF-16, remain isolated under encoded profile-key directories.
33
+
34
+ ## [2.4.2] - 2026-05-13
35
+
36
+ ### Fixed
37
+ - `proxyProfile` now takes precedence over raw `proxy` when both are supplied for session proxy/geo resolution, matching the documented/tested contract for `/tabs` and `/tabs/open`.
38
+ - Refreshed runtime and dev dependency lockfile entries so full `npm audit` reports zero vulnerabilities.
39
+
5
40
  ## Release Audit: v2.3.0 -> v2.4.1
6
41
 
7
42
  ### What shipped in this line
package/README.md CHANGED
@@ -393,7 +393,7 @@ This works with **Claude Code**, **Codex**, **Cursor**, **Gemini CLI**, **GitHub
393
393
  | Skill | Focus | Best For |
394
394
  |-------|-------|----------|
395
395
  | `camofox-browser` | Full coverage (CLI + API + OpenClaw) | Complete reference |
396
- | `camofox-cli` | CLI-only (50 commands) | Terminal-first workflows |
396
+ | `camofox-cli` | CLI-only (50+ commands) | Terminal-first workflows |
397
397
  | `dogfood` | QA testing workflow | Systematic web app testing |
398
398
  | `gemini-image` | Gemini image generation | AI image automation |
399
399
  | `reddit` | Reddit automation | Reddit posting/commenting |
@@ -849,11 +849,13 @@ Then use profiles by name in API requests or CLI commands.
849
849
  | `CAMOFOX_MAX_BATCH_CONCURRENCY` | `5` | Batch download concurrency cap |
850
850
  | `CAMOFOX_MAX_BLOB_SIZE_MB` | `5` | Max blob payload size |
851
851
  | `CAMOFOX_MAX_DOWNLOADS_PER_USER` | `500` | Per-user download record cap |
852
+ | `CAMOFOX_CONSOLE_BUFFER_SIZE` | `1000` | Per-tab console/error message buffer size (minimum `100`) |
852
853
  | `HANDLER_TIMEOUT_MS` | `30000` | Handler timeout fallback |
853
854
  | `MAX_CONCURRENT_PER_USER` | `3` | Concurrent operations per user |
854
855
  | `CAMOFOX_VNC_BASE_PORT` | `6080` | noVNC/websockify base port |
855
856
  | `CAMOFOX_VNC_HOST` | `localhost` | noVNC host in returned URL |
856
857
  | `CAMOFOX_CLI_USER` | `cli-default` | Default CLI user id |
858
+ | `CAMOFOX_SERVER_PID_FILE` | (unset) | Optional daemon PID file path used by the CLI server manager |
857
859
  | `CAMOFOX_IDLE_TIMEOUT_MS` | `1800000` | Stage 1 idle cleanup threshold (ms) |
858
860
  | `CAMOFOX_IDLE_EXIT_TIMEOUT_MS` | `1800000` | Stage 2 daemon exit quiet window (ms, defaults to match Stage 1) |
859
861
  | `CAMOFOX_PRESETS_FILE` | (unset) | Optional JSON file defining/overriding geo presets |
@@ -866,9 +868,12 @@ Then use profiles by name in API requests or CLI commands.
866
868
  | `PROXY_USERNAME` | (empty) | Proxy username (server-level default) |
867
869
  | `PROXY_PASSWORD` | (empty) | Proxy password (server-level default) |
868
870
  | `CAMOFOX_MAX_SNAPSHOT_CHARS` | `80000` | Max characters in snapshot before truncation |
871
+ | `CAMOFOX_MAX_SNAPSHOT_NODES` | `2000` | Max accessibility snapshot nodes before truncation |
869
872
  | `CAMOFOX_SNAPSHOT_TAIL_CHARS` | `5000` | Characters preserved at end of truncated snapshot |
870
873
  | `CAMOFOX_BUILDREFS_TIMEOUT_MS` | `12000` | Timeout for building element refs |
871
874
  | `CAMOFOX_TAB_LOCK_TIMEOUT_MS` | `30000` | Timeout for acquiring tab lock |
875
+ | `CAMOFOX_TRACES_DIR` | `~/.camofox/traces` | Managed Playwright trace artifact directory |
876
+ | `CAMOFOX_TRACE_MAX_DURATION_MS` | `300000` | Maximum trace recording duration before automatic stop |
872
877
  | `CAMOFOX_HEALTH_PROBE_INTERVAL_MS` | `60000` | Health probe check interval |
873
878
  | `CAMOFOX_FAILURE_THRESHOLD` | `3` | Consecutive failures before health degradation |
874
879
  | `CAMOFOX_YT_DLP_TIMEOUT_MS` | `30000` | Timeout for yt-dlp subtitle extraction |
@@ -1 +1 @@
1
- {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../../../src/routes/core.ts"],"names":[],"mappings":"AAwGA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAktDxB,eAAe,MAAM,CAAC"}
1
+ {"version":3,"file":"core.d.ts","sourceRoot":"","sources":["../../../src/routes/core.ts"],"names":[],"mappings":"AAmHA,QAAA,MAAM,MAAM,4CAAW,CAAC;AAk0DxB,eAAe,MAAM,CAAC"}
@@ -186,6 +186,16 @@ router.post('/sessions/:userId/cookies', express_1.default.json({ limit: '512kb'
186
186
  message: 'Cannot import cookies without an active session. Create a tab via POST /tabs first.',
187
187
  });
188
188
  }
189
+ if (existingSessions.length > 1) {
190
+ (0, logging_1.log)('warn', 'cookie import rejected: ambiguous active sessions', {
191
+ userId: String(userId),
192
+ sessionCount: existingSessions.length,
193
+ });
194
+ return res.status(409).json({
195
+ error: 'Ambiguous active sessions',
196
+ message: 'Multiple active browser contexts exist for this user. Provide tabId to import cookies into a specific context.',
197
+ });
198
+ }
189
199
  session = existingSessions[0][1];
190
200
  }
191
201
  await session.context.addCookies(sanitized);
@@ -272,6 +282,11 @@ router.get('/presets', (_req, res) => {
272
282
  // Create new tab
273
283
  router.post('/tabs', async (req, res) => {
274
284
  let createUserId;
285
+ let createSessionKey;
286
+ let createdSessionProfile = false;
287
+ let createdSessionProfileSignature;
288
+ let createdDefaultSessionProfileClaim = false;
289
+ let releaseSessionProfileCreate;
275
290
  let isFirstCreator = false;
276
291
  let stagedGeneration;
277
292
  try {
@@ -287,10 +302,11 @@ router.post('/tabs', async (req, res) => {
287
302
  return res.status(400).json({ error: 'userId and sessionKey required' });
288
303
  }
289
304
  createUserId = String(userId);
305
+ createSessionKey = String(resolvedSessionKey);
290
306
  // Check for session profile conflict (proxy/geo drift)
307
+ let resolvedSessionProfile;
291
308
  if (proxy || proxyProfile || geoMode) {
292
309
  const { resolveSessionProfileInput, getConfiguredServerProxy, loadProxyProfiles } = await Promise.resolve().then(() => __importStar(require('../utils/proxy-profiles')));
293
- const { establishSessionProfile } = await Promise.resolve().then(() => __importStar(require('../services/session')));
294
310
  const profileInput = {
295
311
  preset,
296
312
  locale,
@@ -307,14 +323,10 @@ router.post('/tabs', async (req, res) => {
307
323
  };
308
324
  try {
309
325
  const resolvedProfileBase = resolveSessionProfileInput(profileInput, deps);
310
- const resolvedProfile = { ...resolvedProfileBase, sessionKey: resolvedSessionKey };
311
- establishSessionProfile(userId, resolvedSessionKey, resolvedProfile);
326
+ resolvedSessionProfile = { ...resolvedProfileBase, sessionKey: resolvedSessionKey };
312
327
  }
313
328
  catch (err) {
314
329
  const message = err instanceof Error ? err.message : String(err);
315
- if (message === 'Session profile conflict') {
316
- return res.status(409).json({ error: 'Session profile conflict' });
317
- }
318
330
  return res.status(400).json({ error: message });
319
331
  }
320
332
  }
@@ -370,14 +382,58 @@ router.post('/tabs', async (req, res) => {
370
382
  (0, logging_1.log)('error', 'canonical profile acquisition failed after retries', { userId: String(userId) });
371
383
  return res.status(503).json({ error: 'Could not acquire canonical profile, try again' });
372
384
  }
373
- const sessionMapKey = (0, session_1.getSessionMapKey)(userId, contextOverrides);
385
+ if (resolvedSessionProfile) {
386
+ while (true) {
387
+ await (0, session_1.waitForSessionProfileCreate)(userId, resolvedSessionKey);
388
+ const existingProfile = (0, session_1.getEstablishedSessionProfile)(userId, resolvedSessionKey);
389
+ if (existingProfile) {
390
+ if (existingProfile.signature !== resolvedSessionProfile.signature) {
391
+ return res.status(409).json({ error: 'Session profile conflict' });
392
+ }
393
+ break;
394
+ }
395
+ if ((0, session_1.hasDefaultSessionProfileRuntime)(userId, resolvedSessionKey)) {
396
+ return res.status(409).json({ error: 'Session profile conflict' });
397
+ }
398
+ const mutex = (0, session_1.acquireSessionProfileCreateMutex)(userId, resolvedSessionKey, resolvedSessionProfile.signature);
399
+ if (mutex.acquired) {
400
+ releaseSessionProfileCreate = mutex.release;
401
+ try {
402
+ (0, session_1.establishSessionProfile)(userId, resolvedSessionKey, resolvedSessionProfile);
403
+ createdSessionProfile = true;
404
+ createdSessionProfileSignature = resolvedSessionProfile.signature;
405
+ }
406
+ catch (err) {
407
+ releaseSessionProfileCreate(false);
408
+ releaseSessionProfileCreate = undefined;
409
+ const message = err instanceof Error ? err.message : String(err);
410
+ if (message === 'Session profile conflict') {
411
+ return res.status(409).json({ error: 'Session profile conflict' });
412
+ }
413
+ return res.status(400).json({ error: message });
414
+ }
415
+ break;
416
+ }
417
+ await mutex.wait;
418
+ }
419
+ }
420
+ else {
421
+ await (0, session_1.waitForSessionProfileCreate)(userId, resolvedSessionKey);
422
+ if (!(0, session_1.getEstablishedSessionProfile)(userId, resolvedSessionKey)) {
423
+ createdDefaultSessionProfileClaim = (0, session_1.claimDefaultSessionProfileRuntime)(userId, resolvedSessionKey);
424
+ }
425
+ }
426
+ const establishedProfile = (0, session_1.getEstablishedSessionProfile)(userId, resolvedSessionKey);
427
+ const sessionMapKey = establishedProfile
428
+ ? (0, session_1.getSessionMapKey)(userId, resolvedSessionKey, establishedProfile.signature)
429
+ : (0, session_1.getSessionMapKey)(userId, contextOverrides);
374
430
  let tabId;
375
431
  let pageUrl;
376
432
  if (isFirstCreator) {
377
- const staged = await (0, session_1.createStagedSession)(userId, contextOverrides);
433
+ const staged = await (0, session_1.createStagedSession)(userId, contextOverrides, resolvedSessionKey);
378
434
  stagedGeneration = staged.generation;
379
435
  const { session, generation } = staged;
380
- const page = await session.context.newPage();
436
+ const page = await (0, tab_1.acquirePageForNewTab)(session.context);
381
437
  tabId = node_crypto_1.default.randomUUID();
382
438
  page.__camofox_tabId = tabId;
383
439
  const tabState = await (0, tab_1.createTabState)(page);
@@ -399,19 +455,37 @@ router.post('/tabs', async (req, res) => {
399
455
  }, generation);
400
456
  if (!committed) {
401
457
  await (0, session_1.rollbackStagedFirstUse)(createUserId ?? userId, generation).catch(() => { });
458
+ if (createdSessionProfile && createdSessionProfileSignature) {
459
+ await (0, session_1.rollbackSessionProfileRuntime)(userId, resolvedSessionKey, createdSessionProfileSignature);
460
+ }
461
+ if (createdDefaultSessionProfileClaim) {
462
+ (0, session_1.clearDefaultSessionProfileClaim)(userId, resolvedSessionKey);
463
+ createdDefaultSessionProfileClaim = false;
464
+ }
465
+ releaseSessionProfileCreate?.(false);
466
+ releaseSessionProfileCreate = undefined;
402
467
  return res.status(409).json({ error: 'Session closed during creation' });
403
468
  }
404
469
  (0, download_1.commitStagedDownloads)(tabId);
405
470
  pageUrl = page.url();
406
471
  }
407
472
  else {
408
- const session = await (0, session_1.getSession)(userId, contextOverrides);
473
+ const session = await (0, session_1.getSession)(userId, contextOverrides, resolvedSessionKey);
409
474
  const totalTabs = (0, session_1.countTotalTabsForSessions)([[sessionMapKey, session]]);
410
475
  if (totalTabs >= session_1.MAX_TABS_PER_SESSION) {
476
+ if (createdSessionProfile && createdSessionProfileSignature) {
477
+ await (0, session_1.rollbackSessionProfileRuntime)(userId, resolvedSessionKey, createdSessionProfileSignature);
478
+ }
479
+ if (createdDefaultSessionProfileClaim) {
480
+ (0, session_1.clearDefaultSessionProfileClaim)(userId, resolvedSessionKey);
481
+ createdDefaultSessionProfileClaim = false;
482
+ }
483
+ releaseSessionProfileCreate?.(false);
484
+ releaseSessionProfileCreate = undefined;
411
485
  return res.status(429).json({ error: 'Maximum tabs per session reached' });
412
486
  }
413
487
  const group = (0, session_1.getTabGroup)(session, resolvedSessionKey);
414
- const page = await session.context.newPage();
488
+ const page = await (0, tab_1.acquirePageForNewTab)(session.context);
415
489
  tabId = node_crypto_1.default.randomUUID();
416
490
  page.__camofox_tabId = tabId;
417
491
  const tabState = await (0, tab_1.createTabState)(page);
@@ -436,6 +510,9 @@ router.post('/tabs', async (req, res) => {
436
510
  url: pageUrl,
437
511
  });
438
512
  lifecycle_controller_1.lifecycleController.recordInteractiveActivity();
513
+ releaseSessionProfileCreate?.(true);
514
+ releaseSessionProfileCreate = undefined;
515
+ createdSessionProfile = false;
439
516
  return res.json({ tabId, url: pageUrl });
440
517
  }
441
518
  catch (err) {
@@ -447,6 +524,19 @@ router.post('/tabs', async (req, res) => {
447
524
  (0, session_1.rollbackCanonicalMutex)(createUserId);
448
525
  }
449
526
  }
527
+ if (createdSessionProfile && createUserId && createSessionKey) {
528
+ if (createdSessionProfileSignature) {
529
+ await (0, session_1.rollbackSessionProfileRuntime)(createUserId, createSessionKey, createdSessionProfileSignature);
530
+ }
531
+ else {
532
+ (0, session_1.clearSessionProfile)(createUserId, createSessionKey);
533
+ }
534
+ }
535
+ if (createdDefaultSessionProfileClaim && createUserId && createSessionKey) {
536
+ (0, session_1.clearDefaultSessionProfileClaim)(createUserId, createSessionKey);
537
+ }
538
+ releaseSessionProfileCreate?.(false);
539
+ releaseSessionProfileCreate = undefined;
450
540
  const message = err instanceof Error ? err.message : String(err);
451
541
  (0, logging_1.log)('error', 'tab create failed', { reqId: req.reqId, error: message });
452
542
  return res.status(getRouteErrorStatus(err)).json({ error: (0, errors_1.safeError)(err) });
@@ -1079,14 +1169,28 @@ router.post('/sessions/:userId/toggle-display', async (req, res) => {
1079
1169
  error: 'headless must be a boolean or "virtual"',
1080
1170
  });
1081
1171
  }
1082
- // Existing tabs become invalid after context restart.
1083
- await (0, session_1.closeSessionsForUser)(userId);
1084
- await context_pool_1.contextPool.restartContext(userId, headless);
1172
+ const existingSessions = (0, session_1.getSessionsForUser)(userId);
1173
+ const prewarmProfileKey = existingSessions.length === 1 ? existingSessions[0][0] : undefined;
1174
+ const prewarmEntry = prewarmProfileKey ? context_pool_1.contextPool.getEntry(prewarmProfileKey) : undefined;
1175
+ const prewarmLaunchSettings = prewarmProfileKey && !prewarmEntry ? (0, session_1.getSessionProfileLaunchSettings)(userId, prewarmProfileKey) : undefined;
1176
+ const prewarmOptions = prewarmEntry ? prewarmEntry.seedOptions : prewarmLaunchSettings?.contextOverrides ?? undefined;
1177
+ const prewarmProxy = prewarmEntry ? prewarmEntry.proxyConfig : prewarmLaunchSettings?.proxy ?? null;
1178
+ let tabsInvalidated = false;
1085
1179
  let vncUrl;
1086
1180
  if (headless === true) {
1181
+ if (prewarmProfileKey) {
1182
+ // Existing tabs become invalid after context restart/close.
1183
+ await (0, session_1.closeSessionsForUser)(userId, { clearProfiles: false });
1184
+ tabsInvalidated = true;
1185
+ }
1186
+ context_pool_1.contextPool.setHeadlessOverride(userId, headless);
1087
1187
  await (0, vnc_1.stopVnc)(userId).catch(() => { });
1088
1188
  }
1089
- else {
1189
+ else if (prewarmProfileKey) {
1190
+ // Existing tabs become invalid after context restart.
1191
+ await (0, session_1.closeSessionsForUser)(userId, { clearProfiles: false });
1192
+ tabsInvalidated = true;
1193
+ await context_pool_1.contextPool.restartContext(userId, headless, prewarmProfileKey, prewarmOptions, prewarmProxy);
1090
1194
  const displayNum = (0, context_pool_1.getDisplayForUser)(userId);
1091
1195
  if (displayNum) {
1092
1196
  try {
@@ -1099,13 +1203,21 @@ router.post('/sessions/:userId/toggle-display', async (req, res) => {
1099
1203
  }
1100
1204
  }
1101
1205
  }
1206
+ else {
1207
+ context_pool_1.contextPool.setHeadlessOverride(userId, headless);
1208
+ }
1102
1209
  const modeLabel = headless === false ? 'headed mode' : headless === 'virtual' ? 'virtual display mode' : 'headless mode';
1103
1210
  return res.json({
1104
1211
  ok: true,
1105
1212
  headless,
1213
+ tabsInvalidated,
1106
1214
  ...(vncUrl
1107
1215
  ? { vncUrl, message: 'Browser visible via VNC' }
1108
- : { message: `Browser restarted in ${modeLabel}. Previous tabs invalidated — create new tabs.` }),
1216
+ : {
1217
+ message: tabsInvalidated
1218
+ ? `Display mode updated to ${modeLabel}. Previous tabs invalidated — create new tabs.`
1219
+ : `Display mode override saved as ${modeLabel}. Existing tabs preserved; new contexts use the requested mode.`,
1220
+ }),
1109
1221
  userId,
1110
1222
  });
1111
1223
  }