diff --git a/src/agents/tools/browser-tool.actions.ts b/src/agents/tools/browser-tool.actions.ts index 12cb54e323d..37fc4a099c5 100644 --- a/src/agents/tools/browser-tool.actions.ts +++ b/src/agents/tools/browser-tool.actions.ts @@ -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: { diff --git a/src/browser/client-actions-observe.ts b/src/browser/client-actions-observe.ts index 7f7d8cd6926..23cc21a1a37 100644 --- a/src/browser/client-actions-observe.ts +++ b/src/browser/client-actions-observe.ts @@ -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 }); } diff --git a/src/browser/routes/agent.act.ts b/src/browser/routes/agent.act.ts index af0d8e40794..4d453c630e0 100644 --- a/src/browser/routes/agent.act.ts +++ b/src/browser/routes/agent.act.ts @@ -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 }); }, }); }); diff --git a/src/browser/routes/agent.debug.ts b/src/browser/routes/agent.debug.ts index f5c0d7b2030..14caeced279 100644 --- a/src/browser/routes/agent.debug.ts +++ b/src/browser/routes/agent.debug.ts @@ -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 }); }, }); }); diff --git a/src/browser/routes/agent.shared.enrich-url.test.ts b/src/browser/routes/agent.shared.enrich-url.test.ts new file mode 100644 index 00000000000..2af5440e9c2 --- /dev/null +++ b/src/browser/routes/agent.shared.enrich-url.test.ts @@ -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 = { 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 = { 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 = { ok: true }; + enrichTabResponseBody(body, TAB, undefined); + expect(body.url).toBe("https://example.com/page"); + }); + + it("does not overwrite existing targetId", () => { + const body: Record = { ok: true, targetId: "existing-id" }; + enrichTabResponseBody(body, TAB); + expect(body.targetId).toBe("existing-id"); + }); + + it("does not overwrite existing url", () => { + const body: Record = { 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).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 = { ok: true }; + enrichTabResponseBody(body, { targetId: "tid-2" }); + expect(body.targetId).toBe("tid-2"); + expect(body.url).toBeUndefined(); + }); +}); diff --git a/src/browser/routes/agent.shared.ts b/src/browser/routes/agent.shared.ts index cc82e00d004..08e0ec8ede4 100644 --- a/src/browser/routes/agent.shared.ts +++ b/src/browser/routes/agent.shared.ts @@ -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).ok !== true + ) { + return false; + } + const record = body as Record; + 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( } 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).ok === true && + (interceptedBody as Record).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;