Skip to content

Commit c40cc4b

Browse files
authored
fix: make browser verification model-independent (#250)
## Summary - replace provider-backed browser observation with Stagehand native accessibility snapshots - keep XPath mappings inside the sandbox driver and expose only page-bound, single-use refs - return the post-action page tree so functional verification needs no second inference call - align web/mobile prompts, tool descriptions, capabilities, skills, and architecture docs ## Architecture The driver snapshots the active page locally, exposes bounded hyphenated refs, and retains the ref-to-XPath map in memory. browser_act accepts one ref plus a validated method/value, enforces URL and origin binding, consumes the observation, executes deterministically with self-healing disabled, and returns a fresh state tree. ## Decisions - Native snapshot instead of changing observation models: removes provider-specific structured-output failure and latency for every agent model. - Server-held selectors instead of returning XPath: prevents selector invention and keeps the trust boundary inside the driver. - Post-action tree in the act response instead of a second extract: makes the acceptance flow bounded and deterministic. ## Verification - pnpm lint - pnpm typecheck - pnpm turbo build --force - pnpm deadcode - pnpm architecture:check - pnpm turbo skills:build - node --check infra/containers/sandbox/browser-driver/server.js Production browser QA follows after the sandbox image is built and promoted.
1 parent 965a341 commit c40cc4b

9 files changed

Lines changed: 262 additions & 123 deletions

File tree

infra/containers/sandbox/README.md

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,13 @@ suggested breaking Stagehand downgrade. Static checks fail on
156156
moderate-or-higher findings across every sandbox lock without hiding this
157157
low-severity report.
158158

159-
Browser interaction uses Stagehand's observe-then-act boundary. The driver stores the exact
160-
actions from the latest observation for the active page and executes only an unchanged, single-use
161-
match. Direct natural-language actions and Stagehand self-healing are disabled so an interaction
162-
cannot trigger a second hidden model decision or keep retrying a stale selector. Navigation clears
163-
the observation, and origin interception remains active for the deterministic execution.
159+
Browser interaction uses Stagehand's native accessibility snapshot followed by deterministic act.
160+
The driver returns hyphenated page refs while retaining their XPath map server-side, then accepts
161+
one bounded method/value against a single-use ref from the latest active-page observation. The act
162+
result includes a fresh post-action tree, so verification needs no second observation inference.
163+
Stagehand observation inference, natural-language actions, and self-healing are disabled; browser
164+
behavior is independent of the selected model's structured-output quirks. Navigation clears the
165+
observation, and origin interception remains active for deterministic execution.
164166

165167
Snapshot publication builds and scans the exact local AMD64 image before pushing it
166168
to Daytona. Trivy fails on every fixable medium-or-higher vulnerability and every

infra/containers/sandbox/browser-driver/server.js

Lines changed: 143 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -19,26 +19,56 @@ const MAX_PROVIDER_RESPONSE_BYTES = 16 * 1024 * 1024;
1919
// An 8 MiB PNG expands to roughly 10.7 MiB as base64 before JSON framing.
2020
const MAX_RESPONSE_BYTES = 12 * 1024 * 1024;
2121
const MAX_SCREENSHOT_BYTES = 8 * 1024 * 1024;
22+
const MAX_SNAPSHOT_TREE_BYTES = 140 * 1024;
2223
const REQUEST_BODY_TIMEOUT_MS = 30 * 1000;
23-
const ObservedActionSchema = z.strictObject({
24-
arguments: z.array(z.string().max(2_000)).max(10).optional(),
25-
backendNodeId: z.number().int().positive().optional(),
26-
description: z.string().min(1).max(2_000),
27-
method: z.enum([
28-
"click",
29-
"doubleClick",
30-
"dragAndDrop",
31-
"fill",
32-
"hover",
33-
"nextChunk",
34-
"press",
35-
"prevChunk",
36-
"scrollTo",
37-
"selectOptionFromDropdown",
38-
"type",
39-
]),
40-
selector: z.string().min(1).max(4_096).startsWith("xpath="),
41-
});
24+
const BrowserElementRefSchema = z.string().regex(/^\d+-\d+$/u).max(64);
25+
const BrowserActionMethodSchema = z.enum([
26+
"click",
27+
"doubleClick",
28+
"dragAndDrop",
29+
"fill",
30+
"hover",
31+
"nextChunk",
32+
"press",
33+
"prevChunk",
34+
"scrollTo",
35+
"selectOptionFromDropdown",
36+
"type",
37+
]);
38+
const BROWSER_VALUE_METHODS = new Set([
39+
"fill",
40+
"press",
41+
"scrollTo",
42+
"selectOptionFromDropdown",
43+
"type",
44+
]);
45+
const BoundActionSchema = z.strictObject({
46+
ref: BrowserElementRefSchema,
47+
targetRef: BrowserElementRefSchema.optional(),
48+
value: z.string().max(2_000).optional(),
49+
method: BrowserActionMethodSchema,
50+
}).superRefine(validateBoundActionShape);
51+
52+
function validateBoundActionShape(action, context) {
53+
const needsValue = BROWSER_VALUE_METHODS.has(action.method);
54+
if (needsValue !== (action.value !== undefined)) {
55+
context.addIssue({
56+
code: "custom",
57+
message: needsValue ? `${action.method} requires value` : `${action.method} rejects value`,
58+
path: ["value"],
59+
});
60+
}
61+
const needsTarget = action.method === "dragAndDrop";
62+
if (needsTarget !== (action.targetRef !== undefined)) {
63+
context.addIssue({
64+
code: "custom",
65+
message: needsTarget
66+
? "dragAndDrop requires targetRef"
67+
: `${action.method} rejects targetRef`,
68+
path: ["targetRef"],
69+
});
70+
}
71+
}
4272
const bootstrap = await readBootstrapConfig();
4373
const PORT = bootstrap.port;
4474
const MODEL_NAME = bootstrap.modelName;
@@ -393,12 +423,9 @@ async function runAction(runtime, action) {
393423
return runGuardedAct(runtime, page, action);
394424
}
395425
if (action.type === "observe") {
396-
const result = await stagehand.observe(action.instruction, { page });
397-
latestObservation = {
398-
actions: result.map(normalizeObservedAction),
399-
url: page.url(),
400-
};
401-
return { result, type: action.type, url: page.url() };
426+
const observation = await capturePageObservation(page);
427+
latestObservation = observation.boundary;
428+
return { result: observation.state, type: action.type, url: page.url() };
402429
}
403430
if (action.type === "extract") {
404431
const result = await stagehand.extract(action.instruction, { page });
@@ -422,7 +449,7 @@ async function runAction(runtime, action) {
422449
async function runGuardedAct(runtime, page, action) {
423450
const { stagehand } = runtime;
424451
assertExpectedBrowserTarget(page.url(), action.expectedUrl, action.allowedOrigin);
425-
const observedAction = requireObservedAction(action.action, page.url());
452+
const observedAction = requireBoundAction(action.action, page.url());
426453
latestObservation = undefined;
427454
let failure;
428455
let originInterceptor;
@@ -438,7 +465,15 @@ async function runGuardedAct(runtime, page, action) {
438465
await originInterceptor.assertHealthy();
439466
const activePage = await stagehand.context.awaitActivePage();
440467
assertAllowedBrowserOrigin(activePage.url(), action.allowedOrigin);
441-
response = { result, type: action.type, url: activePage.url() };
468+
response = {
469+
result: {
470+
action: { method: action.action.method, ref: action.action.ref },
471+
stagehand: result,
472+
state: await capturePageState(activePage),
473+
},
474+
type: action.type,
475+
url: activePage.url(),
476+
};
442477
} catch (error) {
443478
failure = error;
444479
}
@@ -456,32 +491,88 @@ async function runGuardedAct(runtime, page, action) {
456491
return response;
457492
}
458493

459-
function normalizeObservedAction(action) {
460-
const parsed = ObservedActionSchema.safeParse(action);
461-
if (!parsed.success) {
462-
throw new Error("Browser observation returned an invalid action");
463-
}
464-
return parsed.data;
494+
async function capturePageObservation(page) {
495+
const snapshot = await page.snapshot({ includeIframes: true });
496+
const state = boundedSnapshotState(snapshot.formattedTree);
497+
return {
498+
boundary: {
499+
refs: snapshotRefMap(state.tree, snapshot.xpathMap),
500+
url: page.url(),
501+
},
502+
state,
503+
};
465504
}
466505

467-
function requireObservedAction(action, pageUrl) {
468-
const normalized = validateObservedAction(action);
506+
async function capturePageState(page) {
507+
const snapshot = await page.snapshot({ includeIframes: true });
508+
return boundedSnapshotState(snapshot.formattedTree);
509+
}
510+
511+
function requireBoundAction(action, pageUrl) {
512+
const normalized = validateBoundAction(action);
469513
const observation = latestObservation;
470-
if (
471-
!observation ||
472-
observation.url !== pageUrl ||
473-
!observation.actions.some((candidate) => actionsEqual(candidate, normalized))
474-
) {
514+
if (!observation || observation.url !== pageUrl) {
475515
throw new RequestError(
476516
409,
477-
"Browser action was not returned by the latest observation for this page",
517+
"Browser action is not bound to the latest observation for this page",
478518
);
479519
}
480-
return normalized;
520+
const selector = observedSelector(observation.refs, normalized.ref);
521+
const args = browserActionArguments(observation.refs, normalized);
522+
return {
523+
arguments: args,
524+
description: `${normalized.method} observed element [${normalized.ref}]`,
525+
method: normalized.method,
526+
selector,
527+
};
528+
}
529+
530+
function observedSelector(refs, ref) {
531+
const xpath = refs.get(ref);
532+
if (!xpath) {
533+
throw new RequestError(409, "Browser action ref is absent from the latest observation");
534+
}
535+
const selector = xpath.startsWith("xpath=") ? xpath : `xpath=${xpath}`;
536+
if (selector.length > 4_096) {
537+
throw new RequestError(409, "Browser action ref resolves to an invalid selector");
538+
}
539+
return selector;
540+
}
541+
542+
function browserActionArguments(refs, action) {
543+
if (action.method === "dragAndDrop") {
544+
return [observedSelector(refs, action.targetRef)];
545+
}
546+
return action.value === undefined ? [] : [action.value];
481547
}
482548

483-
function actionsEqual(left, right) {
484-
return JSON.stringify(left) === JSON.stringify(right);
549+
function boundedSnapshotState(formattedTree) {
550+
const tree = String(formattedTree ?? "");
551+
if (Buffer.byteLength(tree) <= MAX_SNAPSHOT_TREE_BYTES) {
552+
return { tree, truncated: false };
553+
}
554+
const suffix = "\n[Snapshot truncated: narrow the page state before observing again]";
555+
const selected = [];
556+
let byteLength = Buffer.byteLength(suffix);
557+
for (const line of tree.split("\n")) {
558+
const lineBytes = Buffer.byteLength(line) + (selected.length > 0 ? 1 : 0);
559+
if (byteLength + lineBytes > MAX_SNAPSHOT_TREE_BYTES) break;
560+
selected.push(line);
561+
byteLength += lineBytes;
562+
}
563+
return { tree: `${selected.join("\n")}${suffix}`, truncated: true };
564+
}
565+
566+
function snapshotRefMap(tree, xpathMap) {
567+
const refs = new Map();
568+
for (const match of tree.matchAll(/^\s*\[(\d+-\d+)\]/gmu)) {
569+
const ref = match[1];
570+
const xpath = xpathMap[ref];
571+
if (typeof xpath === "string" && xpath.length > 0 && xpath.length <= 4_090) {
572+
refs.set(ref, xpath);
573+
}
574+
}
575+
return refs;
485576
}
486577

487578
async function discardBrowserRuntime(runtime) {
@@ -587,7 +678,7 @@ function validateAction(action) {
587678
return action;
588679
}
589680
if (action.type === "act") {
590-
action.action = validateObservedAction(action.action);
681+
action.action = validateBoundAction(action.action);
591682
const expectedUrl = assertHttpUrl(action.expectedUrl);
592683
const allowedOrigin = assertHttpUrl(action.allowedOrigin);
593684
if (allowedOrigin.href !== `${allowedOrigin.origin}/` || expectedUrl.origin !== allowedOrigin.origin) {
@@ -601,7 +692,10 @@ function validateAction(action) {
601692
}
602693
return action;
603694
}
604-
if (action.type === "observe" || action.type === "extract") {
695+
if (action.type === "observe") {
696+
return action;
697+
}
698+
if (action.type === "extract") {
605699
assertInstruction(action.instruction);
606700
return action;
607701
}
@@ -614,10 +708,10 @@ function validateAction(action) {
614708
throw new RequestError(400, "Browser action type is unsupported");
615709
}
616710

617-
function validateObservedAction(action) {
618-
const parsed = ObservedActionSchema.safeParse(action);
711+
function validateBoundAction(action) {
712+
const parsed = BoundActionSchema.safeParse(action);
619713
if (!parsed.success) {
620-
throw new RequestError(400, "Observed browser action is invalid");
714+
throw new RequestError(400, "Ref-bound browser action is invalid");
621715
}
622716
return parsed.data;
623717
}

packages/agent-core/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -43,11 +43,12 @@ Tools execute autonomously inside the active request context. Sandbox operations
4343
remain project-root confined, browser actions remain origin-bound, connected-app
4444
actions remain scoped to the user's active account, and secret-bearing input is
4545
validated before execution. Deterministic prepare/execute boundaries keep dynamic
46-
ports and Git destinations stable between resolution and execution. The managed
47-
browser follows the same boundary: observation returns page-bound executable actions,
48-
and execution accepts only an unchanged action from the latest observation. Natural-language
49-
inference therefore runs once during observation; a click or fill cannot silently invoke another
50-
model decision, reuse a stale selector, or cross the active origin. The managed
46+
ports and Git destinations stable between resolution and execution. The managed browser follows
47+
the same boundary: observation reads Stagehand's native accessibility snapshot without model
48+
inference and returns page-bound element refs. Execution accepts only a single-use ref from the
49+
latest observation plus a bounded method/value, resolves its server-held XPath, and returns the
50+
post-action snapshot. A click or fill therefore cannot invoke a hidden model decision, expose a
51+
selector, reuse a stale ref, or cross the active origin. The managed
5152
preview tool owns Computer-visible dev servers, remaps a requested port to the
5253
project’s allocated port when necessary, injects the supported framework binding when the model
5354
omits it, and is distinct from generic background process tools so idle recovery always has a

packages/agent-core/src/mastra/system-prompt.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,7 @@ Beyond these you also have browser, document-generation, data-analysis, web-rese
206206
const WEB_MODULE = `## Building web apps
207207
208208
Make the app real and complete: working features, real data flow, considered design. Default to a clean modern stack — React / Next.js. Ship something polished: sensible colour and type, responsive, mobile-first, no lorem ipsum, no dead buttons, no placeholder images. Write the files, add or update dependencies only when the app actually needs them, and start the dev server early with code_start_dev_server (request port 5173) so you're always working against the running app. The managed server restores an unchanged project's existing dependencies itself. Never substitute a shell process for the managed preview, even for a static server or when restarting an existing project.
209-
Verify it in the browser: open the app's INTERNAL address in the sandbox's headed Chromium — http://localhost:<port> (e.g. http://localhost:5173), NOT the external preview link (your sandbox browser can't reach that). Take one screenshot with the exact visual acceptance criterion; its result includes a visual PASS/FAIL assessment, so do not take another screenshot unless you changed the rendered output. To exercise one representative interaction, call browser_observe once for that exact interaction, pass one returned action unchanged to browser_act, then read the resulting state once. Never send prose directly to browser_act, invent selectors, write a separate Playwright/Python test, or install another browser. If either check fails, fix the concrete defect and repeat only that changed check once; never loop on equivalent screenshots or interactions. If the browser can't load it at all, note you couldn't visually verify and go straight to your closing summary. The running app is shown to the user automatically in the Computer panel's Browser tab — never paste the preview URL.`;
209+
Verify it in the browser: open the app's INTERNAL address in the sandbox's headed Chromium — http://localhost:<port> (e.g. http://localhost:5173), NOT the external preview link (your sandbox browser can't reach that). Take one screenshot with the exact visual acceptance criterion; its result includes a visual PASS/FAIL assessment, so do not take another screenshot unless you changed the rendered output. To exercise one representative interaction, call browser_observe once, choose one exact hyphenated element ref from its accessibility tree, and call browser_act with that ref plus the required method/value. browser_act returns the post-action page tree, so use that result to verify the interaction without another observation or extraction. Never invent a ref or selector, write a separate Playwright/Python test, or install another browser. If either check fails, fix the concrete defect and repeat only that changed check once; never loop on equivalent screenshots or interactions. If the browser can't load it at all, note you couldn't visually verify and go straight to your closing summary. The running app is shown to the user automatically in the Computer panel's Browser tab — never paste the preview URL.`;
210210

211211
const MOBILE_MODULE = `## Building the mobile app
212212
@@ -219,7 +219,7 @@ Build the Expo Router screens for a polished, native-feeling app: real screens,
219219
// keeps WEB_MODULE's "start the dev server yourself" guidance; this note only applies here.
220220
const APP_BUILDER_PREVIEW_NOTE = `## Your preview is already running — do not start your own
221221
222-
This project is scaffolded at the workspace root and its dev server + live preview are ALREADY running and managed for you before your turn begins (for a mobile app that's Metro serving the app on web plus the Expo Go QR). Do NOT initialize, scaffold, or create another app or nested project. Do NOT start, restart, or reconfigure the server yourself — no code_start_dev_server, \`expo start\`, \`npm run dev\`/\`web\`, or \`npx expo …\`: a second server fights the managed one for the project's port and breaks the preview. Use pnpm, never npm/npx, only when dependency changes are necessary. Inspect and edit the existing root files; the preview hot-reloads on save. Verify by opening the running app in the sandbox's headed Chromium at its INTERNAL localhost address; it's shown to the user automatically in the Computer/App panel — never paste the preview URL. Metro may briefly show an empty document while rebuilding the first web bundle after edits: wait for page content once and reload at most once before treating it as a defect. Take one screenshot with the exact visual acceptance criterion and use its returned PASS/FAIL assessment; never judge screenshot byte size. Exercise one representative interaction by calling browser_observe once, passing one returned action unchanged to browser_act, and reading the resulting state once. Never write a separate Playwright/Python test or install another browser. If a check fails, fix the concrete defect and repeat only that changed check once. Once the requested content renders, that interaction passes, and no blocking browser error remains, finish.`;
222+
This project is scaffolded at the workspace root and its dev server + live preview are ALREADY running and managed for you before your turn begins (for a mobile app that's Metro serving the app on web plus the Expo Go QR). Do NOT initialize, scaffold, or create another app or nested project. Do NOT start, restart, or reconfigure the server yourself — no code_start_dev_server, \`expo start\`, \`npm run dev\`/\`web\`, or \`npx expo …\`: a second server fights the managed one for the project's port and breaks the preview. Use pnpm, never npm/npx, only when dependency changes are necessary. Inspect and edit the existing root files; the preview hot-reloads on save. Verify by opening the running app in the sandbox's headed Chromium at its INTERNAL localhost address; it's shown to the user automatically in the Computer/App panel — never paste the preview URL. Metro may briefly show an empty document while rebuilding the first web bundle after edits: wait for page content once and reload at most once before treating it as a defect. Take one screenshot with the exact visual acceptance criterion and use its returned PASS/FAIL assessment; never judge screenshot byte size. Exercise one representative interaction by calling browser_observe once, choosing one exact hyphenated element ref from its accessibility tree, and calling browser_act with that ref plus the required method/value. Use browser_act's post-action tree as the result check; do not observe or extract again. Never invent a ref or selector, write a separate Playwright/Python test, or install another browser. If a check fails, fix the concrete defect and repeat only that changed check once. Once the requested content renders, that interaction passes, and no blocking browser error remains, finish.`;
223223

224224
const DOCS_MODULE = `## Building documents & slides
225225

packages/agent-core/src/mastra/tool-defs/browser-tools.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const mastraBrowserOpen = createTool({
2828
export const mastraBrowserAct = createTool({
2929
id: "browser_act",
3030
description:
31-
"Execute one exact action returned by the immediately preceding browser_observe call. Pass the returned action object unchanged; invented or stale actions are rejected.",
31+
"Execute one deterministic action against an exact element ref from the immediately preceding browser_observe tree. The ref is page-bound and single-use; the result includes the post-action page tree.",
3232
inputSchema: BrowserActInputSchema,
3333
outputSchema: BrowserActionsOutputSchema,
3434
execute: async (input, context) => {
@@ -49,7 +49,7 @@ export const mastraBrowserAct = createTool({
4949
export const mastraBrowserObserve = createTool({
5050
id: "browser_observe",
5151
description:
52-
"Find executable actions for one explicit interaction in the current sandbox browser page. Select one returned action and pass it unchanged to browser_act.",
52+
"Read the current sandbox page as a deterministic accessibility tree with page-bound element refs. Choose an exact hyphenated ref from the tree for browser_act; no secondary model is invoked.",
5353
inputSchema: BrowserObserveInputSchema,
5454
outputSchema: BrowserActionsOutputSchema,
5555
execute: async (input, context) =>

0 commit comments

Comments
 (0)