diff --git a/README.md b/README.md index 360c6ea1..757d2b6a 100644 --- a/README.md +++ b/README.md @@ -66,9 +66,9 @@ Load a skill explicitly: } ``` -### Residential proxy (`browserless_agent`) +### Built-in proxy (`browserless_agent`) -Pass a top-level `proxy` object on `browserless_agent` to route the session through residential IPs. Use this when targets IP-block datacenter traffic. +Pass a top-level `proxy` object on `browserless_agent` to route the session through datacenter or residential IPs. Datacenter is cheaper per MB; residential is less likely to be blocked. ```jsonc { @@ -90,19 +90,35 @@ Pass a top-level `proxy` object on `browserless_agent` to route the session thro | Field | Notes | | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | -| `proxy` | `"residential"` — only value supported today. | +| `proxy` | `"datacenter"` for lower cost or `"residential"` when targets block datacenter traffic. | | `proxyCountry` | ISO-2 country code (`"us"`, `"de"`). Auto-normalized to lowercase. Non-letter values are rejected. | | `proxyState` | US state name with whitespace replaced by underscores (`"new_york"`). Paid-plan gated — non-eligible tokens get a 401. | | `proxyCity` | City target. Paid/enterprise plan gated — non-eligible tokens get a 401. | | `proxySticky` | Stable IP while the underlying WebSocket stays open. Reconnects (idle drop, network blip, browser crash) allocate a new sticky id and new IP. | | `proxyLocaleMatch` | Match `navigator` locale to the proxy IP country. | -| `proxyPreset` | Named preset (e.g. `"px_amazon01"`). Available presets are plan-dependent — ask Browserless support for your list. | +| `proxyPreset` | Residential-only named preset (e.g. `"px_amazon01"`). Available presets are plan-dependent — ask Browserless support for your list. | | `externalProxyServer` | Bring-your-own upstream, e.g. `http://user:pass@host:port`. Must be `http://` or `https://`. | -> **Note:** `proxyCountry` / `proxyState` / `proxyCity` / `proxySticky` / `proxyLocaleMatch` / `proxyPreset` require either `proxy: "residential"` or `externalProxyServer` to be set. The MCP rejects this combination at validation time; without it, the API would silently ignore them. +> **Note:** Geo, sticky, and locale options require either a built-in `proxy` tier or `externalProxyServer`; `proxyPreset` requires `proxy: "residential"`. The MCP rejects unsupported combinations instead of letting the API silently ignore them. The `proxy` object is read once at session creation. To change it, call `close` and start a new session — the agent client keys sessions on the proxy fingerprint, so passing a different config will land on a fresh WebSocket. +### OS persona (`browserless_agent`) + +Agent sessions can opt into a coherent OS persona with top-level creation options: + +| Field | Notes | +| ------------------- | ----------------------------------------------------------------------------------------- | +| `emulationOs` | `"windows"`, `"macos"`, `"linux"`, or `"android"`. Enables platform spoofing. | +| `emulatedDevice` | Android device slug; used only with `emulationOs: "android"`. | +| `screen` | Desktop screen in `WIDTHxHEIGHT` form. | +| `deviceScaleFactor` | Desktop device pixel ratio: `1` or `1.25`. | +| `deviceSlot` | Non-negative stable desktop-device slot; the server validates the account-specific range. | + +Set persona options on the first call before navigation and reuse the returned +`sessionId` afterward. Persona is fixed for the life of that browser session; +close it before selecting a different persona. + ## Configuration The server is hosted at `https://mcp.browserless.io/mcp`. Authenticate via headers (preferred) or a `?token=` query parameter. diff --git a/src/@types/types.d.ts b/src/@types/types.d.ts index ff1ce302..2d29866d 100644 --- a/src/@types/types.d.ts +++ b/src/@types/types.d.ts @@ -28,7 +28,10 @@ import type { } from '../tools/crawl.js'; import type { AgentParamsSchema } from '../tools/agent.js'; import type { CreateProfileParams } from '../tools/schemas.js'; -import type { ProxyOptionsSchema } from '../lib/agent-client.js'; +import type { + PersonaOptionsSchema, + ProxyOptionsSchema, +} from '../lib/agent-client.js'; import type { AuthMethod, McpTransport } from '../lib/attribution.js'; /* ------------------------------------------------------------------ */ @@ -205,11 +208,13 @@ export interface SnapshotResult { export interface ActiveSession { ws: WebSocket; msgId: number; - // Identity fields: these feed the session-cache key (see getSessionKey). - // Mutating them post-creation would desync the cache, so they're readonly. + // Creation identity fields are immutable. Most feed the session-cache key; + // persona is retained on the handle and checked before reuse. readonly apiUrl: string; readonly token: string; readonly proxy?: ProxyOptions; + /** Persona fixed when the underlying browser session is created. */ + readonly persona?: PersonaOptions; readonly profile?: string; // When set, this session was opened in profile-creation mode: the WS is bound // to a creation session from POST /profile rather than a fresh launch. Feeds @@ -364,6 +369,7 @@ export type SmartScraperResponse = z.infer; export type FunctionParams = z.infer; export type ExportParams = z.infer; export type ProxyOptions = z.infer; +export type PersonaOptions = z.infer; export type SearchSource = z.infer; export type SearchCategory = z.infer; export type TimeBasedOptions = z.infer; diff --git a/src/lib/agent-client.ts b/src/lib/agent-client.ts index 8c6a7a47..b93bef53 100644 --- a/src/lib/agent-client.ts +++ b/src/lib/agent-client.ts @@ -9,6 +9,7 @@ import type { ActiveSession, AgentMessage, AgentResponse, + PersonaOptions, ProxyOptions, } from '../@types/types.js'; @@ -16,6 +17,7 @@ import type { // need (e.g. a hosted Agent constructor that takes `proxy?: ProxyOptions`). export type { ProxyOptions, + PersonaOptions, ActiveSession, AgentMessage, AgentResponse, @@ -30,9 +32,11 @@ export type { const ProxyOptionsObjectSchema = z.object({ proxy: z - .enum(['residential']) + .enum(['residential', 'datacenter']) .optional() - .describe('Routing tier. Only "residential" is supported today.'), + .describe( + 'Routing tier. Datacenter is cheaper per MB; residential is less likely to be blocked.', + ), proxyCountry: z .string() .regex(/^[A-Za-z]{2}$/, 'Must be a 2-letter ISO-2 country code') @@ -93,20 +97,82 @@ export const ProxyOptionsSchema = ProxyOptionsObjectSchema.refine( (v) => { const hasDependent = DEPENDENT_PROXY_FIELDS.some((k) => v[k] !== undefined); return ( - !hasDependent || v.proxy === 'residential' || !!v.externalProxyServer + !hasDependent || + v.proxy === 'residential' || + v.proxy === 'datacenter' || + !!v.externalProxyServer ); }, { message: 'proxyCountry/proxyState/proxyCity/proxySticky/proxyLocaleMatch/proxyPreset ' + - "require proxy: 'residential' or externalProxyServer to be set; otherwise the API silently ignores them.", + "require proxy: 'residential'/'datacenter' or externalProxyServer to be set; otherwise the API silently ignores them.", }, -); +).refine((v) => v.proxyPreset === undefined || v.proxy === 'residential', { + message: 'proxyPreset is supported only with proxy: "residential".', +}); export const PROXY_FIELDS = Object.keys( ProxyOptionsObjectSchema.shape, ) as Array; +export const PersonaOptionsSchema = z.object({ + emulationOs: z + .enum(['windows', 'macos', 'linux', 'android']) + .optional() + .describe( + 'OS persona for platform spoofing. Set on the first call before navigation.', + ), + emulatedDevice: z + .string() + .optional() + .describe( + 'Android device slug, used only with emulationOs="android". Unknown slugs select a seeded device.', + ), + screen: z + .string() + .trim() + .refine((value) => { + const match = /^(\d{2,5})x(\d{2,5})$/.exec(value); + if (!match) return false; + const width = Number(match[1]); + const height = Number(match[2]); + return width >= 640 && width <= 7680 && height >= 640 && height <= 7680; + }, 'screen must be WIDTHxHEIGHT with each dimension between 640 and 7680') + .optional() + .describe( + 'Desktop screen as WIDTHxHEIGHT, with each dimension from 640 through 7680. Ignored for Android.', + ), + deviceScaleFactor: z + .union([z.literal(1), z.literal(1.25)]) + .optional() + .describe('Desktop device pixel ratio. Ignored for Android.'), + deviceSlot: z + .number() + .int() + .nonnegative() + .optional() + .describe( + 'Stable desktop device slot. The server validates the account-specific upper bound.', + ), +}); + +export const PERSONA_FIELDS = Object.keys(PersonaOptionsSchema.shape) as Array< + keyof PersonaOptions +>; + +const hasPersona = (persona?: PersonaOptions): boolean => + !!persona && PERSONA_FIELDS.some((field) => persona[field] !== undefined); + +const hasPersonaConflict = ( + existing: PersonaOptions | undefined, + requested: PersonaOptions, +): boolean => + PERSONA_FIELDS.some( + (field) => + requested[field] !== undefined && existing?.[field] !== requested[field], + ); + /** * Thrown when the agent WebSocket upgrade is rejected with a non-101 HTTP * response. Carries the status code and body so the tool layer can render a @@ -144,6 +210,16 @@ export class ProfileNotFoundError extends UpgradeError { } } +/** A caller attempted to redefine immutable persona state on a live browser. */ +export class PersonaConflictError extends Error { + constructor( + message = 'Persona options are fixed when a browser session opens. Close the session before changing them.', + ) { + super(message); + this.name = 'PersonaConflictError'; + } +} + // Upgrade statuses where a one-shot retry cannot help: bad request (400), // bad auth (401), forbidden by plan/policy (403), missing resource (404), or // concurrency limit (429). Retrying a 429 just opens another session and @@ -164,6 +240,7 @@ const assertCompatibleRecordingMode = ( }; export const isRetryableUpgradeError = (err: unknown): boolean => { + if (err instanceof PersonaConflictError) return false; if (err instanceof SessionReuseError) return false; if (err instanceof UpgradeError) { // A 2xx UpgradeError is a structurally-bad success response — retrying @@ -180,10 +257,16 @@ const createdAt = new WeakMap(); // getOrCreateSession callers await the same promise instead of each // opening their own WebSocket. const pending = new Map>(); +// Retain creation-state persona and proxy routing across socket eviction so an +// echoed handle can recreate coherently without repeating first-call options. +const retainedPersonas = new Map(); +// null is an explicit no-proxy configuration; absence means no retained state. +const retainedProxies = new Map(); const DEFAULT_TIMEOUT = 60_000; const IDLE_TTL_MS = 15 * 60 * 1000; const MAX_SESSIONS = 500; +const MAX_RETAINED_CONFIGS = 500; // mcp session id -> last time a request arrived on it. `disconnect` is the // primary signal, but a client that abandons a transport never sends one. const mcpSeenAt = new Map(); @@ -281,12 +364,14 @@ export const getSessionKey = ( KEY_SEP + 'conv#' + sessionHandle(mcpSessionId, token, echoedSessionId) + - proxyFingerprint(proxy) + + // A returned handle identifies proxy state; profile and integration bindings + // remain contractual scope and must be repeated on every call. + (echoedSessionId ? '' : proxyFingerprint(proxy)) + (profile ? KEY_SEP + 'profile#' + hashToken(profile) : '') + (createProfile ? KEY_SEP + 'create#' + hashToken(createProfile.name) : '') + (attachSessionId ? KEY_SEP + 'attach#' + attachSessionId : '') + - // Different scope must key to a different WS, else a same-integration call - // silently reuses the first scope. Sorted so domain order doesn't fork the key. + // Different integration scope must key to a different WS. Sorted so domain + // order doesn't fork the key. (integrationId ? KEY_SEP + 'int#' + @@ -309,8 +394,8 @@ const apiEndpoint = (apiUrl: string, path: string, ws = false): URL => { }; /** - * Build the WebSocket URL for `/chromium/agent`, appending `token` plus proxy - * params. Boolean proxy flags follow the API's presence-only contract. + * Build the WebSocket URL for `/chromium/agent`, appending `token`, proxy, and + * persona params. Boolean proxy flags follow the API's presence-only contract. */ export const buildAgentWsUrl = ( apiUrl: string, @@ -324,7 +409,13 @@ export const buildAgentWsUrl = ( os?: string, humanlike?: boolean, record?: boolean, + persona?: PersonaOptions, ): string => { + if (os && persona?.emulationOs && os !== persona.emulationOs) { + throw new PersonaConflictError( + '`os` and `emulationOs` must match when both are provided.', + ); + } const url = apiEndpoint(apiUrl, '/chromium/agent', true); url.searchParams.set('token', token); // On attach (sessionId set) the creation session already owns proxy/profile from @@ -332,7 +423,19 @@ export const buildAgentWsUrl = ( // emulationOs is likewise carried by the running browser on reconnect (enterprise // restores requestedEmulationOs from the instance), so it's only set on a fresh connect. if (sessionId) { + if (record) { + throw new PersonaConflictError( + 'Recording cannot be armed on an attached browser. Start a new browser session with `record: true` instead.', + ); + } + if (hasPersona(persona) || os !== undefined) { + throw new PersonaConflictError( + 'Persona options cannot redefine an attached browser. Set the persona when the browser session is created.', + ); + } url.searchParams.set('sessionId', sessionId); + if (humanlike !== undefined) + url.searchParams.set('humanlike', String(humanlike)); return url.toString(); } // Compliant surface exposes no proxy/profile — the schema and run()-layer @@ -344,7 +447,13 @@ export const buildAgentWsUrl = ( url.searchParams.set('proxyCountry', proxy.proxyCountry); if (proxy?.proxyState) url.searchParams.set('proxyState', proxy.proxyState); if (proxy?.proxyCity) url.searchParams.set('proxyCity', proxy.proxyCity); - if (proxy?.proxySticky) url.searchParams.set('proxySticky', 'true'); + if ( + proxy?.proxySticky || + ((persona?.emulationOs ?? os) !== undefined && + proxy?.proxySticky === false) + ) { + url.searchParams.set('proxySticky', String(proxy.proxySticky)); + } if (proxy?.proxyLocaleMatch) url.searchParams.set('proxyLocaleMatch', 'true'); if (proxy?.proxyPreset) @@ -355,7 +464,8 @@ export const buildAgentWsUrl = ( // Opt the agent socket into the stealth stack with a spoofed desktop OS. // Without it the agent (BraveStealthBrowser) reports native Linux under a // Chrome-masked UA — an incoherent fingerprint anti-bot checks flag. - if (os) url.searchParams.set('emulationOs', os); + const emulationOs = persona?.emulationOs ?? os; + if (emulationOs) url.searchParams.set('emulationOs', emulationOs); // Human-like cursor/pacing — lifts the passive score of invisible anti-bot // challenges (the agent otherwise moves no mouse). Read at session creation. if (humanlike) url.searchParams.set('humanlike', 'true'); @@ -366,6 +476,11 @@ export const buildAgentWsUrl = ( if (allowedDomains?.length) url.searchParams.set('allowedDomains', JSON.stringify(allowedDomains)); } + for (const field of PERSONA_FIELDS) { + if (field === 'emulationOs') continue; + const value = persona?.[field]; + if (value !== undefined) url.searchParams.set(field, String(value)); + } } // Recording is armed only when launching a new browser. if (record) url.searchParams.set('record', 'true'); @@ -519,13 +634,10 @@ const postCreateProfile = async ( token: string, createProfile: CreateProfileParams, os?: string, - humanlike?: boolean, ): Promise => { const url = apiEndpoint(apiUrl, '/profile'); url.searchParams.set('token', token); if (os) url.searchParams.set('emulationOs', os); - if (humanlike !== undefined) - url.searchParams.set('humanlike', String(humanlike)); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), CREATE_PROFILE_TIMEOUT_MS); @@ -579,6 +691,7 @@ const connect = ( os?: string, humanlike?: boolean, record?: boolean, + persona?: PersonaOptions, ): Promise => new Promise((resolve, reject) => { const wsUrl = buildAgentWsUrl( @@ -593,6 +706,7 @@ const connect = ( os, humanlike, record, + persona, ); // Forward the origin on the upgrade so the server can attribute captured // skills; reuses the same header the MCP already receives on its inbound. @@ -731,9 +845,45 @@ export const getOrCreateSession = async ( os?: string, humanlike?: boolean, record?: boolean, + persona?: PersonaOptions, onSession?: (reused: boolean, ageMs: number) => void, ): Promise => { sweepSessions(); + if (os && persona?.emulationOs && os !== persona.emulationOs) { + throw new PersonaConflictError( + '`os` and `emulationOs` must match when both are provided.', + ); + } + if (record && createProfile) { + throw new PersonaConflictError( + 'Recording cannot be armed during profile creation. Create and save the profile first, then start a new browser session with `profile` and `record: true`.', + ); + } + if (record && attachSessionId) { + throw new PersonaConflictError( + 'Recording cannot be armed on an attached browser. Start a new browser session with `record: true` instead.', + ); + } + const effectiveOs = persona?.emulationOs ?? os; + const requestedPersona = + !attachSessionId && effectiveOs + ? { + ...persona, + emulationOs: + persona?.emulationOs ?? + (effectiveOs as PersonaOptions['emulationOs']), + } + : persona; + if ( + createProfile && + PERSONA_FIELDS.some( + (field) => field !== 'emulationOs' && persona?.[field] !== undefined, + ) + ) { + throw new PersonaConflictError( + 'Additional persona options cannot be combined with profile creation. Use only os/emulationOs while creating a profile, then pass the other persona options on a later session.', + ); + } // Reusing on a bare call guessed "same task" — but every concurrent task in a // conversation shares the MCP session id, so the guess collided them onto one page. const handle = @@ -752,6 +902,72 @@ export const getOrCreateSession = async ( ); noteMcpSession(mcpSessionId); const existing = sessions.get(key); + const retainedPersona = retainedPersonas.get(key); + const hasRetainedProxy = retainedProxies.has(key); + const retainedProxy = retainedProxies.get(key); + + if ( + hasRetainedProxy && + proxy !== undefined && + proxyFingerprint(retainedProxy ?? undefined) !== proxyFingerprint(proxy) + ) { + throw new PersonaConflictError( + 'Proxy options are fixed when a browser session opens. Close the session before changing them.', + ); + } + const effectiveProxy = hasRetainedProxy + ? (retainedProxy ?? undefined) + : proxy; + if (hasRetainedProxy) { + retainedProxies.delete(key); + retainedProxies.set(key, retainedProxy ?? null); + } + + if ( + retainedPersona && + requestedPersona && + hasPersona(requestedPersona) && + hasPersonaConflict(retainedPersona, requestedPersona) + ) { + throw new PersonaConflictError(); + } + const effectivePersona = retainedPersona ?? requestedPersona; + if (retainedPersona) { + // Refresh insertion order so the bounded map behaves as a small LRU. + retainedPersonas.delete(key); + retainedPersonas.set(key, retainedPersona); + } + + if (attachSessionId && (hasPersona(persona) || effectiveOs !== undefined)) { + throw new PersonaConflictError( + 'Persona options cannot redefine an attached browser. Set the persona when the browser session is created.', + ); + } + + if ( + existing && + proxy !== undefined && + proxyFingerprint(existing.proxy) !== proxyFingerprint(proxy) + ) { + throw new PersonaConflictError( + 'Proxy options are fixed when a browser session opens. Close the session before changing them.', + ); + } + + if (existing && record !== undefined && Boolean(existing.record) !== record) { + throw new PersonaConflictError( + 'Recording mode is fixed when a browser session opens. Close the session before changing it.', + ); + } + + if ( + existing && + requestedPersona && + hasPersona(requestedPersona) && + hasPersonaConflict(existing.persona, requestedPersona) + ) { + throw new PersonaConflictError(); + } if ( existing && @@ -768,7 +984,26 @@ export const getOrCreateSession = async ( const inFlight = pending.get(key); if (inFlight) { const session = await inFlight; - assertCompatibleRecordingMode(session, record); + if ( + proxy !== undefined && + proxyFingerprint(session.proxy) !== proxyFingerprint(proxy) + ) { + throw new PersonaConflictError( + 'Proxy options are fixed when a browser session opens. Close the session before changing them.', + ); + } + if ( + requestedPersona && + hasPersona(requestedPersona) && + hasPersonaConflict(session.persona, requestedPersona) + ) { + throw new PersonaConflictError(); + } + if (record !== undefined && Boolean(session.record) !== record) { + throw new PersonaConflictError( + 'Recording mode is fixed when a browser session opens. Close the session before changing it.', + ); + } onSession?.(true, Math.max(0, Date.now() - createdAt.get(session)!)); return session; } @@ -794,29 +1029,35 @@ export const getOrCreateSession = async ( creationSessionId = attachSessionId; } else if (createProfile) { creationSessionId = ( - await postCreateProfile(apiUrl, token, createProfile, os, humanlike) + await postCreateProfile( + apiUrl, + token, + createProfile, + effectivePersona?.emulationOs, + ) ).id; } const ws = await connect( apiUrl, token, - proxy, + effectiveProxy, profile, creationSessionId, compliant, source, integrationId, allowedDomains, - os, + createProfile ? undefined : effectiveOs, humanlike, record, + createProfile ? undefined : effectivePersona, ); const session: ActiveSession = { ws, msgId: 0, apiUrl, token, - proxy, + proxy: effectiveProxy, profile, createProfile, creationSessionId, @@ -825,14 +1066,32 @@ export const getOrCreateSession = async ( handle, integrationId, allowedDomains, - os, + os: effectiveOs, humanlike, + persona: effectivePersona, record, skillState: createSkillState(), lastUsedAt: Date.now(), }; createdAt.set(session, Date.now()); + if (hasPersona(effectivePersona)) { + retainedPersonas.delete(key); + retainedPersonas.set(key, effectivePersona!); + while (retainedPersonas.size > MAX_RETAINED_CONFIGS) { + const oldest = retainedPersonas.keys().next().value; + if (oldest === undefined) break; + retainedPersonas.delete(oldest); + } + } + retainedProxies.delete(key); + retainedProxies.set(key, effectiveProxy ?? null); + while (retainedProxies.size > MAX_RETAINED_CONFIGS) { + const oldest = retainedProxies.keys().next().value; + if (oldest === undefined) break; + retainedProxies.delete(oldest); + } + // Auto-cleanup on close ws.on('close', (code: number, reason: Buffer) => { if (code !== 1000) { @@ -886,9 +1145,10 @@ export const send = async ( session.source, session.integrationId, session.allowedDomains, - session.os, + session.creationSessionId ? undefined : session.os, session.humanlike, session.record, + session.creationSessionId ? undefined : session.persona, ).finally(() => { session.reconnecting = undefined; }); @@ -955,6 +1215,8 @@ export const closeSession = ( } sessions.delete(key); } + retainedPersonas.delete(key); + retainedProxies.delete(key); }; /** diff --git a/src/skills/system-prompt.ts b/src/skills/system-prompt.ts index c8d0cf53..08cb2f6d 100644 --- a/src/skills/system-prompt.ts +++ b/src/skills/system-prompt.ts @@ -21,14 +21,20 @@ Many specific sites (marketplaces, gov portals, travel, real-estate, etc.) have **Report the outcome (only if you loaded a site recipe).** As your final command in the run, send \`{ method: "reportSkillOutcome", params: { domain: "", task: "", success: } }\` inside \`commands\` — where \`domain\`/\`task\` are the loaded recipe's \`\`/\`\` and \`success\` is whether the recipe actually got you the result. This refines shared recipes and retires ones that stop working. Send it once, and only when you loaded a recipe — never for a self-planned run. Send it as your last command **before** any \`close\` (close ends the run and anything after it is dropped). ## Proxy (optional) -Proxy config is a **top-level tool argument** (\`proxy\`, \`proxyCountry\`, etc. on the tool call itself) — it is applied when the session is opened. **NEVER call \`proxy\` as a method inside \`commands\`** — a \`{ method: "proxy", ... }\` JSON-RPC mutation does NOT change the upstream proxy on an already-open session and will silently no-op. +Proxy config is a **top-level \`proxy\` object** on the tool call — it is applied when the session is opened. **NEVER call \`proxy\` as a method inside \`commands\`** — a \`{ method: "proxy", ... }\` JSON-RPC mutation does NOT change the upstream proxy on an already-open session and will silently no-op. **If there is credible evidence the task needs a proxy, you MUST pass proxy options on the very FIRST call** (before any \`goto\`/\`snapshot\`), because the config is read once at session creation. Credible signals include: the user asks for a specific country/region/locale; the target site is known to geo-restrict or block datacenter IPs (streaming, ticketing, retail, banking, real-estate, news paywalls); a prior attempt returned 403/451/captcha/"unusual traffic"/"access denied"; the user explicitly mentions residential / sticky IP / proxy. If you already opened a session without a proxy and now realize one is needed, you must \`close\` and start a new session with the proxy options set — there is no in-session switch. -- \`proxy: "residential"\` — enable routing; \`proxyCountry: "us"\` — geo (ISO-2); \`proxyState\` / \`proxyCity\` (paid plans, 401 otherwise); \`proxySticky: true\` — stable IP; \`proxyLocaleMatch: true\` — match locale; \`proxyPreset\` — named config; \`externalProxyServer: "http://u:p@host:port"\` — bring your own (http(s) only) -- Geo/preset/sticky require \`proxy: "residential"\` or \`externalProxyServer\` set +- Use \`proxy: { proxy: "datacenter" }\` when a lower-cost IP is sufficient. Use \`proxy: { proxy: "residential" }\` when the target is known to block datacenter IPs or the cheaper tier still hits a challenge. +- Inside the object: \`proxyCountry: "us"\` — geo (ISO-2); \`proxyState\` / \`proxyCity\` (paid plans, 401 otherwise); \`proxySticky: true\` — stable IP; \`proxyLocaleMatch: true\` — match locale; \`proxyPreset\` — residential-only named config; \`externalProxyServer: "http://u:p@host:port"\` — bring your own (http(s) only) +- Geo/sticky/locale options require a built-in proxy tier or \`externalProxyServer\`; \`proxyPreset\` requires \`proxy: "residential"\` + +## OS persona (optional) +The top-level \`emulationOs\`, \`emulatedDevice\`, \`screen\`, \`deviceScaleFactor\`, and \`deviceSlot\` options are read once when the session opens. Put them on the **very first call, before any \`goto\`**, then keep using the returned \`sessionId\`; close and open a new session to change them. + +Reach for \`emulationOs\` only when there is evidence of platform fingerprinting: a Cloudflare or similar interstitial that never resolves, a hard block on an otherwise healthy page, or a site known to inspect the operating system. Start with \`emulationOs: "windows"\` unless the task or site requires another OS. Use \`emulatedDevice\` only with Android; desktop \`screen\`, \`deviceScaleFactor\`, and \`deviceSlot\` refine a desktop persona. ## Auth Never log in by default. Never invent or assume credentials exist (no "test credentials", no "your account"). If the snapshot contains a sign-in link OR you're about to mention "sign in" / "log in" / "auth required" — even as a suggested option to the user — call \`browserless_skill { id: "autonomous-login" }\` **first**, then follow its gates. The skill decides whether login is appropriate and whether credentials are in scope; do not skip it just because no password field is on the page yet. diff --git a/src/tools/agent.ts b/src/tools/agent.ts index eef37c2b..d2e2fbb3 100644 --- a/src/tools/agent.ts +++ b/src/tools/agent.ts @@ -17,12 +17,14 @@ import { closeSession, destroySession, isRetryableUpgradeError, + PERSONA_FIELDS, UpgradeError, } from '../lib/agent-client.js'; import type { AgentParams, ErrorCategory, McpConfig, + PersonaOptions, SkillId, SnapshotResult, } from '../@types/types.js'; @@ -645,6 +647,9 @@ export function registerAgentTools( params: c.params ?? {}, })) : [{ method: params.method ?? '', params: params.params ?? {} }]; + const personaRequested = + params.os !== undefined || + PERSONA_FIELDS.some((field) => params[field] !== undefined); // Defense-in-depth: even if the schema were mis-built, compliant never // forwards a non-allowlisted method, auth-profile, or proxy arg to the backend. @@ -669,6 +674,11 @@ export function registerAgentTools( 'Proxy configuration is not available on this endpoint.', ); } + if (personaRequested) { + throw new UserError( + 'Persona configuration is not available on this endpoint.', + ); + } if ( params.integrationId !== undefined || params.allowedDomains !== undefined @@ -726,8 +736,12 @@ export function registerAgentTools( // bot tell). Forwarded as ?emulationOs; see agent-client buildAgentWsUrl. const os = typeof (params as { os?: unknown }).os === 'string' - ? (params as { os: string }).os - : 'windows'; + ? (params as { os: PersonaOptions['emulationOs'] }).os + : undefined; + const emulationOs = + params.emulationOs ?? + os ?? + (params.sessionId || attachSessionId ? undefined : 'windows'); // Human-like cursor movement + pacing. Improves the passive score of // invisible anti-bot challenges (e.g. Revolut's post-passcode hCaptcha), // which weight real mouse/interaction signals; a machine-timed agent with @@ -737,6 +751,13 @@ export function registerAgentTools( typeof (params as { humanlike?: unknown }).humanlike === 'boolean' ? (params as { humanlike: boolean }).humanlike : true; + const persona = { + emulationOs: params.emulationOs ?? os, + emulatedDevice: params.emulatedDevice, + screen: params.screen, + deviceScaleFactor: params.deviceScaleFactor, + deviceSlot: params.deviceSlot, + }; // createProfile attaches by session id, which omits integrationId — binding // here would be silently dropped, so reject rather than mislead. if (createProfile && integrationId) { @@ -744,6 +765,31 @@ export function registerAgentTools( 'Credential integrations cannot be combined with profile creation. Create the profile first, then pass integrationId on a follow-up session.', ); } + if (record && createProfile) { + throw new UserError( + 'Recording cannot be armed during profile creation. Create and save the profile first, then start a new browser session with `profile` and `record: true`.', + ); + } + if (record && attachSessionId) { + throw new UserError( + 'Recording cannot be armed on an attached browser. Start a new browser session with `record: true` instead.', + ); + } + if ( + createProfile && + PERSONA_FIELDS.some( + (field) => field !== 'emulationOs' && params[field] !== undefined, + ) + ) { + throw new UserError( + 'Additional persona options cannot be combined with profile creation. Use only os/emulationOs while creating a profile, then pass the other persona options on a later session.', + ); + } + if (attachSessionId && personaRequested) { + throw new UserError( + 'Persona options cannot redefine an attached browser. Set the persona when the browser session is created.', + ); + } const echoedSessionId = params.sessionId; // Whether the caller threaded the handle is the difference between one // browser per conversation and one per call — log it, don't infer it. @@ -786,6 +832,8 @@ export function registerAgentTools( proxy_country: proxy?.proxyCountry ?? null, proxy_sticky: !!proxy?.proxySticky, proxy_external: !!proxy?.externalProxyServer, + emulation_os: emulationOs, + persona_requested: personaRequested, profile_used: !!profile, create_profile: !!createProfile, integration_used: !!integrationId, @@ -805,8 +853,8 @@ export function registerAgentTools( }); sendAnalytics(false); throw new UserError( - 'Invalid command: "proxy" is not a BQL mutation. Proxy config is a top-level tool argument (proxy, proxyCountry, proxyState, proxyCity, proxySticky, proxyLocaleMatch, proxyPreset, externalProxyServer) and is read once at session creation. ' + - 'Recovery: call `close` to end the current session, then call browserless_agent again with the proxy options set at the top level (alongside `method`/`commands`), e.g. { "proxy": "residential", "proxyCountry": "us", "commands": [ ... ] }.', + 'Invalid command: "proxy" is not a BQL mutation. Proxy config is a top-level `proxy` object and is read once at session creation. ' + + 'Recovery: call `close` to end the current session, then call browserless_agent again with the proxy object alongside `method`/`commands`, e.g. { "proxy": { "proxy": "residential", "proxyCountry": "us" }, "commands": [ ... ] }.', ); } @@ -847,9 +895,10 @@ export function registerAgentTools( echoedSessionId, integrationId, allowedDomains, - os, + emulationOs, humanlike, record, + persona, onSession, ); } catch (connErr: unknown) { @@ -869,7 +918,10 @@ export function registerAgentTools( ]; } - const runCommands = async (isRetry: boolean): Promise => { + const runCommands = async ( + isRetry: boolean, + retryPersona: PersonaOptions = persona, + ): Promise => { onSession(false, 0); lastFailure = undefined; lastCategory = undefined; @@ -888,9 +940,10 @@ export function registerAgentTools( echoedSessionId, integrationId, allowedDomains, - os, + emulationOs, humanlike, record, + retryPersona, onSession, ); } catch (connErr: unknown) { @@ -914,7 +967,7 @@ export function registerAgentTools( integrationId, allowedDomains, ); - return runCommands(true); + return runCommands(true, retryPersona); } // Execute all commands sequentially @@ -1030,7 +1083,7 @@ export function registerAgentTools( log.warn( `agent: ${cmd.method} failed (first attempt, retrying once): ${errMessage}`, ); - return runCommands(true); + return runCommands(true, agentSession.persona ?? retryPersona); } const classified = classifyAgentError({ err: { message: errMessage }, @@ -1064,7 +1117,7 @@ export function registerAgentTools( allowedDomains, ); if (!isRetry) { - return runCommands(true); + return runCommands(true, agentSession.persona ?? retryPersona); } } diff --git a/src/tools/schemas.ts b/src/tools/schemas.ts index abf666ea..a6d05224 100644 --- a/src/tools/schemas.ts +++ b/src/tools/schemas.ts @@ -1,5 +1,9 @@ import { z } from 'zod'; -import { ProxyOptionsSchema } from '../lib/agent-client.js'; +import { + PERSONA_FIELDS, + PersonaOptionsSchema, + ProxyOptionsSchema, +} from '../lib/agent-client.js'; // NUL is the session-key separator (KEY_SEP) in agent-client.ts. Computed via // fromCharCode so the literal control character never appears in source. @@ -836,12 +840,21 @@ const AgentToolCommandSchema = z.object({ params: flatCommandParams, }); -// Top-level invariants, shared by the full validation schema and the slim tool -// schema so both enforce them identically. const refineProfileExclusive = (v: { profile?: unknown; createProfile?: unknown; }): boolean => !(v.profile && v.createProfile); +const refineRecordCreateProfile = (v: { + record?: unknown; + createProfile?: unknown; +}): boolean => !(v.record && v.createProfile); +const refineCreateProfilePersona = ( + v: Record & { createProfile?: unknown }, +): boolean => + !v.createProfile || + !PERSONA_FIELDS.some( + (field) => field !== 'emulationOs' && v[field] !== undefined, + ); const refineStopRecordingLast = (v: { commands?: ReadonlyArray<{ method?: string }>; }): boolean => { @@ -854,6 +867,69 @@ const refineStopRecordingLast = (v: { ); }; +const addPersonaIssues = ( + v: Record & { + os?: string; + emulationOs?: string; + emulatedDevice?: unknown; + deviceSlot?: unknown; + screen?: unknown; + deviceScaleFactor?: unknown; + }, + ctx: z.RefinementCtx, +): void => { + if (v.os && v.emulationOs && v.os !== v.emulationOs) { + ctx.addIssue({ + code: 'custom', + path: ['emulationOs'], + message: '`os` and `emulationOs` must match when both are provided.', + }); + } + const hasDesktopOs = ['windows', 'macos', 'linux'].includes( + v.emulationOs ?? v.os ?? '', + ); + if (v.emulatedDevice !== undefined && v.emulationOs !== 'android') { + ctx.addIssue({ + code: 'custom', + path: ['emulatedDevice'], + message: 'emulatedDevice requires emulationOs="android".', + }); + } + if (v.deviceSlot !== undefined && !hasDesktopOs) { + ctx.addIssue({ + code: 'custom', + path: ['deviceSlot'], + message: + 'deviceSlot requires a desktop emulationOs (windows, macos, or linux).', + }); + } + if (v.screen !== undefined && !hasDesktopOs) { + ctx.addIssue({ + code: 'custom', + path: ['screen'], + message: + 'screen requires a desktop emulationOs (windows, macos, or linux).', + }); + } + if (v.deviceScaleFactor !== undefined) { + if (!hasDesktopOs) { + ctx.addIssue({ + code: 'custom', + path: ['deviceScaleFactor'], + message: + 'deviceScaleFactor requires a desktop emulationOs (windows, macos, or linux).', + }); + } + if (v.screen === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['deviceScaleFactor'], + message: 'deviceScaleFactor requires screen.', + }); + } + } +}; + // Apply both top-level agent invariants to a params object so the rich // validation schema and its slim tool projection can never drift. const withAgentInvariants = >(schema: T) => @@ -863,11 +939,33 @@ const withAgentInvariants = >(schema: T) => '`profile` (hydrate an existing profile) and `createProfile` (author a new ' + 'one) cannot both be set', }) + .refine(refineRecordCreateProfile, { + message: + 'Recording cannot be armed during profile creation. Create and save the profile first, then start a new browser session with `profile` and `record: true`.', + path: ['record'], + }) + .refine(refineCreateProfilePersona, { + message: + 'Additional persona options cannot be combined with profile creation. Use only os/emulationOs while creating a profile, then pass the other persona options on a later session.', + }) .refine(refineStopRecordingLast, { message: '`stopRecording` must be the final command, except before `close`', path: ['commands'], - }); + }) + .superRefine((value, ctx) => + addPersonaIssues( + value as Record & { + os?: string; + emulationOs?: string; + emulatedDevice?: unknown; + deviceSlot?: unknown; + screen?: unknown; + deviceScaleFactor?: unknown; + }, + ctx, + ), + ); const agentParamsObject = z.object({ method: z @@ -888,9 +986,10 @@ const agentParamsObject = z.object({ .optional() .describe(COMMANDS_DESCRIPTION), proxy: ProxyOptionsSchema.optional().describe( - 'Residential / external proxy config. Read once at session creation. ' + + 'Residential, datacenter, or external proxy config. Read once at session creation. ' + 'Changing requires close() + a new session call.', ), + ...PersonaOptionsSchema.shape, record: z .boolean() .optional() diff --git a/test/helpers/upgrade-server.ts b/test/helpers/upgrade-server.ts index 8bb9e2c3..e3a56aaf 100644 --- a/test/helpers/upgrade-server.ts +++ b/test/helpers/upgrade-server.ts @@ -11,6 +11,10 @@ export interface RejectingServerHandle extends UpgradeServerHandle { hits: () => number; } +export interface RespondingServerHandle extends RejectingServerHandle { + upgradeUrls: () => string[]; +} + export class AgentErrorFrame { constructor( public readonly error: { @@ -118,12 +122,14 @@ export const makeStallingServer = async ( // for tests only export const makeRespondingServer = async ( responder: (method: string, params: unknown) => unknown, -): Promise => { +): Promise => { const wss = new WebSocketServer({ noServer: true }); const server = http.createServer(); let upgrades = 0; + const upgradeUrls: string[] = []; server.on('upgrade', (req, socket, head) => { upgrades++; + upgradeUrls.push(req.url ?? ''); socket.on('error', () => {}); wss.handleUpgrade(req, socket, head, (ws) => { ws.on('message', (data: Buffer) => { @@ -148,6 +154,7 @@ export const makeRespondingServer = async ( return { url: `http://127.0.0.1:${port}`, hits: () => upgrades, + upgradeUrls: () => [...upgradeUrls], close: () => new Promise((r) => { wss.clients.forEach((c) => c.terminate()); @@ -162,19 +169,29 @@ export const makeRespondingServer = async ( * tests that need a live session — the server holds connections open until * `close()` terminates them. */ -export const makeAcceptingServer = async (): Promise => { +export const makeAcceptingServer = async ( + upgradeDelayMs = 0, +): Promise => { const wss = new WebSocketServer({ noServer: true }); const server = http.createServer(); + const upgradeUrls: string[] = []; server.on('upgrade', (req, socket, head) => { + upgradeUrls.push(req.url ?? ''); socket.on('error', () => {}); - wss.handleUpgrade(req, socket, head, (ws) => { - ws.on('close', () => {}); - }); + setTimeout( + () => + wss.handleUpgrade(req, socket, head, (ws) => { + ws.on('close', () => {}); + }), + upgradeDelayMs, + ); }); await new Promise((r) => server.listen(0, '127.0.0.1', () => r())); const { port } = server.address() as AddressInfo; return { url: `http://127.0.0.1:${port}`, + hits: () => upgradeUrls.length, + upgradeUrls: () => [...upgradeUrls], close: () => new Promise((r) => { wss.clients.forEach((c) => c.terminate()); diff --git a/test/lib/agent-client.spec.ts b/test/lib/agent-client.spec.ts index bee19d8c..ea0d0251 100644 --- a/test/lib/agent-client.spec.ts +++ b/test/lib/agent-client.spec.ts @@ -6,12 +6,13 @@ import { getOrCreateSession, getSessionKey, isRetryableUpgradeError, + PersonaConflictError, ProfileNotFoundError, proxyFingerprint, sessionHandle, dropMcpSession, - UpgradeError, send, + UpgradeError, } from '../../src/lib/agent-client.js'; import type { ProxyOptions } from '../../src/@types/types.js'; import { @@ -97,6 +98,17 @@ describe('agent-client buildAgentWsUrl', () => { expect(url.searchParams.get('proxy')).to.equal('residential'); }); + it('sets proxy=datacenter when requested', () => { + const url = new URL( + buildAgentWsUrl('http://localhost:3000', 'tok', { + proxy: 'datacenter', + proxyCountry: 'us', + }), + ); + expect(url.searchParams.get('proxy')).to.equal('datacenter'); + expect(url.searchParams.get('proxyCountry')).to.equal('us'); + }); + it('passes country, sticky, and locale-match flags', () => { const proxy: ProxyOptions = { proxy: 'residential', @@ -121,6 +133,43 @@ describe('agent-client buildAgentWsUrl', () => { expect(url.searchParams.has('proxySticky')).to.equal(false); }); + it('preserves explicit rotating proxy behavior for a persona session', () => { + const url = new URL( + buildAgentWsUrl( + 'http://localhost:3000', + 'tok', + { proxy: 'datacenter', proxySticky: false }, + undefined, + undefined, + false, + undefined, + undefined, + undefined, + undefined, + undefined, + { emulationOs: 'windows' }, + ), + ); + expect(url.searchParams.get('proxySticky')).to.equal('false'); + }); + + it('preserves explicit rotating proxy behavior for the shipped OS alias', () => { + const url = new URL( + buildAgentWsUrl( + 'http://localhost:3000', + 'tok', + { proxy: 'datacenter', proxySticky: false }, + undefined, + undefined, + false, + undefined, + undefined, + 'windows', + ), + ); + expect(url.searchParams.get('proxySticky')).to.equal('false'); + }); + it('omits locale-match when false (server uses presence-only semantics)', () => { const url = new URL( buildAgentWsUrl('http://localhost:3000', 'tok', { @@ -251,6 +300,133 @@ describe('agent-client buildAgentWsUrl', () => { expect(url.searchParams.has('allowedDomains')).to.equal(false); }); + it('preserves the published recording argument while adding persona options', () => { + const persona = { + emulationOs: 'windows' as const, + emulatedDevice: 'pixel-8', + screen: '1920x1080', + deviceScaleFactor: 1.25 as const, + deviceSlot: 3, + }; + const full = new URL( + buildAgentWsUrl( + 'http://localhost:3000', + 'tok', + undefined, + undefined, + undefined, + false, + undefined, + undefined, + undefined, + undefined, + // `record` shipped in v1.26 at this position; persona stays appended. + true, + persona, + ), + ); + expect(Object.fromEntries(full.searchParams)).to.include({ + emulationOs: 'windows', + emulatedDevice: 'pixel-8', + screen: '1920x1080', + deviceScaleFactor: '1.25', + deviceSlot: '3', + record: 'true', + }); + + const compliant = new URL( + buildAgentWsUrl( + 'http://localhost:3000', + 'tok', + undefined, + undefined, + undefined, + true, + undefined, + undefined, + undefined, + undefined, + undefined, + persona, + ), + ); + for (const field of Object.keys(persona)) { + expect(compliant.searchParams.has(field), field).to.equal(false); + } + }); + + it('rejects conflicting OS aliases before serializing the URL', () => { + expect(() => + buildAgentWsUrl( + 'http://localhost:3000', + 'tok', + undefined, + undefined, + undefined, + false, + undefined, + undefined, + 'macos', + undefined, + undefined, + { emulationOs: 'windows' }, + ), + ).to.throw(PersonaConflictError, /os.*emulationOs/i); + }); + + it('rejects redefining persona while attaching an existing browser', () => { + expect(() => + buildAgentWsUrl( + 'http://localhost:3000', + 'tok', + undefined, + undefined, + 'sess-123', + false, + undefined, + undefined, + undefined, + undefined, + undefined, + { emulationOs: 'windows' }, + ), + ).to.throw(/cannot redefine an attached browser/i); + }); + + it('rejects the legacy OS alias while attaching an existing browser', () => { + expect(() => + buildAgentWsUrl( + 'http://localhost:3000', + 'tok', + undefined, + undefined, + 'sess-123', + false, + undefined, + undefined, + 'windows', + ), + ).to.throw(PersonaConflictError, /cannot redefine an attached browser/i); + }); + + it('rejects recording while attaching an existing browser', () => { + expect(() => + buildAgentWsUrl( + 'http://localhost:3000', + 'tok', + undefined, + undefined, + 'sess-123', + false, + undefined, + undefined, + undefined, + undefined, + true, + ), + ).to.throw(/recording.*cannot.*attached browser/i); + }); + it('skips integrationId when attaching to an existing session', () => { const url = new URL( buildAgentWsUrl( @@ -386,6 +562,10 @@ describe('agent-client isRetryableUpgradeError', () => { it('retries on plain errors (network failures, timeouts)', () => { expect(isRetryableUpgradeError(new Error('ECONNREFUSED'))).to.equal(true); }); + + it('does not retry persona conflicts that would destroy the live session', () => { + expect(isRetryableUpgradeError(new PersonaConflictError())).to.equal(false); + }); }); // Verbatim error bodies the backend emits. Tests reference these constants @@ -619,7 +799,12 @@ describe('agent-client connect (upgrade error handling)', () => { describe('agent-client bare-call isolation', () => { const bare = (sid: string | undefined, url: string) => getOrCreateSession(sid, url, 'tok'); - const echo = (sid: string | undefined, url: string, handle: string) => + const echo = ( + sid: string | undefined, + url: string, + handle: string, + record?: boolean, + ) => getOrCreateSession( sid, url, @@ -628,9 +813,14 @@ describe('agent-client bare-call isolation', () => { undefined, undefined, undefined, - undefined, + false, undefined, handle, + undefined, + undefined, + undefined, + undefined, + record, ); // Regression: tasks in one conversation hashed to one key, so every task after @@ -690,6 +880,551 @@ describe('agent-client bare-call isolation', () => { } }); + it('rejects enabling recording on an existing unrecorded session', async () => { + const server = await makeAcceptingServer(); + try { + const unrecorded = await bare('mcp-record-off', server.url); + expect( + (await echo('mcp-record-off-2', server.url, unrecorded.handle, false)) + .ws, + ).to.equal(unrecorded.ws); + + const enableError = await echo( + 'mcp-record-off-3', + server.url, + unrecorded.handle, + true, + ).catch((error: unknown) => error); + expect(enableError).to.be.instanceOf(PersonaConflictError); + expect((enableError as Error).message).to.match(/recording.*fixed/i); + } finally { + await server.close(); + } + }); + + it('retains persona on an echoed handle and rejects a conflicting persona', async () => { + const server = await makeAcceptingServer(); + try { + const opened = await getOrCreateSession( + 'mcp-persona', + server.url, + 'tok', + undefined, + undefined, + undefined, + undefined, + false, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { emulationOs: 'windows', screen: '1920x1080' }, + ); + const resumed = await getOrCreateSession( + 'mcp-persona-2', + server.url, + 'tok', + undefined, + undefined, + undefined, + undefined, + false, + undefined, + opened.handle, + ); + expect(resumed.ws).to.equal(opened.ws); + expect(resumed.persona).to.deep.equal({ + emulationOs: 'windows', + screen: '1920x1080', + }); + + let thrown: unknown; + try { + await getOrCreateSession( + 'mcp-persona-2', + server.url, + 'tok', + undefined, + undefined, + undefined, + undefined, + false, + undefined, + opened.handle, + undefined, + undefined, + undefined, + undefined, + undefined, + { emulationOs: 'macos' }, + ); + } catch (error) { + thrown = error; + } + expect((thrown as Error).message).to.match(/fixed when.*opens/i); + } finally { + await server.close(); + } + }); + + it('keeps a proxy-backed persona when only the handle is repeated', async () => { + const server = await makeAcceptingServer(); + try { + const opened = await getOrCreateSession( + 'mcp-proxy-persona', + server.url, + 'tok', + { proxy: 'datacenter', proxyCountry: 'us' }, + undefined, + undefined, + undefined, + false, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + { emulationOs: 'windows' }, + ); + const resumed = await getOrCreateSession( + 'mcp-proxy-persona-follow-up', + server.url, + 'tok', + undefined, + undefined, + undefined, + undefined, + false, + undefined, + opened.handle, + ); + + expect(resumed.ws).to.equal(opened.ws); + expect(resumed.persona).to.deep.equal({ emulationOs: 'windows' }); + } finally { + await server.close(); + } + }); + + it('retains proxy routing when a dropped session is recreated by handle', async () => { + const server = await makeAcceptingServer(); + try { + const opened = await getOrCreateSession( + 'mcp-dropped-proxy', + server.url, + 'tok', + { proxy: 'datacenter', proxyCountry: 'us' }, + undefined, + undefined, + undefined, + false, + undefined, + 'dropped-proxy-handle', + ); + const closed = new Promise((resolve) => + opened.ws.once('close', () => resolve()), + ); + opened.ws.terminate(); + await closed; + + const resumed = await getOrCreateSession( + 'mcp-dropped-proxy-2', + server.url, + 'tok', + undefined, + undefined, + undefined, + undefined, + false, + undefined, + 'dropped-proxy-handle', + ); + + expect(resumed.proxy).to.deep.equal({ + proxy: 'datacenter', + proxyCountry: 'us', + }); + const reconnectUrl = new URL(server.upgradeUrls()[1]!, server.url); + expect(reconnectUrl.searchParams.get('proxy')).to.equal('datacenter'); + expect(reconnectUrl.searchParams.get('proxyCountry')).to.equal('us'); + } finally { + await server.close(); + } + }); + + it('retains a no-proxy configuration when a dropped session is recreated', async () => { + const server = await makeAcceptingServer(); + try { + const opened = await bare('mcp-dropped-no-proxy', server.url); + const closed = new Promise((resolve) => + opened.ws.once('close', () => resolve()), + ); + opened.ws.terminate(); + await closed; + + const changedProxy = await getOrCreateSession( + 'mcp-dropped-no-proxy-2', + server.url, + 'tok', + { proxy: 'datacenter' }, + undefined, + undefined, + undefined, + false, + undefined, + opened.handle, + ).catch((error: unknown) => error); + expect(changedProxy).to.be.instanceOf(PersonaConflictError); + expect((changedProxy as Error).message).to.match(/proxy.*fixed/i); + + const resumed = await echo( + 'mcp-dropped-no-proxy-3', + server.url, + opened.handle, + ); + expect(resumed.proxy).to.equal(undefined); + expect( + new URL(server.upgradeUrls()[1]!, server.url).searchParams.has('proxy'), + ).to.equal(false); + } finally { + await server.close(); + } + }); + + it('rejects a proxy change for an echoed session handle', async () => { + const server = await makeAcceptingServer(); + try { + const opened = await getOrCreateSession( + 'mcp-proxy-conflict', + server.url, + 'tok', + { proxy: 'datacenter' }, + undefined, + undefined, + undefined, + false, + undefined, + 'proxy-conflict-handle', + ); + + let thrown: unknown; + try { + await getOrCreateSession( + 'mcp-proxy-conflict-2', + server.url, + 'tok', + { proxy: 'residential' }, + undefined, + undefined, + undefined, + false, + undefined, + opened.handle, + ); + } catch (error) { + thrown = error; + } + + expect((thrown as Error).message).to.match(/proxy.*fixed/i); + } finally { + await server.close(); + } + }); + + it('retains persona when a dropped socket is recreated by handle', async () => { + const server = await makeAcceptingServer(); + try { + const opened = await getOrCreateSession( + 'mcp-dropped-persona', + server.url, + 'tok', + undefined, + undefined, + undefined, + undefined, + false, + undefined, + 'dropped-persona-handle', + undefined, + undefined, + undefined, + undefined, + undefined, + { emulationOs: 'windows', screen: '1920x1080' }, + ); + const closed = new Promise((resolve) => + opened.ws.once('close', () => resolve()), + ); + opened.ws.terminate(); + await closed; + + const resumed = await getOrCreateSession( + 'mcp-dropped-persona-2', + server.url, + 'tok', + undefined, + undefined, + undefined, + undefined, + false, + undefined, + 'dropped-persona-handle', + ); + + expect(resumed.ws).to.not.equal(opened.ws); + expect(resumed.persona).to.deep.equal({ + emulationOs: 'windows', + screen: '1920x1080', + }); + const reconnectUrl = new URL(server.upgradeUrls()[1]!, server.url); + expect(reconnectUrl.searchParams.get('emulationOs')).to.equal('windows'); + expect(reconnectUrl.searchParams.get('screen')).to.equal('1920x1080'); + } finally { + await server.close(); + } + }); + + it('retains the shipped OS alias when a dropped socket is recreated by handle', async () => { + const server = await makeAcceptingServer(); + try { + const opened = await getOrCreateSession( + 'mcp-dropped-os', + server.url, + 'tok', + undefined, + undefined, + undefined, + undefined, + false, + undefined, + 'dropped-os-handle', + undefined, + undefined, + 'windows', + ); + const closed = new Promise((resolve) => + opened.ws.once('close', () => resolve()), + ); + opened.ws.terminate(); + await closed; + + const resumed = await getOrCreateSession( + 'mcp-dropped-os-2', + server.url, + 'tok', + undefined, + undefined, + undefined, + undefined, + false, + undefined, + 'dropped-os-handle', + ); + + expect(resumed.persona).to.deep.equal({ emulationOs: 'windows' }); + const reconnectUrl = new URL(server.upgradeUrls()[1]!, server.url); + expect(reconnectUrl.searchParams.get('emulationOs')).to.equal('windows'); + } finally { + await server.close(); + } + }); + + it('rejects a conflicting persona while sharing an in-flight creation', async () => { + const server = await makeAcceptingServer(25); + try { + const open = (mcpSessionId: string, emulationOs: 'windows' | 'macos') => + getOrCreateSession( + mcpSessionId, + server.url, + 'tok', + undefined, + undefined, + undefined, + undefined, + false, + undefined, + 'shared-pending-persona', + undefined, + undefined, + undefined, + undefined, + undefined, + { emulationOs }, + ); + + const windows = open('mcp-pending-a', 'windows'); + const macos = open('mcp-pending-b', 'macos').catch( + (error: unknown) => error, + ); + expect((await windows).persona).to.deep.equal({ + emulationOs: 'windows', + }); + expect(await macos).to.be.instanceOf(PersonaConflictError); + } finally { + await server.close(); + } + }); + + it('rejects a recording-mode change while sharing an in-flight creation', async () => { + const server = await makeAcceptingServer(25); + try { + const unrecorded = echo( + 'mcp-pending-record-a', + server.url, + 'shared-pending-record', + false, + ); + const recorded = echo( + 'mcp-pending-record-b', + server.url, + 'shared-pending-record', + true, + ).catch((error: unknown) => error); + + expect((await unrecorded).record).to.equal(false); + expect(await recorded).to.be.instanceOf(PersonaConflictError); + } finally { + await server.close(); + } + }); + + it('rejects a conflicting proxy while sharing an in-flight creation', async () => { + const server = await makeAcceptingServer(25); + try { + const open = ( + mcpSessionId: string, + proxy: 'residential' | 'datacenter', + ) => + getOrCreateSession( + mcpSessionId, + server.url, + 'tok', + { proxy }, + undefined, + undefined, + undefined, + false, + undefined, + 'shared-pending-proxy', + ); + + const residential = open('mcp-pending-proxy-a', 'residential'); + const datacenter = open('mcp-pending-proxy-b', 'datacenter').catch( + (error: unknown) => error, + ); + expect((await residential).proxy).to.deep.equal({ + proxy: 'residential', + }); + expect(await datacenter).to.be.instanceOf(PersonaConflictError); + } finally { + await server.close(); + } + }); + + it('rejects persona before allocating a profile-creation session', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({ id: 'created-profile' }), { + status: 200, + }), + ); + try { + let thrown: unknown; + try { + await getOrCreateSession( + 'mcp-create-profile-persona', + 'http://127.0.0.1:1', + 'tok', + undefined, + undefined, + { name: 'demo' }, + undefined, + false, + undefined, + 'profile-persona-handle', + undefined, + undefined, + undefined, + undefined, + undefined, + { emulationOs: 'windows', screen: '1920x1080' }, + ); + } catch (error) { + thrown = error; + } + expect(thrown).to.be.instanceOf(PersonaConflictError); + expect(fetchStub.called).to.equal(false); + } finally { + fetchStub.restore(); + } + }); + + it('rejects recording before allocating a profile-creation session', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({ id: 'created-profile' }), { + status: 200, + }), + ); + try { + let thrown: unknown; + try { + await getOrCreateSession( + 'mcp-create-profile-recording', + 'http://127.0.0.1:1', + 'tok', + undefined, + undefined, + { name: 'demo' }, + undefined, + false, + undefined, + 'profile-recording-handle', + undefined, + undefined, + undefined, + undefined, + true, + ); + } catch (error) { + thrown = error; + } + expect(thrown).to.be.instanceOf(PersonaConflictError); + expect((thrown as Error).message).to.match( + /recording.*cannot.*profile creation/i, + ); + expect(fetchStub.called).to.equal(false); + } finally { + fetchStub.restore(); + } + }); + + it('rejects the legacy OS alias before attaching an existing browser', async () => { + const thrown = await getOrCreateSession( + 'mcp-attach-os', + 'http://127.0.0.1:1', + 'tok', + undefined, + undefined, + undefined, + 'sess-123', + false, + undefined, + undefined, + undefined, + undefined, + 'windows', + ).catch((error: unknown) => error); + + expect(thrown).to.be.instanceOf(PersonaConflictError); + expect((thrown as Error).message).to.match( + /cannot redefine an attached browser/i, + ); + }); + it('keeps an echoed handle scoped to its own token', async () => { const server = await makeAcceptingServer(); try { @@ -1001,7 +1736,65 @@ describe('agent-client createProfile with os and humanlike', () => { sinon.restore(); }); - it('forwards non-default os and humanlike as query params to POST /profile', async () => { + for (const recovery of ['reconnect', 'recreate'] as const) { + it(`preserves profile OS on ${recovery} after socket loss`, async () => { + const server = await makeRespondingServer(() => ({ ok: true })); + fetchStub = sinon.stub(globalThis, 'fetch').callsFake( + async () => + new Response( + JSON.stringify({ id: `profile-${fetchStub.callCount}` }), + { + status: 200, + }, + ), + ); + const open = (os?: string) => + getOrCreateSession( + `profile-${recovery}`, + server.url, + 'tok', + undefined, + undefined, + { name: 'recovery-profile' }, + undefined, + false, + undefined, + `profile-${recovery}-handle`, + undefined, + undefined, + os, + ); + + try { + const first = await open('macos'); + const closed = new Promise((resolve) => + first.ws.once('close', () => resolve()), + ); + first.ws.terminate(); + await closed; + + const session = recovery === 'reconnect' ? first : await open(); + const response = await send(session, 'getCookies'); + expect(response.result).to.deep.equal({ ok: true }); + expect(fetchStub.callCount).to.equal(recovery === 'reconnect' ? 1 : 2); + for (const call of fetchStub.getCalls()) { + expect( + new URL(call.args[0]).searchParams.get('emulationOs'), + ).to.equal('macos'); + } + const attach = new URL(server.upgradeUrls()[1]!, server.url); + expect(attach.searchParams.get('sessionId')).to.equal( + recovery === 'reconnect' ? 'profile-1' : 'profile-2', + ); + expect(attach.searchParams.has('emulationOs')).to.equal(false); + expect(session.persona?.emulationOs).to.equal('macos'); + } finally { + await server.close(); + } + }); + } + + it('forwards OS to profile creation and humanlike to the Agent attach', async () => { const server = await makeAcceptingServer(); try { fetchStub = sinon.stub(globalThis, 'fetch').resolves( @@ -1032,7 +1825,10 @@ describe('agent-client createProfile with os and humanlike', () => { const calledUrl = new URL(fetchStub.firstCall.args[0] as string); expect(calledUrl.pathname).to.equal('/profile'); expect(calledUrl.searchParams.get('emulationOs')).to.equal('macos'); - expect(calledUrl.searchParams.get('humanlike')).to.equal('true'); + expect(calledUrl.searchParams.has('humanlike')).to.equal(false); + const attachUrl = new URL(server.upgradeUrls()[0]!, server.url); + expect(attachUrl.searchParams.get('sessionId')).to.equal('sess-test-123'); + expect(attachUrl.searchParams.get('humanlike')).to.equal('true'); } finally { await server.close(); } @@ -1066,7 +1862,43 @@ describe('agent-client createProfile with os and humanlike', () => { } }); - it('forwards humanlike=false explicitly when humanlike is false', async () => { + it('forwards emulationOs through profile creation like the shipped os alias', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch').resolves( + new Response(JSON.stringify({ id: 'created-profile' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const server = await makeAcceptingServer(); + try { + await getOrCreateSession( + 'mcp-create-profile-emulation-os', + server.url, + 'tok', + undefined, + undefined, + { name: 'emulated-profile' }, + undefined, + false, + undefined, + 'emulated-profile-handle', + undefined, + undefined, + undefined, + undefined, + undefined, + { emulationOs: 'macos' }, + ); + + const calledUrl = new URL(fetchStub.firstCall.args[0] as string); + expect(calledUrl.searchParams.get('emulationOs')).to.equal('macos'); + } finally { + fetchStub.restore(); + await server.close(); + } + }); + + it('forwards humanlike=false explicitly on the Agent attach', async () => { const server = await makeAcceptingServer(); try { fetchStub = sinon.stub(globalThis, 'fetch').resolves( @@ -1095,7 +1927,10 @@ describe('agent-client createProfile with os and humanlike', () => { expect(fetchStub.calledOnce).to.be.true; const calledUrl = new URL(fetchStub.firstCall.args[0] as string); - expect(calledUrl.searchParams.get('humanlike')).to.equal('false'); + expect(calledUrl.searchParams.has('humanlike')).to.equal(false); + const attachUrl = new URL(server.upgradeUrls()[0]!, server.url); + expect(attachUrl.searchParams.get('sessionId')).to.equal('sess-hl-false'); + expect(attachUrl.searchParams.get('humanlike')).to.equal('false'); } finally { await server.close(); } diff --git a/test/skills/system-prompt.spec.ts b/test/skills/system-prompt.spec.ts index 740292aa..89d9e9db 100644 --- a/test/skills/system-prompt.spec.ts +++ b/test/skills/system-prompt.spec.ts @@ -40,3 +40,30 @@ describe('agent system prompt contextual snapshot guidance', () => { expect(COMPLIANT_AGENT_SYSTEM_PROMPT).to.not.include('clearSecrets'); }); }); + +describe('full Agent persona and proxy guidance', () => { + it('names the persona contract, timing, block signals, and proxy trade-off', () => { + for (const field of [ + 'emulationOs', + 'emulatedDevice', + 'screen', + 'deviceScaleFactor', + 'deviceSlot', + ]) { + expect(AGENT_SYSTEM_PROMPT).to.include(field); + } + for (const personaOnlyField of [ + 'emulationOs', + 'emulatedDevice', + 'deviceScaleFactor', + 'deviceSlot', + ]) { + expect(COMPLIANT_AGENT_SYSTEM_PROMPT).to.not.include(personaOnlyField); + } + expect(AGENT_SYSTEM_PROMPT).to.match(/very first call|first call/i); + expect(AGENT_SYSTEM_PROMPT).to.match(/Cloudflare|hard block/i); + expect(AGENT_SYSTEM_PROMPT).to.match(/datacenter/i); + expect(AGENT_SYSTEM_PROMPT).to.match(/lower-cost/i); + expect(AGENT_SYSTEM_PROMPT).to.match(/residential.*block/i); + }); +}); diff --git a/test/tools/agent.spec.ts b/test/tools/agent.spec.ts index 2ee14f17..424db6a4 100644 --- a/test/tools/agent.spec.ts +++ b/test/tools/agent.spec.ts @@ -964,14 +964,19 @@ describe('formatConnectError with proxy-injected errors', () => { const getAgentExecute = ( apiUrl: string, transport: McpConfig['transport'] = 'stdio', + analytics?: AnalyticsHelper, ): ((args: unknown, ctx: unknown) => unknown) => { const server = new FastMCP({ name: 'test', version: '0.1.0' }); const addToolSpy = sinon.spy(server, 'addTool'); - registerAgentTools(server, { - ...mockConfig, - browserlessApiUrl: apiUrl, - transport, - }); + registerAgentTools( + server, + { + ...mockConfig, + browserlessApiUrl: apiUrl, + transport, + }, + analytics, + ); const agentCall = addToolSpy .getCalls() .find((c) => c.args[0].name === 'browserless_agent'); @@ -1033,6 +1038,78 @@ describe('browserless_agent integration binding guard', () => { }); }); +describe('browserless_agent persona creation guard', () => { + afterEach(() => sinon.restore()); + + it('does not default a persona when attaching an existing browser', async () => { + const server = await makeRespondingServer(() => ({ elements: [] })); + try { + const analytics = new AnalyticsHelper(false); + const fire = sinon.stub(analytics, 'fireToolRequest'); + const execute = getAgentExecute(server.url, 'stdio', analytics); + await execute( + { method: 'snapshot' }, + { + ...mockContext, + sessionId: 'persona-attach-default', + session: { attachSessionId: 'existing-browser' }, + }, + ); + + const attachUrl = new URL(server.upgradeUrls()[0]!, server.url); + expect(attachUrl.searchParams.has('emulationOs')).to.equal(false); + expect(fire.firstCall.args[2].emulation_os).to.equal(undefined); + } finally { + await server.close(); + } + }); + + it('rejects createProfile with additional persona state before connecting', async () => { + const execute = getAgentExecute('http://127.0.0.1:1'); + try { + await execute( + { + createProfile: { name: 'demo' }, + emulationOs: 'windows', + screen: '1920x1080', + commands: [ + { method: 'goto', params: { url: 'https://example.com' } }, + ], + }, + { ...mockContext, sessionId: 'persona-create-guard' }, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match( + /persona.*cannot be combined with profile creation/i, + ); + } + }); +}); + +describe('browserless_agent recording creation guard', () => { + afterEach(() => sinon.restore()); + + it('rejects recording while attaching before connecting', async () => { + const execute = getAgentExecute('http://127.0.0.1:1'); + try { + await execute( + { method: 'snapshot', record: true }, + { + ...mockContext, + sessionId: 'recording-attach-guard', + session: { attachSessionId: 'existing-browser' }, + }, + ); + expect.fail('expected UserError'); + } catch (err) { + expect((err as Error).message).to.match( + /recording.*cannot.*attached browser/i, + ); + } + }); +}); + describe('browserless_agent retry-guard (runCommands)', () => { // Each test uses a distinct mcpSessionId so the module-level session // cache can't return a stale entry from a prior case. @@ -1141,6 +1218,53 @@ describe('browserless_agent retry-guard (runCommands)', () => { await srv.close(); } }); + + it('preserves a live session persona when recovering from a browser crash', async () => { + let snapshots = 0; + const srv = await makeRespondingServer((method) => { + if (method !== 'snapshot') return {}; + snapshots += 1; + if (snapshots === 2) { + return new AgentErrorFrame({ + code: 'BROWSER_CRASHED', + message: 'browser crashed', + }); + } + return { + url: 'https://example.com/', + title: 'Example', + elements: [], + time: 1, + }; + }); + try { + const execute = getAgentExecute(srv.url); + const opened = (await execute( + { method: 'snapshot', emulationOs: 'macos' }, + ctx('retry-persona-open'), + )) as { content: Array<{ text?: string }> }; + const openedText = opened.content + .map((item) => item.text ?? '') + .join('\n'); + const sessionId = /sessionId: (\S+)/.exec(openedText)?.[1]; + expect(sessionId).to.match(/^s:/); + + await execute( + { method: 'snapshot', sessionId }, + ctx('retry-persona-follow-up'), + ); + + expect( + srv.hits(), + 'the browser-crash recovery opened one replacement', + ).to.equal(2); + expect( + new URL(srv.upgradeUrls()[1]!, srv.url).searchParams.get('emulationOs'), + ).to.equal('macos'); + } finally { + await srv.close(); + } + }); }); describe('browserless_agent _prompt capture', () => { @@ -1426,6 +1550,20 @@ describe('browserless_agent _prompt capture', () => { expect(props).to.not.have.property('error_category'); }); + it('reports the canonical emulation OS when the shipped alias is used', async () => { + const { execute, fire } = registerWithAnalytics(mockConfig); + + await execute( + { method: 'close', os: 'macos' }, + { ...mockContext, sessionId: 'analytics-os-alias' }, + ); + + expect(fire.firstCall.args[2]).to.include({ + emulation_os: 'macos', + persona_requested: true, + }); + }); + it('joins a live URL result and distinguishes reused from idle-evicted sessions', async () => { const clock = sinon.useFakeTimers({ now: 1000, toFake: ['Date'] }); const srv = await makeRespondingServer((method) => diff --git a/test/tools/compliance-mode.spec.ts b/test/tools/compliance-mode.spec.ts index 18ea886d..35a80efb 100644 --- a/test/tools/compliance-mode.spec.ts +++ b/test/tools/compliance-mode.spec.ts @@ -277,6 +277,20 @@ describe('compliance mode — compliant tool surface', () => { }).success, 'profile must be rejected', ).to.be.false; + for (const extra of [ + { emulationOs: 'windows' }, + { emulatedDevice: 'pixel-8' }, + { screen: '1920x1080' }, + { deviceScaleFactor: 1 }, + { deviceSlot: 0 }, + { proxy: { proxy: 'datacenter' } }, + ]) { + expect( + agent.parameters.safeParse({ commands: [VALID_GOTO], ...extra }) + .success, + JSON.stringify(extra), + ).to.be.false; + } expect( agent.parameters.safeParse({ commands: [VALID_GOTO], @@ -573,6 +587,12 @@ describe('compliance mode — compliant tool surface', () => { { profile: 'my-profile' }, { createProfile: { name: 'x' } }, { proxy: { proxy: 'residential', proxyCountry: 'us' } }, + { proxy: { proxy: 'datacenter' } }, + { emulationOs: 'windows' }, + { emulatedDevice: 'pixel-8' }, + { screen: '1920x1080' }, + { deviceScaleFactor: 1 }, + { deviceSlot: 0 }, ]) { try { await execute({ commands: [VALID_GOTO], ...extra }, mockCtx); diff --git a/test/tools/schemas.spec.ts b/test/tools/schemas.spec.ts index 3549795a..e2279ba4 100644 --- a/test/tools/schemas.spec.ts +++ b/test/tools/schemas.spec.ts @@ -7,6 +7,7 @@ import { import { AgentParamsSchema } from '../../src/tools/agent.js'; import { FunctionParamsSchema } from '../../src/tools/function.js'; import { + PERSONA_FIELDS, ProxyOptionsSchema, PROXY_FIELDS, } from '../../src/lib/agent-client.js'; @@ -231,6 +232,25 @@ describe('ProxyOptionsSchema', () => { }); describe('dependent-field refinement', () => { + const tierCases: Array<[string, Record, boolean]> = [ + ['residential preset', { proxy: 'residential', proxyPreset: 'px' }, true], + ['datacenter geo', { proxy: 'datacenter', proxyCountry: 'us' }, true], + ['datacenter sticky', { proxy: 'datacenter', proxySticky: true }, true], + ['datacenter preset', { proxy: 'datacenter', proxyPreset: 'px' }, false], + [ + 'external preset', + { externalProxyServer: 'http://host', proxyPreset: 'px' }, + false, + ], + ['orphan preset', { proxyPreset: 'px' }, false], + ]; + + for (const [name, value, accepted] of tierCases) { + it(`${accepted ? 'accepts' : 'rejects'} ${name}`, () => { + expect(ProxyOptionsSchema.safeParse(value).success).to.equal(accepted); + }); + } + it('accepts an empty object', () => { expect(() => ProxyOptionsSchema.parse({})).to.not.throw(); }); @@ -241,6 +261,16 @@ describe('ProxyOptionsSchema', () => { ).to.not.throw(); }); + it('accepts datacenter with geo and sticky fields', () => { + expect(() => + ProxyOptionsSchema.parse({ + proxy: 'datacenter', + proxyCountry: 'us', + proxySticky: true, + }), + ).to.not.throw(); + }); + it('accepts externalProxyServer alone', () => { expect(() => ProxyOptionsSchema.parse({ @@ -329,7 +359,199 @@ describe('AgentParamsSchema.proxy', () => { }); }); +describe('AgentParamsSchema persona', () => { + it('accepts every persona option on the top-level Agent surface', () => { + const desktop = AgentParamsSchema.parse({ + method: 'goto', + params: { url: 'https://example.com' }, + emulationOs: 'windows', + screen: '1920x1080', + deviceScaleFactor: 1.25, + deviceSlot: 3, + }); + const android = AgentParamsSchema.parse({ + method: 'goto', + params: { url: 'https://example.com' }, + emulationOs: 'android', + emulatedDevice: 'pixel-8', + }); + expect(desktop.deviceSlot).to.equal(3); + expect(android.emulatedDevice).to.equal('pixel-8'); + expect(PERSONA_FIELDS).to.have.members([ + 'emulationOs', + 'emulatedDevice', + 'screen', + 'deviceScaleFactor', + 'deviceSlot', + ]); + }); + + it('normalizes surrounding whitespace in a desktop screen', () => { + const parsed = AgentParamsSchema.parse({ + method: 'snapshot', + emulationOs: 'windows', + screen: ' 1920x1080 ', + }); + + expect(parsed.screen).to.equal('1920x1080'); + }); + + it('enforces device and slot persona relationships locally', () => { + const cases: Array<[string, Record, boolean]> = [ + [ + 'Android device', + { emulationOs: 'android', emulatedDevice: 'pixel-8' }, + true, + ], + ['device without OS', { emulatedDevice: 'pixel-8' }, false], + [ + 'device on desktop', + { emulationOs: 'windows', emulatedDevice: 'pixel-8' }, + false, + ], + ['desktop slot', { emulationOs: 'windows', deviceSlot: 2 }, true], + ['slot without OS', { deviceSlot: 2 }, false], + ['slot on Android', { emulationOs: 'android', deviceSlot: 2 }, false], + ['desktop screen', { emulationOs: 'windows', screen: '1920x1080' }, true], + [ + 'desktop screen with OS alias', + { os: 'windows', screen: '1920x1080' }, + true, + ], + [ + 'malformed desktop screen', + { emulationOs: 'windows', screen: 'wide' }, + false, + ], + [ + 'undersized desktop screen', + { emulationOs: 'windows', screen: '320x200' }, + false, + ], + [ + 'oversized desktop screen', + { emulationOs: 'windows', screen: '8000x8000' }, + false, + ], + ['screen without OS', { screen: '1920x1080' }, false], + [ + 'screen on Android', + { emulationOs: 'android', screen: '1920x1080' }, + false, + ], + [ + 'desktop screen with DPR', + { + emulationOs: 'windows', + screen: '1920x1080', + deviceScaleFactor: 1.25, + }, + true, + ], + [ + 'DPR without screen', + { emulationOs: 'windows', deviceScaleFactor: 1.25 }, + false, + ], + [ + 'DPR without OS', + { screen: '1920x1080', deviceScaleFactor: 1.25 }, + false, + ], + ]; + for (const [name, extra, accepted] of cases) { + expect( + AgentParamsSchema.safeParse({ method: 'snapshot', ...extra }).success, + name, + ).to.equal(accepted); + } + }); + + it('rejects unknown operating systems, DPRs, and invalid slots', () => { + for (const extra of [ + { emulationOs: 'plan9' }, + { deviceScaleFactor: 2 }, + { deviceSlot: -1 }, + { deviceSlot: 1.5 }, + ]) { + expect(() => + AgentParamsSchema.parse({ + method: 'goto', + params: { url: 'https://example.com' }, + ...extra, + }), + ).to.throw(); + } + }); + + it('enforces the persona creation relationship matrix', () => { + const cases: Array<[string, Record, boolean]> = [ + ['persona launch', { emulationOs: 'windows' }, true], + ['profile creation', { createProfile: { name: 'demo' } }, true], + [ + 'profile creation with OS alias', + { createProfile: { name: 'demo' }, emulationOs: 'windows' }, + true, + ], + [ + 'profile creation with additional persona state', + { + createProfile: { name: 'demo' }, + emulationOs: 'windows', + screen: '1920x1080', + }, + false, + ], + ]; + for (const [name, extra, accepted] of cases) { + expect( + AgentParamsSchema.safeParse({ + commands: [ + { method: 'goto', params: { url: 'https://example.com' } }, + ], + ...extra, + }).success, + name, + ).to.equal(accepted); + } + }); + + it('accepts matching OS aliases and rejects conflicting aliases', () => { + expect( + AgentParamsSchema.safeParse({ + method: 'snapshot', + os: 'windows', + emulationOs: 'windows', + }).success, + ).to.equal(true); + expect( + AgentParamsSchema.safeParse({ + method: 'snapshot', + os: 'macos', + emulationOs: 'windows', + }).success, + ).to.equal(false); + }); +}); + describe('AgentParamsSchema recording batches', () => { + it('rejects recording during profile creation', () => { + expect( + AgentParamsSchema.safeParse({ + createProfile: { name: 'demo' }, + record: true, + method: 'snapshot', + }).success, + ).to.equal(false); + expect( + AgentParamsSchema.safeParse({ + createProfile: { name: 'demo' }, + record: false, + method: 'snapshot', + }).success, + ).to.equal(true); + }); + it('requires stopRecording to be final except before close', () => { expect( AgentParamsSchema.safeParse({