feat(browser): include current page URL in all tab-targeting responses

Enrich every browser action response with the resolved page URL so
downstream consumers (security plugins, audit loggers) know which page
was targeted without a separate tabs query.

- Add shared withRouteTabContext URL enrichment wrapper (agent.shared.ts)
- Resolve live URL via Playwright, fall back to tab list URL
- Include url field in browser-tool console message results
- Push URL changes from Chrome extension background script

Co-authored-by: Eddie Abrams <eddie@bighatbio.com>
This commit is contained in:
zeroaltitude 2026-02-27 21:19:35 -07:00 committed by zeroaltitude
parent 91d37ccfc3
commit a66693b025
No known key found for this signature in database
GPG Key ID: 77592FB1C703882E
5 changed files with 92 additions and 15 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

@ -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, targetId: tab.targetId, url: tab.url });
}
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, targetId: tab.targetId, url: tab.url });
}
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, targetId: tab.targetId, url: tab.url });
}
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, targetId: tab.targetId, url: tab.url });
}
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, targetId: tab.targetId, url: tab.url });
}
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, targetId: tab.targetId, url: tab.url });
}
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, targetId: tab.targetId, url: tab.url });
}
case "resize": {
const width = toNumber(body.width);
@ -1001,7 +1001,7 @@ export function registerBrowserAgentActRoutes(
fn,
timeoutMs,
});
return res.json({ ok: true, targetId: tab.targetId });
return res.json({ ok: true, targetId: tab.targetId, url: tab.url });
}
case "evaluate": {
if (!evaluateEnabled) {
@ -1152,7 +1152,7 @@ export function registerBrowserAgentActRoutes(
timeoutMs: timeoutMs ?? undefined,
maxChars: maxChars ?? undefined,
});
res.json({ ok: true, targetId: tab.targetId, response: result });
res.json({ ok: true, targetId: tab.targetId, url: tab.url, response: result });
},
});
});
@ -1204,7 +1204,7 @@ export function registerBrowserAgentActRoutes(
targetId: tab.targetId,
ref,
});
res.json({ ok: true, targetId: tab.targetId });
res.json({ ok: true, targetId: tab.targetId, url: tab.url });
},
});
});

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, targetId: tab.targetId, url: tab.url });
},
});
});

View File

@ -109,6 +109,56 @@ export async function withRouteTabContext<T>(
}
try {
const tab = await profileCtx.ensureTabAvailable(params.targetId);
// Enrich every successful tab-targeting response with the resolved tab's
// 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 values win;
// the wrapper only fills in missing fields.
//
// We attempt to resolve the *live* page URL via Playwright (which queries
// the actual browser page), falling back to the tab list URL if
// Playwright is unavailable. This corrects stale URL caches when the
// user navigates in the browser without triggering a relay metadata
// refresh (e.g. Chrome extension relay).
let liveUrl: string | undefined;
try {
const pwMod = await getPwAiModuleBase({ mode: "soft" });
if (pwMod?.getPageForTargetId) {
const page = await pwMod.getPageForTargetId({
cdpUrl: profileCtx.profile.cdpUrl,
targetId: tab.targetId,
});
if (page) {
liveUrl = page.url();
}
}
} catch {
// Playwright not available or page not found — fall back to tab.url
}
const resolvedUrl = liveUrl || tab.url;
const originalJson = params.res.json.bind(params.res);
params.res.json = (body: unknown) => {
if (
body &&
typeof body === "object" &&
!Array.isArray(body) &&
(body as Record<string, unknown>).ok === true
) {
const record = body as Record<string, unknown>;
if (record.targetId === undefined) {
record.targetId = tab.targetId;
}
if (resolvedUrl) {
// Always override url with live value — the route may have used a
// stale tab.url from the relay cache.
record.url = resolvedUrl;
}
}
return originalJson(body);
};
return await params.run({
profileCtx,
tab,