Merge 8eac832ea9bfda6774bf91dd647ba825e4b451d7 into 8a05c05596ca9ba0735dafd8e359885de4c2c969

This commit is contained in:
Edward Abrams 2026-03-20 22:55:33 -07:00 committed by GitHub
commit 017ee46a2b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 226 additions and 28 deletions

View File

@ -288,11 +288,37 @@ export async function executeConsoleAction(params: {
level,
targetId,
},
})) as { ok?: boolean; targetId?: string; messages?: unknown[] };
return formatConsoleToolResult(result);
})) as { ok?: boolean; targetId?: string; url?: string; messages?: unknown[] };
const wrapped = wrapBrowserExternalJson({
kind: "console",
payload: result,
includeWarning: false,
});
return {
content: [{ type: "text" as const, text: wrapped.wrappedText }],
details: {
...wrapped.safeDetails,
targetId: typeof result.targetId === "string" ? result.targetId : undefined,
url: typeof result.url === "string" ? result.url : undefined,
messageCount: Array.isArray(result.messages) ? result.messages.length : undefined,
},
};
}
const result = await browserConsoleMessages(baseUrl, { level, targetId, profile });
return formatConsoleToolResult(result);
const wrapped = wrapBrowserExternalJson({
kind: "console",
payload: result,
includeWarning: false,
});
return {
content: [{ type: "text" as const, text: wrapped.wrappedText }],
details: {
...wrapped.safeDetails,
targetId: result.targetId,
url: result.url,
messageCount: result.messages.length,
},
};
}
export async function executeActAction(params: {

View File

@ -25,7 +25,7 @@ function buildQuerySuffix(params: Array<[string, string | boolean | undefined]>)
export async function browserConsoleMessages(
baseUrl: string | undefined,
opts: { level?: string; targetId?: string; profile?: string } = {},
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string }> {
): Promise<{ ok: true; messages: BrowserConsoleMessage[]; targetId: string; url?: string }> {
const suffix = buildQuerySuffix([
["level", opts.level],
["targetId", opts.targetId],
@ -35,6 +35,7 @@ export async function browserConsoleMessages(
ok: true;
messages: BrowserConsoleMessage[];
targetId: string;
url?: string;
}>(withBaseUrl(baseUrl, `/console${suffix}`), { timeoutMs: 20000 });
}

View File

@ -558,7 +558,7 @@ export function registerBrowserAgentActRoutes(
clickRequest.timeoutMs = timeoutMs;
}
await pw.clickViaPlaywright(clickRequest);
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
return res.json({ ok: true });
}
case "type": {
const ref = toStringOrEmpty(body.ref) || undefined;
@ -626,7 +626,7 @@ export function registerBrowserAgentActRoutes(
typeRequest.timeoutMs = timeoutMs;
}
await pw.typeViaPlaywright(typeRequest);
return res.json({ ok: true, targetId: tab.targetId });
return res.json({ ok: true });
}
case "press": {
const key = toStringOrEmpty(body.key);
@ -656,7 +656,7 @@ export function registerBrowserAgentActRoutes(
key,
delayMs: delayMs ?? undefined,
});
return res.json({ ok: true, targetId: tab.targetId });
return res.json({ ok: true });
}
case "hover": {
const ref = toStringOrEmpty(body.ref) || undefined;
@ -699,7 +699,7 @@ export function registerBrowserAgentActRoutes(
selector,
timeoutMs: timeoutMs ?? undefined,
});
return res.json({ ok: true, targetId: tab.targetId });
return res.json({ ok: true });
}
case "scrollIntoView": {
const ref = toStringOrEmpty(body.ref) || undefined;
@ -750,7 +750,7 @@ export function registerBrowserAgentActRoutes(
scrollRequest.timeoutMs = timeoutMs;
}
await pw.scrollIntoViewViaPlaywright(scrollRequest);
return res.json({ ok: true, targetId: tab.targetId });
return res.json({ ok: true });
}
case "drag": {
const startRef = toStringOrEmpty(body.startRef) || undefined;
@ -801,7 +801,7 @@ export function registerBrowserAgentActRoutes(
endSelector,
timeoutMs: timeoutMs ?? undefined,
});
return res.json({ ok: true, targetId: tab.targetId });
return res.json({ ok: true });
}
case "select": {
const ref = toStringOrEmpty(body.ref) || undefined;
@ -854,7 +854,7 @@ export function registerBrowserAgentActRoutes(
values,
timeoutMs: timeoutMs ?? undefined,
});
return res.json({ ok: true, targetId: tab.targetId });
return res.json({ ok: true });
}
case "fill": {
const rawFields = Array.isArray(body.fields) ? body.fields : [];
@ -899,7 +899,7 @@ export function registerBrowserAgentActRoutes(
fields,
timeoutMs: timeoutMs ?? undefined,
});
return res.json({ ok: true, targetId: tab.targetId });
return res.json({ ok: true });
}
case "resize": {
const width = toNumber(body.width);
@ -927,7 +927,7 @@ export function registerBrowserAgentActRoutes(
width,
height,
});
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
return res.json({ ok: true });
}
case "wait": {
const timeMs = toNumber(body.timeMs);
@ -1001,7 +1001,7 @@ export function registerBrowserAgentActRoutes(
fn,
timeoutMs,
});
return res.json({ ok: true, targetId: tab.targetId });
return res.json({ ok: true });
}
case "evaluate": {
if (!evaluateEnabled) {
@ -1050,12 +1050,7 @@ export function registerBrowserAgentActRoutes(
evalRequest.timeoutMs = evalTimeoutMs;
}
const result = await pw.evaluateViaPlaywright(evalRequest);
return res.json({
ok: true,
targetId: tab.targetId,
url: tab.url,
result,
});
return res.json({ ok: true, result });
}
case "close": {
if (isExistingSession) {
@ -1152,7 +1147,7 @@ export function registerBrowserAgentActRoutes(
timeoutMs: timeoutMs ?? undefined,
maxChars: maxChars ?? undefined,
});
res.json({ ok: true, targetId: tab.targetId, response: result });
res.json({ ok: true, response: result });
},
});
});
@ -1204,7 +1199,7 @@ export function registerBrowserAgentActRoutes(
targetId: tab.targetId,
ref,
});
res.json({ ok: true, targetId: tab.targetId });
res.json({ ok: true });
},
});
});

View File

@ -32,7 +32,7 @@ export function registerBrowserAgentDebugRoutes(
targetId: tab.targetId,
level: level.trim() || undefined,
});
res.json({ ok: true, messages, targetId: tab.targetId });
res.json({ ok: true, messages });
},
});
});

View File

@ -0,0 +1,63 @@
import { describe, expect, it } from "vitest";
import { enrichTabResponseBody } from "./agent.shared.js";
const TAB = { targetId: "tid-1", url: "https://example.com/page" };
describe("enrichTabResponseBody", () => {
it("adds targetId and url to successful response", () => {
const body: Record<string, unknown> = { ok: true, data: "snapshot" };
const result = enrichTabResponseBody(body, TAB);
expect(result).toBe(true);
expect(body.targetId).toBe("tid-1");
expect(body.url).toBe("https://example.com/page");
});
it("prefers postRunUrl over tab.url", () => {
const body: Record<string, unknown> = { ok: true };
enrichTabResponseBody(body, TAB, "https://example.com/after-navigate");
expect(body.url).toBe("https://example.com/after-navigate");
});
it("falls back to tab.url when postRunUrl is undefined", () => {
const body: Record<string, unknown> = { ok: true };
enrichTabResponseBody(body, TAB, undefined);
expect(body.url).toBe("https://example.com/page");
});
it("does not overwrite existing targetId", () => {
const body: Record<string, unknown> = { ok: true, targetId: "existing-id" };
enrichTabResponseBody(body, TAB);
expect(body.targetId).toBe("existing-id");
});
it("does not overwrite existing url", () => {
const body: Record<string, unknown> = { ok: true, url: "https://existing.com" };
enrichTabResponseBody(body, TAB, "https://new.com");
expect(body.url).toBe("https://existing.com");
});
it("returns false for non-ok responses", () => {
const body = { ok: false, error: "not found" };
expect(enrichTabResponseBody(body, TAB)).toBe(false);
expect((body as Record<string, unknown>).targetId).toBeUndefined();
});
it("returns false for null body", () => {
expect(enrichTabResponseBody(null, TAB)).toBe(false);
});
it("returns false for array body", () => {
expect(enrichTabResponseBody([{ ok: true }], TAB)).toBe(false);
});
it("returns false for primitive body", () => {
expect(enrichTabResponseBody("ok", TAB)).toBe(false);
});
it("handles tab with no url and no postRunUrl", () => {
const body: Record<string, unknown> = { ok: true };
enrichTabResponseBody(body, { targetId: "tid-2" });
expect(body.targetId).toBe("tid-2");
expect(body.url).toBeUndefined();
});
});

View File

@ -5,6 +5,38 @@ import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { BrowserRequest, BrowserResponse } from "./types.js";
import { getProfileContext, jsonError } from "./utils.js";
/**
* Enrich a tab-targeting response body with targetId and URL.
* Pure function no side effects, no I/O. Mutates `body` in place.
*
* @returns true if enrichment was applied, false if body was not eligible.
*/
export function enrichTabResponseBody(
body: unknown,
tab: { targetId: string; url?: string },
postRunUrl?: string,
): boolean {
if (
!body ||
typeof body !== "object" ||
Array.isArray(body) ||
(body as Record<string, unknown>).ok !== true
) {
return false;
}
const record = body as Record<string, unknown>;
if (record.targetId === undefined) {
record.targetId = tab.targetId;
}
if (record.url === undefined) {
const resolvedUrl = postRunUrl || tab.url;
if (resolvedUrl) {
record.url = resolvedUrl;
}
}
return true;
}
export const SELECTOR_UNSUPPORTED_MESSAGE = [
"Error: 'selector' is not supported. Use 'ref' from snapshot instead.",
"",
@ -109,11 +141,92 @@ export async function withRouteTabContext<T>(
}
try {
const tab = await profileCtx.ensureTabAvailable(params.targetId);
return await params.run({
profileCtx,
tab,
cdpUrl: profileCtx.profile.cdpUrl,
});
// Enrich every successful tab-targeting response with the current page URL.
// This gives downstream consumers (security plugins, audit loggers, etc.)
// a consistent way to know which page was targeted without issuing a
// separate tabs query. Existing explicit handler values win; the wrapper
// only fills in missing fields.
//
// URL resolution happens *after* the handler runs so that actions which
// navigate (e.g. /act, /navigate) report the post-action URL, not a stale
// pre-run snapshot. We avoid a pre-run Playwright page lookup here to
// prevent doubling CDP connection latency — handlers that need a page
// already resolve one themselves.
// Capture original json so we can intercept after run() completes.
const originalJson = params.res.json.bind(params.res);
let interceptedBody: unknown = undefined;
let jsonCalled = false;
params.res.json = (body: unknown) => {
interceptedBody = body;
jsonCalled = true;
// Don't send yet — we'll enrich and send after run().
return params.res;
};
let result: T | undefined;
try {
result = await params.run({
profileCtx,
tab,
cdpUrl: profileCtx.profile.cdpUrl,
});
} catch (runErr) {
// Restore original res.json so error handling can actually send.
params.res.json = originalJson;
throw runErr;
}
// Now enrich and flush the intercepted response body.
if (jsonCalled) {
// Only resolve the live URL when the response is eligible for
// enrichment (ok: true). Skip the Playwright CDP lookup for error
// responses to avoid unnecessary connection latency.
let postRunUrl: string | undefined;
const isEligible =
interceptedBody &&
typeof interceptedBody === "object" &&
!Array.isArray(interceptedBody) &&
(interceptedBody as Record<string, unknown>).ok === true &&
(interceptedBody as Record<string, unknown>).url === undefined;
if (isEligible) {
// Resolve live URL *after* the handler ran, so navigating actions
// report the post-action URL. Try Playwright first (actual page
// state), fall back to tab metadata URL.
// Note: cross-site navigations that trigger a renderer swap may
// invalidate tab.targetId; in that case getPageForTargetId returns
// null and we fall back to the (possibly stale) tab.url.
// existing-session profiles set cdpUrl to "" (Chrome MCP auto-connect,
// no CDP port). Passing an empty cdpUrl to getPageForTargetId would
// always fail, so skip the Playwright lookup for those profiles and
// rely on tab.url (updated by the relay on each tabs.onUpdated event).
const cdpUrl = profileCtx.profile.cdpUrl;
if (cdpUrl) {
try {
const pwMod = await getPwAiModuleBase({ mode: "soft" });
if (pwMod?.getPageForTargetId) {
const page = await pwMod.getPageForTargetId({
cdpUrl,
targetId: tab.targetId,
});
if (page) {
postRunUrl = page.url();
}
}
} catch {
// Playwright unavailable — fall back to tab.url
}
}
}
enrichTabResponseBody(interceptedBody, tab, postRunUrl);
// Restore res.json before flushing so that if originalJson throws
// (e.g. BigInt serialization), the outer catch can still send errors.
params.res.json = originalJson;
originalJson(interceptedBody);
}
return result;
} catch (err) {
handleRouteError(params.ctx, params.res, err);
return undefined;