Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/@types/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export interface ActiveSession {
readonly compliant: boolean;
reconnecting?: Promise<WebSocket>;
skillState: SkillFireState;
secretVisible: boolean;
lastUsedAt: number;
lastUrl?: string;
lastElements?: Map<string, SnapshotElement>;
Expand Down
81 changes: 66 additions & 15 deletions src/lib/agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,22 +226,8 @@ export class PersonaConflictError extends Error {
// stacks more lingering sessions against the same limit, so stop instead.
const NON_RETRYABLE_UPGRADE_STATUSES = new Set([400, 401, 403, 404, 429]);

class SessionReuseError extends Error {}

const assertCompatibleRecordingMode = (
session: ActiveSession,
record: boolean | undefined,
): void => {
if (record !== undefined && record !== (session.record ?? false)) {
throw new SessionReuseError(
'Browser recording mode cannot be changed on an open session. Omit record to reuse it, or close the session before changing the record option.',
);
}
};

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
// can't fix the shape (and may duplicate side effects), so don't.
Expand Down Expand Up @@ -442,6 +428,7 @@ export const buildAgentWsUrl = (
// guards already reject them, but hard-drop here too so no caller path can
// put them on the wire (last line of defense before the upstream connect).
if (!compliant) {
if (proxy) ProxyOptionsSchema.parse(proxy);
if (proxy?.proxy) url.searchParams.set('proxy', proxy.proxy);
if (proxy?.proxyCountry)
url.searchParams.set('proxyCountry', proxy.proxyCountry);
Expand Down Expand Up @@ -487,6 +474,70 @@ export const buildAgentWsUrl = (
return url.toString();
};

interface AgentCapability {
available?: boolean;
availableAt?: string[];
}

interface AgentCapabilityManifest {
version: number;
route: string;
capabilities: Record<string, AgentCapability>;
}

/** Validate declared plan requirements before opening a browser session. */
export const preflightAgentCapabilities = async (
agentUrl: string,
required: string[],
): Promise<void> => {
if (required.length === 0) return;

const url = new URL(agentUrl);
url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:';
url.pathname += '/capabilities';

const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
if (!res.ok) {
await res.body?.cancel().catch(() => {});
throw new Error(
`Capability discovery failed on /chromium/agent (${res.status}). Verify the token, plan, and route parameters.`,
);
}

let manifest: AgentCapabilityManifest;
try {
manifest = (await res.json()) as AgentCapabilityManifest;
} catch {
throw new Error('Capability discovery returned invalid JSON.');
}
if (
manifest?.version !== 1 ||
typeof manifest.route !== 'string' ||
!manifest.capabilities ||
typeof manifest.capabilities !== 'object' ||
Array.isArray(manifest.capabilities)
) {
throw new Error('Capability discovery returned an unsupported manifest.');
}

const missing = required.filter(
(name) => manifest.capabilities[name]?.available !== true,
);
if (missing.length === 0) return;

const details = missing.map((name) => {
const availableAt = manifest.capabilities[name]?.availableAt;
return Array.isArray(availableAt) &&
availableAt.length > 0 &&
availableAt.every((route) => typeof route === 'string')
? `${name} (available on ${availableAt.join(', ')})`
: `${name} (not advertised by this endpoint)`;
});
throw new Error(
`Invalid parameters: required capabilities are unavailable on ${manifest.route}: ${details.join('; ')}.`,
);
};

// HTTP-status failures arrive on `unexpected-response` (typed as
// UpgradeError), so a 1006 close here only means a transport failure or a
// server crash before any HTTP response.
Expand Down Expand Up @@ -974,7 +1025,6 @@ export const getOrCreateSession = async (
existing.ws.readyState === WebSocket.OPEN &&
existing.source === source
) {
assertCompatibleRecordingMode(existing, record);
existing.lastUsedAt = Date.now();
onSession?.(true, Math.max(0, Date.now() - createdAt.get(existing)!));
return existing;
Expand Down Expand Up @@ -1071,6 +1121,7 @@ export const getOrCreateSession = async (
persona: effectivePersona,
record,
skillState: createSkillState(),
secretVisible: false,
lastUsedAt: Date.now(),
};
createdAt.set(session, Date.now());
Expand Down
107 changes: 107 additions & 0 deletions src/tools/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,15 @@ import {
type StoredDownload,
} from '../lib/download-store.js';
import {
buildAgentWsUrl,
getOrCreateSession,
send,
closeSession,
destroySession,
isRetryableUpgradeError,
PERSONA_FIELDS,
UpgradeError,
preflightAgentCapabilities,
} from '../lib/agent-client.js';
import type {
AgentParams,
Expand Down Expand Up @@ -88,6 +90,51 @@ export {

const SNAPSHOT_METHOD = 'snapshot';
const FATAL_CODES = new Set(['BROWSER_CRASHED']);
const SECRET_SAFE_METHODS = new Set([
'click',
'type',
'select',
'checkbox',
'hover',
'scroll',
'waitForSelector',
'waitForTimeout',
'uploadFile',
'saveProfile',
'reportSkillOutcome',
'close',
]);
const TOP_FRAME_NAVIGATION_METHODS = new Set([
'goto',
'back',
'forward',
'reload',
]);

export const validateSecretCaptureOrdering = (
commands: ReadonlyArray<{
method: string;
params?: Record<string, unknown>;
}>,
initiallyVisible = false,
): void => {
let secretVisible = initiallyVisible;
for (const command of commands) {
if (command.method === 'loadSecret') {
secretVisible = true;
} else if (
command.method === 'clearSecrets' ||
TOP_FRAME_NAVIGATION_METHODS.has(command.method)
) {
secretVisible = false;
} else if (secretVisible && !SECRET_SAFE_METHODS.has(command.method)) {
throw new Error(
`${command.method} cannot run after loadSecret while a credential may be on-screen. ` +
`Move the capture before loadSecret, or run clearSecrets or a top-frame navigation first.`,
);
}
}
};

const appendSkills = (
base: string,
Expand Down Expand Up @@ -883,6 +930,43 @@ export function registerAgentTools(
return [{ type: 'text' as const, text: 'Browser session closed.' }];
}

try {
validateSecretCaptureOrdering(commands);
} catch (err) {
lastCategory = 'INVALID_PARAMS';
sendAnalytics(false, err);
throw new UserError(
err instanceof Error
? err.message
: 'Secret capture preflight failed.',
);
}

try {
await preflightAgentCapabilities(
buildAgentWsUrl(
apiUrl,
token,
proxy,
profile,
attachSessionId,
compliant,
integrationId,
allowedDomains,
emulationOs,
humanlike,
record,
persona,
),
params.requiredCapabilities ?? [],
);
Comment thread
xsvfat marked this conversation as resolved.
} catch (err) {
sendAnalytics(false, err);
throw new UserError(
err instanceof Error ? err.message : 'Capability preflight failed.',
);
}

// Open-only call: no real command (e.g. `createProfile`/`profile`/`proxy`
// set with no method/commands). Dispatching the empty-method default would
// make the agent route reject it as `Missing required id/method`, so just
Expand Down Expand Up @@ -1010,6 +1094,17 @@ export function registerAgentTools(
? { failed_method: cmd.method }
: {}),
});
try {
validateSecretCaptureOrdering([cmd], agentSession.secretVisible);
} catch (err) {
lastCategory = 'INVALID_PARAMS';
throw new UserError(
err instanceof Error
? err.message
: 'Secret capture preflight failed.',
);
}

if (cmd.method === 'close') {
closeSession(
mcpSessionId,
Expand Down Expand Up @@ -1198,6 +1293,18 @@ export function registerAgentTools(
);
}

if (cmd.method === 'loadSecret') {
agentSession.secretVisible = true;
} else if (
cmd.method === 'clearSecrets' ||
(TOP_FRAME_NAVIGATION_METHODS.has(cmd.method) &&
resp.result !== null &&
typeof resp.result === 'object' &&
(resp.result as { rejected?: unknown }).rejected !== true)
) {
agentSession.secretVisible = false;
}

// Capture the first URL we observe in the batch as a fallback
// baseline for the cross-origin notice.
if (!crossOriginBaseline) {
Expand Down
19 changes: 19 additions & 0 deletions src/tools/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -939,6 +939,16 @@ const withAgentInvariants = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) =>
'`profile` (hydrate an existing profile) and `createProfile` (author a new ' +
'one) cannot both be set',
})
.refine(
({ method, params, commands }) =>
(Array.isArray(commands) && commands.length > 0) ||
method !== 'clearSecrets' ||
ClearSecretsCommandSchema.safeParse({ method, params }).success,
{
message: '`clearSecrets` does not accept parameters',
path: ['params'],
},
)
Comment thread
artiom marked this conversation as resolved.
.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`.',
Expand Down Expand Up @@ -968,6 +978,14 @@ const withAgentInvariants = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) =>
);

const agentParamsObject = z.object({
requiredCapabilities: z
.array(z.string().trim().min(1))
.optional()
.describe(
'Capabilities the planned flow requires (for example "vision", "os-spoofing", ' +
'"datacenter-proxy", or "secret-capture"). Browserless checks the selected ' +
'route and plan before opening a browser and names an available route on failure.',
),
method: z
.string()
.optional()
Expand Down Expand Up @@ -1136,6 +1154,7 @@ const COMPLIANT_COMMANDS_DESCRIPTION =
// Shared top-level fields for both compliant schemas (rich + slim projection)
// so they can't drift; `.strict()` on each rejects any prohibited/removed key.
const compliantParamsObject = z.object({
requiredCapabilities: agentParamsObject.shape.requiredCapabilities,
rationale: z
.string()
.optional()
Expand Down
Loading