Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ jobs:
npm install --no-save jest-junit
node --experimental-vm-modules node_modules/.bin/jest \
--testPathPatterns='tests/unit|plugins' \
--testPathIgnorePatterns='security\.test|tabRecycling\.test|cookies\.test' \
--testPathIgnorePatterns='security\.test|tabRecycling\.test|cookies\.test|requestProxyApi\.test' \
--forceExit
env:
CI: true
Expand Down Expand Up @@ -92,7 +92,7 @@ jobs:
run: |
xvfb-run --auto-servernum \
node --experimental-vm-modules node_modules/.bin/jest \
--testPathPatterns='tests/unit/(security|tabRecycling|cookies)\.test' \
--testPathPatterns='tests/unit/(security|tabRecycling|cookies|requestProxyApi)\.test' \
--runInBand --forceExit
env:
CI: true
4 changes: 2 additions & 2 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ jobs:
npm install --no-save jest-junit
node --experimental-vm-modules node_modules/.bin/jest \
--testPathPatterns='tests/unit|plugins' \
--testPathIgnorePatterns='security\.test|tabRecycling\.test|cookies\.test' \
--testPathIgnorePatterns='security\.test|tabRecycling\.test|cookies\.test|requestProxyApi\.test' \
--forceExit
env:
CI: true
Expand Down Expand Up @@ -68,7 +68,7 @@ jobs:
run: |
xvfb-run --auto-servernum \
node --experimental-vm-modules node_modules/.bin/jest \
--testPathPatterns='tests/unit/(security|tabRecycling|cookies)\.test' \
--testPathPatterns='tests/unit/(security|tabRecycling|cookies|requestProxyApi)\.test' \
--runInBand --forceExit
env:
CI: true
Expand Down
6 changes: 4 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,13 +303,13 @@ export function register(app, ctx) {

| Property | Type | Description |
|----------|------|-------------|
| `sessions` | `Map` | Live sessions: `userId -> { context, tabGroups, lastAccess }` |
| `sessions` | `Map` | Live sessions: `userId -> { context, tabGroups, pageLeases, lastAccess, proxySessionId, requestProxy, tracePath }` |
| `config` | `object` | Server CONFIG (port, apiKey, nodeEnv, proxy, etc.) |
| `log` | `function` | `log(level, msg, fields)` -- structured JSON logging |
| `events` | `EventEmitter` | Plugin event bus (29 events -- see below) |
| `auth` | `function` | `auth()` returns Express middleware enforcing API key / loopback |
| `ensureBrowser` | `async function` | Launch browser if not running, return browser instance |
| `getSession` | `async function` | `getSession(userId)` -- get or create a session |
| `getSession` | `async function` | `getSession(userId, { trace, requestProxy, inheritedRequestProxy })` -- get or create a session; request proxies are immutable per user session |
| `destroySession` | `async function` | `destroySession(userId, { reason })` -- tear down and await a session close |
| `withUserLimit` | `async function` | `withUserLimit(userId, fn)` -- run `fn` within per-user concurrency limit |
| `safePageClose` | `async function` | `safePageClose(page)` -- close a page with timeout guard |
Expand All @@ -322,6 +322,8 @@ export function register(app, ctx) {
| `createMetric` | `async function` | Create a Prometheus metric registered to the shared registry (see below) |
| `metricsRegistry` | `function` | `metricsRegistry()` -- raw prom-client Registry or null |

`session.requestProxy` is either a normalized Playwright proxy object or `null`. It may contain credentials. Automatic teardown can retain the same normalized object in the bounded, expiring recovery cache after the session closes. Treat both copies as sensitive: compare them with the request-proxy helpers, but do not add them to logs or HTTP responses. `POST /tabs` may expose only the boolean `proxied` state. The existing `session:creating` hook exposes `contextOptions` to trusted in-process plugins, including proxy credentials. `inheritedRequestProxy` is reserved for trusted internal recovery paths; HTTP handlers must pass untrusted input as `requestProxy` so validation and conflict handling run.

### Events (29)

28 emitted by core, 1 (`session:storage:export`) emitted by plugins.
Expand Down
32 changes: 31 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -376,12 +376,42 @@ docker run -p 9377:9377 \
camofox-browser
```

When a proxy is configured:
When a global proxy is configured:
- All traffic routes through the proxy
- Camoufox's GeoIP automatically sets `locale`, `timezone`, and `geolocation` to match the proxy's exit IP
- Browser fingerprint (language, timezone, coordinates) is consistent with the proxy location
- Without a proxy, defaults to `en-US`, `America/Los_Angeles`, San Francisco coordinates

**Request-level proxy (per user session):**

When the server has no global `PROXY_*` configuration, `POST /tabs` can select a proxy for the new user's BrowserContext:

```bash
curl -X POST http://localhost:9377/tabs \
-H 'Content-Type: application/json' \
-d '{
"userId": "research-user",
"sessionKey": "job-1",
"proxy": {
"server": "http://gw.example.com:10000",
"username": "myuser",
"password": "mypass"
}
}'
```

The request proxy is a session-level choice, not a tab-level switch:

- The first session created for a `userId` fixes its proxy. All tab groups for that user share it.
- Repeating the same proxy is allowed. Supplying a different proxy returns `409 proxy_conflict` with `recovery: delete_session`.
- Omitting `proxy` reuses the live session's choice. Dead-context, browser-disconnect/restart, navigation-timeout, new-page, idle-expiry, memory-pressure, and tab-reaper recovery preserve it for at least one minute beyond the longer configured session/tab idle period.
- `POST /tabs` returns `proxied: true` when the user's BrowserContext uses a request-level proxy, and `false` otherwise. It never returns proxy credentials.
- `DELETE /sessions/:userId` intentionally clears both the session and any pending proxy-recovery state.
- Cookie import does not accept a proxy. It reuses the current or recently recovered proxy, otherwise it creates an unproxied session.
- Request-level proxies and global `PROXY_*` configuration are mutually exclusive. Mixed mode returns `409 proxy_mode_conflict`.
- Accepted schemes are `http`, `https`, `socks4`, and `socks5`. Put credentials in `username` and `password`, not in `server`. Request-body credentials are literal strings; unlike global `PROXY_USERNAME` / `PROXY_PASSWORD` values, they are not percent-decoded.
- Unlike global proxy mode, a request-level proxy does not infer GeoIP settings from the exit IP. It uses the deterministic no-global-proxy fallback: `en-US`, `America/Los_Angeles`, and San Francisco coordinates.

### Telemetry

Browser automation fails in ways that are hard to predict -- Cloudflare challenges, site redesigns breaking selectors, redirect loops, dialog storms, renderer crashes. The scope is wide and the failure modes are diverse. Without telemetry, the only signal is "it didn't work."
Expand Down
2 changes: 2 additions & 0 deletions lib/browser-errors.js
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export function browserErrorCode(err) {
}

export function browserErrorRecovery(err) {
if (err?.recovery) return err.recovery;
const code = browserErrorCode(err);
if (code === 'page_crashed' || code === 'tab_destroyed' || code === 'tab_unresponsive') return 'create_new_tab';
if (code === 'stale_refs' || code === 'element_not_actionable' || code === 'ambiguous_selector' || code === 'navigation_race') return 'snapshot_then_retry';
Expand All @@ -137,5 +138,6 @@ export function browserErrorRecovery(err) {
}

export function isRetryableBrowserError(err) {
if (err?.retryable !== undefined) return Boolean(err.retryable);
return Boolean(browserErrorRecovery(err));
}
7 changes: 6 additions & 1 deletion lib/new-page-recovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export async function createPageWithSessionRecovery({
getSession,
log,
}) {
const inheritedRequestProxy = session.requestProxy || null;
let lease = acquirePageLease(session);
try {
const page = setLeasedPage(lease, await withTimeout(session.context.newPage(), timeoutMs, 'new page'));
Expand All @@ -32,7 +33,11 @@ export async function createPageWithSessionRecovery({
await destroySession(userId, { reason: 'new_page_unresponsive' });
}

session = await getSession(userId, { trace });
const recoveryOptions = { trace };
if (inheritedRequestProxy) {
recoveryOptions.inheritedRequestProxy = inheritedRequestProxy;
}
session = await getSession(userId, recoveryOptions);
lease = acquirePageLease(session);
try {
const page = setLeasedPage(lease, await withTimeout(session.context.newPage(), timeoutMs, 'new page retry'));
Expand Down
203 changes: 203 additions & 0 deletions lib/request-proxy.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
const ALLOWED_PROXY_SCHEMES = new Set(['http:', 'https:', 'socks4:', 'socks5:']);
const MAX_PROXY_SERVER_LENGTH = 2048;
const MAX_PROXY_CREDENTIAL_LENGTH = 512;
const DEFAULT_RECOVERY_TTL_MS = 5 * 60 * 1000;
const DEFAULT_MAX_RECOVERY_ENTRIES = 256;
const ALLOWED_PROXY_FIELDS = new Set(['server', 'username', 'password']);
export const REQUEST_PROXY_RECOVERY_REASONS = Object.freeze([
'route_dead_context',
'dead_context',
'browser_disconnected',
'navigation_timeout',
'new_page_unresponsive',
'pressure_cleanup_empty_session',
'session_timeout',
'memory_pressure',
'tab_reaper_empty_session',
]);
const AUTOMATIC_RECOVERY_REASONS = new Set(REQUEST_PROXY_RECOVERY_REASONS);

function proxyError(message, statusCode = 400, { code = null, recovery = null, retryable } = {}) {
return Object.assign(new Error(message), {
statusCode,
...(code ? { code } : {}),
...(recovery ? { recovery } : {}),
...(retryable !== undefined ? { retryable } : {}),
});
}

function copyProxy(proxy) {
if (!proxy) return null;
return {
server: proxy.server,
...(proxy.username !== undefined ? { username: proxy.username } : {}),
...(proxy.password !== undefined ? { password: proxy.password } : {}),
};
}

export function redactProxy(proxy) {
if (!proxy) return null;
return {
server: proxy.server,
username: proxy.username ? '<redacted>' : undefined,
password: proxy.password ? '<redacted>' : undefined,
};
}

export function shouldPreserveRequestProxy(reason) {
return AUTOMATIC_RECOVERY_REASONS.has(reason) || reason?.startsWith('browser_restart:') === true;
}

function validateOptionalCredential(value, field) {
if (value === undefined) return undefined;
if (typeof value !== 'string') {
throw proxyError(`proxy.${field} must be a string`);
}
if (value.length > MAX_PROXY_CREDENTIAL_LENGTH) {
throw proxyError(`proxy.${field} is too long`);
}
return value;
}

export function normalizeRequestProxy(proxy) {
if (proxy === undefined) return null;
if (!proxy || typeof proxy !== 'object' || Array.isArray(proxy)) {
throw proxyError('proxy must be an object');
}
const unsupportedField = Object.keys(proxy).find(field => !ALLOWED_PROXY_FIELDS.has(field));
if (unsupportedField) {
throw proxyError('proxy contains unsupported fields');
}
if (typeof proxy.server !== 'string' || !proxy.server.trim()) {
throw proxyError('proxy.server is required');
}

const server = proxy.server.trim();
if (server.length > MAX_PROXY_SERVER_LENGTH) {
throw proxyError('proxy.server is too long');
}

let parsed;
try {
parsed = new URL(server);
} catch {
throw proxyError('proxy.server must be a valid URL');
}
if (!ALLOWED_PROXY_SCHEMES.has(parsed.protocol)) {
throw proxyError('proxy.server scheme must be http, https, socks4, or socks5');
}
if (!parsed.hostname) {
throw proxyError('proxy.server must include a hostname');
}
if (parsed.username || parsed.password) {
throw proxyError('proxy credentials must be provided as proxy.username/proxy.password, not embedded in proxy.server');
}

const username = validateOptionalCredential(proxy.username, 'username');
const password = validateOptionalCredential(proxy.password, 'password');
return {
server,
...(username !== undefined ? { username } : {}),
...(password !== undefined ? { password } : {}),
};
}

export function requestProxiesEqual(a, b) {
const left = a || null;
const right = b || null;
if (!left || !right) return left === right;
return left.server === right.server &&
(left.username || '') === (right.username || '') &&
(left.password || '') === (right.password || '');
}

export function requestProxyConflict(message = 'existing session uses a different proxy') {
return proxyError(message, 409, {
code: 'proxy_conflict',
recovery: 'delete_session',
retryable: false,
});
}

export function assertRequestProxyCompatible(expectedProxy, actualProxy) {
if (!requestProxiesEqual(expectedProxy, actualProxy)) throw requestProxyConflict();
return actualProxy || null;
}

export function resolveRequestProxy({ requestedProxy, existingSession, globalProxyActive }) {
if (requestedProxy === undefined) return null;
const proxy = normalizeRequestProxy(requestedProxy);

if (globalProxyActive) {
throw proxyError(
'request-level proxy cannot be used while global proxy configuration is active',
409,
{ code: 'proxy_mode_conflict', recovery: 'remove_request_proxy', retryable: false },
);
}

if (existingSession) {
if (requestProxiesEqual(proxy, existingSession.requestProxy || null)) {
return proxy;
}
throw requestProxyConflict('proxy can only be set when creating a new user session; existing session uses a different proxy');
}

return proxy;
}

export function createRequestProxyRecoveryCache({
ttlMs = DEFAULT_RECOVERY_TTL_MS,
maxEntries = DEFAULT_MAX_RECOVERY_ENTRIES,
now = Date.now,
} = {}) {
const entries = new Map();
const boundedTtlMs = Math.max(1, Number(ttlMs) || DEFAULT_RECOVERY_TTL_MS);
const boundedMaxEntries = Math.max(1, Math.floor(Number(maxEntries) || DEFAULT_MAX_RECOVERY_ENTRIES));

function pruneExpired(at = now()) {
for (const [userId, entry] of entries) {
if (entry.expiresAt <= at) entries.delete(userId);
}
}

return {
remember(userId, proxy) {
const key = String(userId);
if (!proxy) {
entries.delete(key);
return;
}
const at = now();
pruneExpired(at);
entries.delete(key);
while (entries.size >= boundedMaxEntries) {
const oldestKey = entries.keys().next().value;
entries.delete(oldestKey);
}
entries.set(key, {
proxy: copyProxy(proxy),
expiresAt: at + boundedTtlMs,
});
},

get(userId) {
const at = now();
pruneExpired(at);
return copyProxy(entries.get(String(userId))?.proxy || null);
},

delete(userId) {
return entries.delete(String(userId));
},

clear() {
entries.clear();
},

get size() {
pruneExpired(now());
return entries.size;
},
};
}
Loading