Merge 8eac832ea9bfda6774bf91dd647ba825e4b451d7 into 8a05c05596ca9ba0735dafd8e359885de4c2c969
This commit is contained in:
commit
017ee46a2b
@ -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: {
|
||||
|
||||
@ -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 });
|
||||
}
|
||||
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@ -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 });
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
63
src/browser/routes/agent.shared.enrich-url.test.ts
Normal file
63
src/browser/routes/agent.shared.enrich-url.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user