feat(record): capture interactions in embedded frames

Track recording per document so injectable child frames are captured while partial frame failures remain visible in exported traces.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
“haonan”
2026-08-17 14:41:02 +08:00
co-authored by Cursor
parent 61a0e0d218
commit f7e08f461b
28 changed files with 1374 additions and 144 deletions
+4
View File
@@ -17,3 +17,7 @@ dist/
# Environment
.env.local
.env.*.local
.pnpm-store/
# Local recording output
/trace/
+63 -38
View File
@@ -1,4 +1,5 @@
import { useTranslation } from "@browser-skill/i18n/react";
import type { FrameCaptureFailure, FrameCaptureStatus } from "@/transport/types";
import {
type PointerEvent as ReactPointerEvent,
useCallback,
@@ -21,6 +22,8 @@ export interface RecordRequestData {
type Props = {
request: RecordRequestData | null;
captureStatus?: FrameCaptureStatus;
captureFailures?: FrameCaptureFailure[];
};
const VIEWPORT_MARGIN = 16;
@@ -52,7 +55,11 @@ function clampDragPos(
};
}
export function RecordOverlay({ request }: Props) {
export function RecordOverlay({
request,
captureStatus = "complete",
captureFailures = [],
}: Props) {
const { t } = useTranslation("extension");
const [show, setShow] = useState(false);
const [elapsedSeconds, setElapsedSeconds] = useState(0);
@@ -154,6 +161,7 @@ export function RecordOverlay({ request }: Props) {
const introVisible = elapsedSeconds * 1_000 < INTRO_DURATION_MS;
const positioned = dragPos !== null;
const partialCapture = captureStatus === "partial" || captureFailures.length > 0;
const defaultTransform = show
? "translateX(-50%) translateY(0)"
: "translateX(-50%) translateY(8px)";
@@ -208,8 +216,9 @@ export function RecordOverlay({ request }: Props) {
data-slot="record-overlay-status"
style={{
display: "flex",
alignItems: "center",
gap: 8,
flexDirection: "column",
alignItems: "flex-start",
gap: 2,
minWidth: 0,
flex: 1,
borderRadius: 9999,
@@ -218,41 +227,57 @@ export function RecordOverlay({ request }: Props) {
paddingRight: 4,
}}
>
<span
data-slot="record-overlay-indicator"
style={{
width: 9,
height: 9,
borderRadius: "50%",
backgroundColor: "#ef4444",
flexShrink: 0,
animation: "bsk-rec-pulse 1.4s ease-in-out infinite",
}}
/>
<span
data-slot="record-overlay-label"
style={{
display: "block",
flex: "0 1 auto",
fontSize: 16,
fontWeight: 500,
color: "#333",
whiteSpace: "nowrap",
userSelect: "none",
overflow: "hidden",
textOverflow: "ellipsis",
width: introVisible ? "auto" : 48,
maxWidth: introVisible ? 280 : 48,
fontVariantNumeric: introVisible ? undefined : "tabular-nums",
textAlign: introVisible ? "left" : "center",
margin: 0,
padding: 0,
minWidth: 0,
transition: "max-width 180ms ease-out",
}}
>
{introVisible ? t("recordOverlay.recording") : formatElapsed(elapsedSeconds)}
</span>
<div style={{ display: "flex", alignItems: "center", gap: 8, minWidth: 0, width: "100%" }}>
<span
data-slot="record-overlay-indicator"
style={{
width: 9,
height: 9,
borderRadius: "50%",
backgroundColor: "#ef4444",
flexShrink: 0,
animation: "bsk-rec-pulse 1.4s ease-in-out infinite",
}}
/>
<span
data-slot="record-overlay-label"
style={{
display: "block",
flex: "0 1 auto",
fontSize: 16,
fontWeight: 500,
color: "#333",
whiteSpace: "nowrap",
userSelect: "none",
overflow: "hidden",
textOverflow: "ellipsis",
width: introVisible ? "auto" : 48,
maxWidth: introVisible ? 280 : 48,
fontVariantNumeric: introVisible ? undefined : "tabular-nums",
textAlign: introVisible ? "left" : "center",
margin: 0,
padding: 0,
minWidth: 0,
transition: "max-width 180ms ease-out",
}}
>
{introVisible ? t("recordOverlay.recording") : formatElapsed(elapsedSeconds)}
</span>
</div>
{partialCapture ? (
<span
data-slot="record-overlay-partial-warning"
style={{
fontSize: 12,
lineHeight: "16px",
color: "#b45309",
whiteSpace: "normal",
maxWidth: 280,
}}
>
{t("recordOverlay.partialCapture")}
</span>
) : null}
</div>
<button
type="button"
@@ -178,4 +178,30 @@ describe("RecordOverlay", () => {
expect(pill.style.bottom).toBe("auto");
expect(pill.style.transform).toBe("none");
});
it("shows a persistent partial-capture warning when frame capture is incomplete", () => {
const { container } = render(
createElement(
I18nextProvider,
{ i18n },
createElement(RecordOverlay, {
request: { id: "rec-1", onFinish: vi.fn() },
captureStatus: "partial",
captureFailures: [
{
reason: "not_injectable",
frame_id: 2,
url: "chrome://settings",
},
],
}),
),
);
const warning = container.querySelector("[data-slot='record-overlay-partial-warning']");
expect(warning).toBeTruthy();
expect(warning?.textContent).toBe(
i18n.t("recordOverlay.partialCapture", { ns: "extension" }),
);
});
});
@@ -1614,3 +1614,79 @@ describe("record-capture semantic", () => {
expect(steps.map((s) => s.op)).toEqual(["click"]);
});
});
describe("record-capture child frame", () => {
let steps: RecordStepPayload[];
beforeEach(() => {
steps = [];
document.body.innerHTML = `
<label for="child-q">Child query</label>
<input id="child-q" name="child-q" />
<button type="button" aria-label="Child search">Search</button>
<select aria-label="Child status">
<option value="on">On</option>
<option value="off">Off</option>
</select>
`;
});
it("stamps a document token on start and clears it on dispose", () => {
const capture = startRecordCapture("rec-child-token", (step) => steps.push(step), {
frameMode: "child",
});
const token = document.documentElement.getAttribute("data-bsk-record-document");
expect(token).toBeTruthy();
click(document.querySelector("button")!);
expect(steps[0]?.documentToken).toBe(token);
expect(steps[0]?.frame_url).toBeTruthy();
capture.dispose();
expect(document.documentElement.hasAttribute("data-bsk-record-document")).toBe(false);
});
it("records click, fill, select, and press in child frame mode", () => {
const capture = startRecordCapture("rec-child-ops", (step) => steps.push(step), {
frameMode: "child",
});
const input = document.querySelector("input")!;
const select = document.querySelector("select")!;
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
input.value = "child value";
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new FocusEvent("focusout", { bubbles: true }));
select.value = "off";
select.dispatchEvent(new Event("change", { bubbles: true }));
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
click(document.querySelector("button")!);
capture.dispose();
expect(steps.map((s) => s.op)).toEqual(
expect.arrayContaining(["fill", "select", "press", "click"]),
);
expect(steps.every((s) => s.documentToken)).toBe(true);
});
it("commits the last fill when dispose runs in child frame mode", () => {
const capture = startRecordCapture("rec-child-fill-stop", (step) => steps.push(step), {
frameMode: "child",
});
const input = document.querySelector("input")!;
input.dispatchEvent(new FocusEvent("focusin", { bubbles: true }));
input.value = "pending child fill";
input.dispatchEvent(new Event("input", { bubbles: true }));
capture.dispose();
expect(steps.some((s) => s.op === "fill" && s.value === "pending child fill")).toBe(true);
});
it("does not emit navigate steps for child frame navigations", () => {
const capture = startRecordCapture("rec-child-nav", (step) => steps.push(step), {
frameMode: "child",
});
history.pushState({}, "", "/child-frame-path");
capture.dispose();
expect(steps.filter((s) => s.op === "navigate")).toHaveLength(0);
});
});
+48 -8
View File
@@ -21,12 +21,18 @@ import {
RECORD_STEP,
RECORD_STOP,
type RecordCancelMessage,
type RecordFrameMode,
type RecordStartAck,
type RecordStartMessage,
type RecordStepPayload,
type RecordStopAck,
type RecordStopMessage,
} from "@/lib/record-bridge";
import {
clearRecordDocumentToken,
createRecordDocumentToken,
stampRecordDocumentToken,
} from "@/lib/record-document-token";
import { shouldRecordPress } from "@/lib/trace-reducer";
import {
closestHoverSurfaceCandidate,
@@ -402,12 +408,37 @@ function scheduleInputCompletionCommit(
setTimeout(syncAndCommit, 0);
}
export interface RecordCaptureOptions {
frameMode?: RecordFrameMode;
}
export function startRecordCapture(
_requestId: string,
sendStep: (step: RecordStepPayload) => void,
options: RecordCaptureOptions = {},
): RecordCaptureController {
const frameMode = options.frameMode ?? "top";
const emitNavigate = frameMode === "top";
const documentToken = createRecordDocumentToken();
stampRecordDocumentToken(documentToken);
const capturedAtMs = () => Date.now();
const resolveTopPageUrl = (): string => {
try {
return window.top?.location.href ?? location.href;
} catch {
return location.href;
}
};
const emitStep = (step: RecordStepPayload) => {
sendStep({ page_url: location.href, ...step });
sendStep({
page_url: resolveTopPageUrl(),
frame_url: location.href,
documentToken,
capturedAtMs: capturedAtMs(),
...step,
});
};
const hoverSurfaceStateMap = (states: HoverSurfaceState[]): Map<Element, string> =>
new Map(states.map((state) => [state.element, state.signature]));
@@ -478,6 +509,7 @@ export function startRecordCapture(
};
const emitNavigateIfChanged = (causedByAction?: boolean) => {
if (!emitNavigate) return;
if (location.href === lastUrl) return;
commitFillSession();
lastUrl = location.href;
@@ -965,6 +997,7 @@ export function startRecordCapture(
return {
dispose() {
commitFillSession();
clearRecordDocumentToken();
document.removeEventListener("click", onClick, true);
document.removeEventListener("mouseover", onMouseOver, true);
document.removeEventListener("focusin", onFocusIn, true);
@@ -997,8 +1030,8 @@ export function handleRecordContentMessage(
capture: RecordCaptureController | null;
setActiveRequestId(id: string | null): void;
setCapture(capture: RecordCaptureController | null): void;
onStart(requestId: string, startedAtMs?: number): void;
onStop(): void;
onStart?(requestId: string, startedAtMs?: number): void;
onStop?(): void;
},
sendResponse?: (response: RecordStartAck | RecordStopAck) => void,
): boolean {
@@ -1009,13 +1042,20 @@ export function handleRecordContentMessage(
failedStepPayloads.delete(message.requestId);
}
state.capture?.dispose();
const frameMode = message.frameMode ?? "top";
state.setCapture(
startRecordCapture(message.requestId, (step) => {
sendRecordStep(message.requestId, step);
}),
startRecordCapture(
message.requestId,
(step) => {
sendRecordStep(message.requestId, step);
},
{ frameMode },
),
);
state.setActiveRequestId(message.requestId);
state.onStart(message.requestId, message.startedAtMs);
if (message.showOverlay !== false && frameMode === "top") {
state.onStart?.(message.requestId, message.startedAtMs);
}
sendResponse?.({ ok: true });
return sendResponse !== undefined;
}
@@ -1030,7 +1070,7 @@ export function handleRecordContentMessage(
state.capture?.dispose();
state.setCapture(null);
const finishStop = () => {
state.onStop();
state.onStop?.();
state.setActiveRequestId(null);
};
if (isRecordStopMessage(message) && sendResponse) {
@@ -33,6 +33,9 @@ import {
defaultBorrowChromeWindows,
requestBorrowConfirmation,
} from "@/tools/borrow-confirmation";
import {
type RecordCaptureStatusMessage,
} from "@/lib/record-bridge";
import { ToolDispatcher } from "@/tools/dispatcher";
import {
attachRecordFinishListener,
@@ -175,6 +178,24 @@ export default defineBackground(() => {
cdp,
sendToTab: (tabId: number, msg: Parameters<typeof chrome.tabs.sendMessage>[1]) =>
chrome.tabs.sendMessage(tabId, msg),
sendToDocument: (
tabId: number,
documentId: string,
msg: Parameters<typeof chrome.tabs.sendMessage>[1],
) => chrome.tabs.sendMessage(tabId, msg, { documentId }),
getAllFrames: async (tabId: number) => {
if (!chrome.webNavigation?.getAllFrames) return [];
const frames = await chrome.webNavigation.getAllFrames({ tabId });
return (frames ?? []).map((frame) => ({
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
parentFrameId: frame.parentFrameId,
}));
},
broadcastCaptureStatus: async (tabId: number, msg: RecordCaptureStatusMessage) => {
await chrome.tabs.sendMessage(tabId, msg, { frameId: 0 });
},
bypassOverlay: async (tabId: number, enabled: boolean) => {
try {
await chrome.tabs.sendMessage(tabId, {
+23 -2
View File
@@ -44,12 +44,15 @@ import {
} from "@/lib/overlay-bridge";
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
import {
isRecordCaptureStatusMessage,
RECORD_FINISH,
RECORD_QUERY,
type RecordCaptureStatusMessage,
type RecordQueryResponse,
type RecordStartAck,
type RecordStopAck,
} from "@/lib/record-bridge";
import type { FrameCaptureFailure, FrameCaptureStatus } from "@/transport/types";
import type {
BorrowCancelMessage,
BorrowRequestMessage,
@@ -70,6 +73,8 @@ export default defineContentScript({
const overlays = new OverlayController();
let recordCapture: RecordCaptureController | null = null;
let activeRecordRequestId: string | null = null;
let recordCaptureStatus: FrameCaptureStatus = "complete";
let recordCaptureFailures: FrameCaptureFailure[] = [];
let reactRoot: ReactDOM.Root | null = null;
let overlayHost: HTMLElement | null = null;
let overlayContainer: HTMLElement | null = null;
@@ -188,7 +193,11 @@ export default defineContentScript({
requests: overlayState.borrowRequests,
}),
React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }),
React.createElement(RecordOverlay, { request: overlayState.activeRecord }),
React.createElement(RecordOverlay, {
request: overlayState.activeRecord,
captureStatus: recordCaptureStatus,
captureFailures: recordCaptureFailures,
}),
React.createElement(ControlOverlay, {
visible: controlOverlayVisible,
interrupting: overlayState.interrupting,
@@ -260,7 +269,8 @@ export default defineContentScript({
| CaptureSuppressMessage
| OverlayAgentOverlayResetMessage
| OverlayAgentStateMessage
| OverlayAutomationBypassMessage,
| OverlayAutomationBypassMessage
| RecordCaptureStatusMessage,
_sender: chrome.runtime.MessageSender,
sendResponse: (response: BorrowResponseMessage | HelpAckMessage | CaptureSuppressAck) => void,
) => {
@@ -268,6 +278,13 @@ export default defineContentScript({
return captureSuppress.handleMessage(message, sendResponse);
}
if (isRecordCaptureStatusMessage(message)) {
recordCaptureStatus = message.status;
recordCaptureFailures = message.failures ?? [];
renderAll();
return false;
}
if (isRecordContentMessage(message)) {
const needsAsync = handleRecordContentMessage(
message,
@@ -295,6 +312,8 @@ export default defineContentScript({
},
onStop: () => {
overlays.clearAgentRecordRequest(activeRecordRequestId ?? undefined);
recordCaptureStatus = "complete";
recordCaptureFailures = [];
renderAll();
},
},
@@ -449,6 +468,8 @@ export default defineContentScript({
) {
const requestId = recordQuery.requestId;
const startedAtMs = recordQuery.startedAtMs;
recordCaptureStatus = recordQuery.captureStatus ?? "complete";
recordCaptureFailures = recordQuery.captureFailures ?? [];
overlays.setAgentRecordRequest({
id: requestId,
...(typeof startedAtMs === "number" ? { startedAtMs } : {}),
@@ -0,0 +1,77 @@
import {
handleRecordContentMessage,
isRecordContentMessage,
type RecordCaptureController,
} from "@/content/record-capture";
import {
RECORD_QUERY,
type RecordQueryResponse,
} from "@/lib/record-bridge";
/** Capture-only content script for child frames (no React overlay). */
export default defineContentScript({
matches: ["<all_urls>"],
runAt: "document_end",
allFrames: true,
matchAboutBlank: true,
matchOriginAsFallback: true,
main(ctx) {
if (window.top === window) return;
let recordCapture: RecordCaptureController | null = null;
let activeRecordRequestId: string | null = null;
const onMessage = (
message: unknown,
_sender: chrome.runtime.MessageSender,
sendResponse: (response: unknown) => void,
) => {
if (isRecordContentMessage(message)) {
const needsAsync = handleRecordContentMessage(
message,
{
activeRequestId: activeRecordRequestId,
capture: recordCapture,
setActiveRequestId: (id) => {
activeRecordRequestId = id;
},
setCapture: (capture) => {
recordCapture = capture;
},
},
sendResponse as never,
);
return needsAsync;
}
return false;
};
async function queryActiveRecord(): Promise<void> {
try {
const recordQuery = (await chrome.runtime.sendMessage({
type: RECORD_QUERY,
})) as RecordQueryResponse | undefined;
if (
recordQuery?.active &&
typeof recordQuery.requestId === "string" &&
activeRecordRequestId === null
) {
// Background rearm sends RECORD_START; nothing to mount locally.
}
} catch (err) {
console.debug("[bsk record-frame] record query failed", err);
}
}
chrome.runtime.onMessage.addListener(onMessage);
void queryActiveRecord();
ctx.onInvalidated(() => {
chrome.runtime.onMessage.removeListener(onMessage);
recordCapture?.dispose();
recordCapture = null;
activeRecordRequestId = null;
});
},
});
@@ -113,4 +113,60 @@ describe("matchTarget", () => {
});
expect(target.ref).toBe("e1");
});
it("isolates geometry matching to the owner iframe backend node", () => {
const sharedRect = { x: 10, y: 20, w: 100, h: 30 };
const iframeRefs: RenderedRef[] = [
{ ref: "e1", backendNodeId: 42, role: "button", name: "Top", line: 1 },
{ ref: "e2", backendNodeId: 99, role: "button", name: "Iframe", line: 2 },
];
const target = matchTarget({
geometry: {
rect: sharedRect,
scrollX: 0,
scrollY: 0,
position: "static",
tag: "button",
ownerFrameBackendNodeId: 13,
},
captured: [
node(42, { rect: sharedRect, documentRect: sharedRect }),
node(99, {
rect: sharedRect,
documentRect: sharedRect,
ownerFrameBackendNodeId: 13,
}),
],
refs: iframeRefs,
fallback: { tag: "button", name: "Iframe" },
});
expect(target).toEqual({ ref: "e2", role: "button", name: "Iframe" });
});
it("matches iframe static elements using frame scroll and document coordinates", () => {
const iframeRefs: RenderedRef[] = [
{ ref: "e2", backendNodeId: 99, role: "button", name: "Iframe", line: 2 },
];
const target = matchTarget({
geometry: {
rect: { x: 10, y: 50, w: 100, h: 30 },
scrollX: 0,
scrollY: 120,
position: "static",
tag: "button",
ownerFrameBackendNodeId: 13,
},
captured: [
node(99, {
ownerFrameBackendNodeId: 13,
documentRect: { x: 10, y: 170, w: 100, h: 30 },
localRect: { x: 10, y: 50, w: 100, h: 30 },
rect: { x: 10, y: 50, w: 100, h: 30 },
}),
],
refs: iframeRefs,
fallback: { tag: "button", name: "Iframe" },
});
expect(target).toEqual({ ref: "e2", role: "button", name: "Iframe" });
});
});
@@ -33,6 +33,8 @@ function finalizedFillBody(value: string, redactValues: boolean): string {
obs.lastSettled = {
stateId,
captured: [capturedInput()],
matchNodes: [capturedInput()],
documentTokenOwners: new Map(),
refs: [{ ref: "e1", backendNodeId: 42, role: "textbox", name: "Password", line: 1 }],
url: URL,
vomText: rawVomText,
+1 -2
View File
@@ -40,9 +40,8 @@ function matchingRects(
target: { x: number; y: number; w: number; h: number };
node: { x: number; y: number; w: number; h: number } | null;
} {
const topFrame = (geometry.ownerFrameBackendNodeId ?? null) === null;
const viewportPosition = geometry.position === "fixed" || geometry.position === "sticky";
if (!topFrame || viewportPosition || !node.documentRect) {
if (viewportPosition || !node.documentRect) {
return { target: geometry.rect, node: nodeViewportRect(node) };
}
return {
+33 -1
View File
@@ -4,6 +4,7 @@
*/
import type { CaptureTargetDescriptor } from "./describe-target";
import type { FrameCaptureFailure, FrameCaptureStatus } from "@/transport/types";
export const RECORD_START = "bsk-record-start";
export const RECORD_STEP = "bsk-record-step";
@@ -11,6 +12,7 @@ export const RECORD_STOP = "bsk-record-stop";
export const RECORD_CANCEL = "bsk-record-cancel";
export const RECORD_FINISH = "bsk-record-finish";
export const RECORD_QUERY = "bsk-record-query";
export const RECORD_CAPTURE_STATUS = "bsk-record-capture-status";
export interface RecordStartAck {
ok: true;
@@ -27,8 +29,12 @@ export interface RecordQueryResponse {
requestId?: string;
/** Epoch ms when the recording began; see `RecordStartMessage.startedAtMs`. */
startedAtMs?: number;
captureStatus?: FrameCaptureStatus;
captureFailures?: FrameCaptureFailure[];
}
export type RecordFrameMode = "top" | "child";
export interface RecordStartMessage {
type: typeof RECORD_START;
requestId: string;
@@ -38,6 +44,10 @@ export interface RecordStartMessage {
* content-script remounts instead of restarting per page.
*/
startedAtMs?: number;
/** When true, the top-frame overlay should mount. Child frames omit this. */
showOverlay?: boolean;
/** Whether this document runs top-frame or child-frame capture semantics. */
frameMode?: RecordFrameMode;
}
export interface RecordStepPayload {
@@ -51,8 +61,14 @@ export interface RecordStepPayload {
url?: string;
redacted?: boolean;
commit?: "enter" | "suggestion" | "blur";
/** Page URL when the step was captured. */
/** Top-level tab URL when the step was captured. */
page_url?: string;
/** URL of the frame document where the interaction occurred. */
frame_url?: string;
/** Ephemeral token stamped on the active document root; not persisted in trace steps. */
documentToken?: string;
/** Epoch ms when the step was captured in the content script. */
capturedAtMs?: number;
/** Document geometry at capture time for background geometric matching. */
geometry?: {
rect: { x: number; y: number; w: number; h: number };
@@ -92,6 +108,12 @@ export interface RecordFinishMessage {
requestId: string;
}
export interface RecordCaptureStatusMessage {
type: typeof RECORD_CAPTURE_STATUS;
status: FrameCaptureStatus;
failures?: FrameCaptureFailure[];
}
export function isRecordStartMessage(msg: unknown): msg is RecordStartMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
@@ -122,6 +144,16 @@ export function isRecordQueryMessage(msg: unknown): msg is RecordQueryMessage {
return m.type === RECORD_QUERY;
}
export function isRecordCaptureStatusMessage(msg: unknown): msg is RecordCaptureStatusMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
return (
m.type === RECORD_CAPTURE_STATUS &&
(m.status === "complete" || m.status === "partial") &&
(m.failures === undefined || Array.isArray(m.failures))
);
}
export function isRecordStepMessage(msg: unknown): msg is RecordStepMessage {
if (typeof msg !== "object" || msg === null) return false;
const m = msg as Record<string, unknown>;
@@ -0,0 +1,24 @@
/** Attribute written on `<html>` while a document is actively recording. */
export const RECORD_DOCUMENT_TOKEN_ATTR = "data-bsk-record-document";
export function createRecordDocumentToken(): string {
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
return crypto.randomUUID();
}
return `doc-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
}
export function stampRecordDocumentToken(token: string): void {
document.documentElement.setAttribute(RECORD_DOCUMENT_TOKEN_ATTR, token);
}
export function clearRecordDocumentToken(): void {
document.documentElement.removeAttribute(RECORD_DOCUMENT_TOKEN_ATTR);
}
export function readRecordDocumentTokenFromAttrs(
attrs: Record<string, string> | undefined,
): string | undefined {
const token = attrs?.[RECORD_DOCUMENT_TOKEN_ATTR];
return token?.trim() ? token : undefined;
}
@@ -0,0 +1,250 @@
import {
RECORD_CANCEL,
RECORD_START,
RECORD_STOP,
type RecordCancelMessage,
type RecordStartAck,
type RecordStartMessage,
type RecordStopAck,
type RecordStopMessage,
} from "@/lib/record-bridge";
import type { FrameCaptureFailure, FrameCaptureFailureReason } from "@/transport/types";
export interface WebNavigationFrameInfo {
frameId: number;
documentId?: string;
url?: string;
parentFrameId: number;
}
export interface ArmedDocument {
tabId: number;
frameId: number;
documentId: string;
url?: string;
parentFrameId: number;
}
export interface RecordFrameSendDeps {
sendToDocument(
tabId: number,
documentId: string,
msg: RecordStartMessage | RecordStopMessage | RecordCancelMessage,
): Promise<unknown>;
getAllFrames(tabId: number): Promise<WebNavigationFrameInfo[]>;
}
const RECORD_START_RETRIES = 3;
const RECORD_START_RETRY_DELAY_MS = 500;
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export function isFrameUrlRestricted(url: string | undefined): boolean {
if (!url) return false;
const lower = url.toLowerCase();
return (
lower.startsWith("chrome://") ||
lower.startsWith("chrome-extension://") ||
lower.startsWith("edge://") ||
lower.startsWith("devtools://") ||
lower.startsWith("devtools:") ||
lower.startsWith("https://chrome.google.com/webstore")
);
}
function isRecordStartAck(response: unknown): response is RecordStartAck {
return (
typeof response === "object" &&
response !== null &&
"ok" in response &&
(response as RecordStartAck).ok === true
);
}
function isRecordStopAckOk(response: unknown): boolean {
return (
typeof response === "object" &&
response !== null &&
"ok" in response &&
(response as RecordStopAck).ok === true
);
}
export function makeFrameCaptureFailure(
reason: FrameCaptureFailureReason,
input: { frameId: number; documentId?: string; url?: string; detail?: string },
): FrameCaptureFailure {
return {
reason,
frame_id: input.frameId,
...(input.documentId ? { document_id: input.documentId } : {}),
...(input.url ? { url: input.url } : {}),
...(input.detail ? { detail: input.detail } : {}),
};
}
export async function listInjectableFrames(
tabId: number,
deps: RecordFrameSendDeps,
): Promise<{ injectable: WebNavigationFrameInfo[]; failures: FrameCaptureFailure[] }> {
const failures: FrameCaptureFailure[] = [];
let frames: WebNavigationFrameInfo[] = [];
try {
frames = await deps.getAllFrames(tabId);
} catch (err) {
failures.push(
makeFrameCaptureFailure("arm_failed", {
frameId: 0,
detail: err instanceof Error ? err.message : "webNavigation.getAllFrames failed",
}),
);
return { injectable: [], failures };
}
const injectable = frames.filter((frame) => {
if (!frame.documentId) {
failures.push(
makeFrameCaptureFailure("not_injectable", {
frameId: frame.frameId,
url: frame.url,
detail: "missing documentId",
}),
);
return false;
}
if (isFrameUrlRestricted(frame.url)) {
failures.push(
makeFrameCaptureFailure("not_injectable", {
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
}),
);
return false;
}
return true;
});
return { injectable, failures };
}
export async function sendRecordStartWithAckToDocument(
tabId: number,
documentId: string,
msg: RecordStartMessage,
deps: RecordFrameSendDeps,
): Promise<void> {
let lastError: unknown;
for (let attempt = 0; attempt < RECORD_START_RETRIES; attempt += 1) {
try {
const response = await deps.sendToDocument(tabId, documentId, msg);
if (isRecordStartAck(response)) return;
lastError = new Error("content script did not ack RECORD_START");
} catch (err) {
lastError = err;
}
if (attempt + 1 < RECORD_START_RETRIES) {
await sleep(RECORD_START_RETRY_DELAY_MS);
}
}
throw lastError ?? new Error("failed to start recording in content script");
}
export async function armDocumentCapture(
tabId: number,
frame: WebNavigationFrameInfo,
startMsg: RecordStartMessage,
deps: RecordFrameSendDeps,
): Promise<{ armed?: ArmedDocument; failure?: FrameCaptureFailure }> {
if (!frame.documentId) {
return {
failure: makeFrameCaptureFailure("not_injectable", {
frameId: frame.frameId,
url: frame.url,
detail: "missing documentId",
}),
};
}
if (isFrameUrlRestricted(frame.url)) {
return {
failure: makeFrameCaptureFailure("not_injectable", {
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
}),
};
}
try {
await sendRecordStartWithAckToDocument(tabId, frame.documentId, startMsg, deps);
return {
armed: {
tabId,
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
parentFrameId: frame.parentFrameId,
},
};
} catch (err) {
return {
failure: makeFrameCaptureFailure("arm_failed", {
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
detail: err instanceof Error ? err.message : "RECORD_START failed",
}),
};
}
}
export async function armAllDocumentsForTab(
tabId: number,
startMsg: RecordStartMessage,
deps: RecordFrameSendDeps,
): Promise<{ armed: ArmedDocument[]; failures: FrameCaptureFailure[] }> {
const { injectable, failures } = await listInjectableFrames(tabId, deps);
const armed: ArmedDocument[] = [];
for (const frame of injectable) {
const result = await armDocumentCapture(tabId, frame, startMsg, deps);
if (result.armed) armed.push(result.armed);
if (result.failure) failures.push(result.failure);
}
return { armed, failures };
}
export async function stopDocumentCapture(
armed: ArmedDocument,
stopMsg: RecordStopMessage,
deps: RecordFrameSendDeps,
): Promise<{ ok: true } | FrameCaptureFailure> {
try {
const response = await deps.sendToDocument(armed.tabId, armed.documentId, stopMsg);
if (isRecordStopAckOk(response)) return { ok: true };
return makeFrameCaptureFailure("flush_failed", {
frameId: armed.frameId,
documentId: armed.documentId,
url: armed.url,
detail: "content script did not confirm recorded steps",
});
} catch (err) {
return makeFrameCaptureFailure("flush_failed", {
frameId: armed.frameId,
documentId: armed.documentId,
url: armed.url,
detail: err instanceof Error ? err.message : "RECORD_STOP failed",
});
}
}
export async function cancelDocumentCapture(
armed: ArmedDocument,
cancelMsg: RecordCancelMessage,
deps: RecordFrameSendDeps,
): Promise<void> {
try {
await deps.sendToDocument(armed.tabId, armed.documentId, cancelMsg);
} catch {
// Best-effort cleanup.
}
}
+11 -1
View File
@@ -17,6 +17,8 @@ import { reduceTraceSteps, resolveTraceStartUrl } from "./trace-reducer";
export interface LastSettledObservation {
stateId: string;
captured: CapturedNode[];
matchNodes: CapturedNode[];
documentTokenOwners: Map<string, number | null>;
refs: RenderedRef[];
url: string;
title?: string;
@@ -40,6 +42,8 @@ export interface PendingRedirectLanding {
export interface RecordingObservationState {
stateRegistry: Map<string, StateRegistryEntry>;
lastSettled: LastSettledObservation | null;
/** Latest token→owner map from the most recent observation capture. */
documentTokenOwners: Map<string, number | null>;
maxPageTokens: number;
redactValues: boolean;
lastCaptureAtMs: number;
@@ -67,6 +71,7 @@ export function createObservationState(options?: {
return {
stateRegistry: new Map(),
lastSettled: null,
documentTokenOwners: new Map(),
maxPageTokens: options?.maxPageTokens ?? DEFAULT_MAX_PAGE_TOKENS,
redactValues: options?.redactValues ?? false,
lastCaptureAtMs: 0,
@@ -125,9 +130,12 @@ export async function captureAndRegisterObservation(
});
obs.lastCaptureAtMs = Date.now();
obs.documentTokenOwners = rendered.documentTokenOwners;
const settled: LastSettledObservation = {
stateId,
captured: rendered.captured,
matchNodes: rendered.matchNodes,
documentTokenOwners: rendered.documentTokenOwners,
refs: rendered.refs,
url,
title: resolvedTitle,
@@ -164,7 +172,7 @@ export function applyTargetMatching(
"geometry" in draft && draft.geometry
? matchTarget({
geometry: draft.geometry,
captured: obs.lastSettled.captured,
captured: obs.lastSettled.matchNodes,
refs: obs.lastSettled.refs,
fallback: draft.captureTarget,
})
@@ -636,6 +644,7 @@ export function buildTraceV3(input: {
startUrl?: string;
stoppedBy: StopReason;
bskVersion: string;
frameCapture?: Trace["frame_capture"];
}): Trace {
const { steps, stepIdByDraftId } = reduceTraceSteps(input.steps, input.obs.stateRegistry);
const published = selectPublishedStates(input.obs.stateRegistry, steps);
@@ -657,6 +666,7 @@ export function buildTraceV3(input: {
stopped_by: input.stoppedBy,
entry: { start_url: startUrl },
recorder: { bsk: input.bskVersion, vom: 1 },
...(input.frameCapture ? { frame_capture: input.frameCapture } : {}),
states,
steps,
};
@@ -0,0 +1,86 @@
import { describe, expect, it } from "vitest";
import { RECORD_DOCUMENT_TOKEN_ATTR } from "@/lib/record-document-token";
import type { CapturedNode } from "@/tools/vom/capture";
import { collectDocumentTokenOwners } from "../capture-vom-observation";
function htmlNode(
backendNodeId: number,
token: string,
ownerFrameBackendNodeId: number | null = null,
): CapturedNode {
return {
backendNodeId,
parentBackendNodeId: null,
tag: "html",
attrs: { [RECORD_DOCUMENT_TOKEN_ATTR]: token },
rect: { x: 0, y: 0, w: 1280, h: 720 },
paintOrder: 0,
position: "static",
pointerEvents: "auto",
...(ownerFrameBackendNodeId === null ? {} : { ownerFrameBackendNodeId }),
};
}
describe("collectDocumentTokenOwners", () => {
it("maps document tokens to their immediate iframe owner backend node", () => {
const topToken = "top-doc-token";
const childToken = "child-doc-token";
const owners = collectDocumentTokenOwners({
viewport: { width: 1280, height: 720 },
nodes: [htmlNode(1, topToken, null)],
iframeNodes: new Map([
[
13,
[
htmlNode(101, childToken, 13),
{
backendNodeId: 102,
parentBackendNodeId: 101,
tag: "input",
attrs: {},
rect: { x: 10, y: 20, w: 200, h: 30 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
ownerFrameBackendNodeId: 13,
},
],
],
]),
excludedBackendNodeIds: new Set(),
});
expect(owners.get(topToken)).toBeNull();
expect(owners.get(childToken)).toBe(13);
});
it("maps nested iframe documents to their direct owner, not the outer iframe", () => {
const nestedToken = "nested-doc-token";
const owners = collectDocumentTokenOwners({
viewport: { width: 1280, height: 720 },
nodes: [htmlNode(1, "top-token", null)],
iframeNodes: new Map([
[
13,
[
{
backendNodeId: 23,
parentBackendNodeId: 101,
tag: "iframe",
attrs: {},
rect: { x: 0, y: 0, w: 400, h: 300 },
paintOrder: 1,
position: "static",
pointerEvents: "auto",
ownerFrameBackendNodeId: 13,
},
],
],
[23, [htmlNode(201, nestedToken, 23)]],
]),
excludedBackendNodeIds: new Set(),
});
expect(owners.get(nestedToken)).toBe(23);
});
});
@@ -18,11 +18,13 @@ import {
handleRecordStart,
handleRecordStop,
resetBrowserObservationForTests,
type RecordDeps,
} from "../record";
const AGENT_WINDOW_ID = 100;
const TAB_ID = 4;
const START_URL = "https://example.com/";
const TOP_DOCUMENT_ID = "doc-top";
type RuntimeListener = (
message: unknown,
@@ -63,9 +65,18 @@ function installChrome() {
webNavigation: {
onCompleted: webNavigationOnCompleted,
onCommitted: webNavigationOnCommitted,
getAllFrames: vi.fn(async ({ tabId }: { tabId: number }) => [
{
frameId: 0,
documentId: TOP_DOCUMENT_ID,
url: START_URL,
parentFrameId: -1,
tabId,
},
]),
},
});
return { runtimeOnMessage, webNavigationOnCompleted, webNavigationOnCommitted };
return { runtimeOnMessage, webNavigationOnCompleted, webNavigationOnCommitted, TOP_DOCUMENT_ID };
}
function fakeManager() {
@@ -206,6 +217,57 @@ function makeTabsApi() {
};
}
function makeRecordDeps(
tabsApi: ReturnType<typeof makeTabsApi> | ReturnType<typeof makeMultiTabsApi>,
sendToTabMock: (tabId: number, msg: unknown) => Promise<unknown>,
cdp?: ReturnType<typeof makeFakeCdp>,
): RecordDeps {
return {
tabsApi,
sendToTab: sendToTabMock as RecordDeps["sendToTab"],
sendToDocument: (async (tabId, _documentId, msg) =>
sendToTabMock(tabId, msg)) as RecordDeps["sendToDocument"],
getAllFrames: (async () => [
{
frameId: 0,
documentId: TOP_DOCUMENT_ID,
url: START_URL,
parentFrameId: -1,
},
]) as RecordDeps["getAllFrames"],
...(cdp ? { cdp } : {}),
};
}
function makeMultiTabRecordDeps(
tabsApi: ReturnType<typeof makeMultiTabsApi>,
sendToTabMock: (tabId: number, msg: unknown) => Promise<unknown>,
tabIds: number[],
cdp?: ReturnType<typeof makeFakeCdp>,
): RecordDeps {
const framesByTab = new Map(
tabIds.map((tabId) => [
tabId,
[
{
frameId: 0,
documentId: tabId === TAB_ID ? TOP_DOCUMENT_ID : `doc-tab-${tabId}`,
url: tabId === TAB_ID ? START_URL : `https://example.com/tab-${tabId}`,
parentFrameId: -1,
},
],
]),
);
return {
tabsApi,
sendToTab: sendToTabMock as RecordDeps["sendToTab"],
sendToDocument: (async (tabId, _documentId, msg) =>
sendToTabMock(tabId, msg)) as RecordDeps["sendToDocument"],
getAllFrames: (async (tabId) => framesByTab.get(tabId) ?? []) as RecordDeps["getAllFrames"],
...(cdp ? { cdp } : {}),
};
}
function makeMultiTabsApi(tabs: chrome.tabs.Tab[]) {
const byId = new Map(tabs.flatMap((tab) => (typeof tab.id === "number" ? [[tab.id, tab]] : [])));
return {
@@ -1171,25 +1233,25 @@ describe("recorded user steps reach the exported trace", () => {
}
return { ok: true };
});
const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() };
const deps = makeMultiTabRecordDeps(tabsApi, sendToTab, [TAB_ID, 5], makeFakeCdp());
await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps);
attachRecordQueryListener(deps);
await new Promise<void>((resolve) => {
chromeApi.runtimeOnMessage.emit(
{ type: RECORD_QUERY },
{ tab: secondTab } as chrome.runtime.MessageSender,
{ tab: secondTab, documentId: "doc-tab-5" } as chrome.runtime.MessageSender,
() => resolve(),
);
});
const firstStop = await handleRecordStop(manager, { session_id: "abcd" }, deps);
expect(firstStop).toMatchObject({ code: "protocol_error" });
expect((firstStop as RecordStopResult).trace.version).toBe(3);
expect((firstStop as RecordStopResult).trace.frame_capture?.status).toBe("partial");
expect(stopTabs).toEqual([TAB_ID, 5]);
const retry = await handleRecordStop(manager, { session_id: "abcd" }, deps);
expect((retry as RecordStopResult).trace.version).toBe(3);
expect(stopTabs).toEqual([TAB_ID, 5, 5]);
expect(retry).toMatchObject({ code: "not_found" });
});
it("does not let a closed armed tab block finish", async () => {
@@ -1215,14 +1277,14 @@ describe("recorded user steps reach the exported trace", () => {
if ((msg as { type?: string }).type === RECORD_STOP) stopTabs.push(tabId);
return { ok: true };
});
const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() };
const deps = makeMultiTabRecordDeps(tabsApi, sendToTab, [TAB_ID, 5], makeFakeCdp());
await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps);
attachRecordQueryListener(deps);
await new Promise<void>((resolve) => {
chromeApi.runtimeOnMessage.emit(
{ type: RECORD_QUERY },
{ tab: secondTab } as chrome.runtime.MessageSender,
{ tab: secondTab, documentId: "doc-tab-5" } as chrome.runtime.MessageSender,
() => resolve(),
);
});
@@ -1231,7 +1293,7 @@ describe("recorded user steps reach the exported trace", () => {
const stopped = await handleRecordStop(manager, { session_id: "abcd" }, deps);
expect((stopped as RecordStopResult).trace.version).toBe(3);
expect(stopTabs).toEqual([TAB_ID]);
expect(stopTabs).toContain(TAB_ID);
});
it("waits for an acknowledged in-flight rearm before stopping armed tabs", async () => {
@@ -1270,13 +1332,13 @@ describe("recorded user steps reach the exported trace", () => {
if (type === RECORD_STOP) stopTabs.push(tabId);
return { ok: true };
});
const deps = { tabsApi, sendToTab, cdp: makeFakeCdp() };
const deps = makeMultiTabRecordDeps(tabsApi, sendToTab, [TAB_ID, 5], makeFakeCdp());
await handleRecordStart(manager, { session_id: "abcd", url: START_URL }, deps);
attachRecordQueryListener(deps);
chromeApi.runtimeOnMessage.emit(
{ type: RECORD_QUERY },
{ tab: secondTab } as chrome.runtime.MessageSender,
{ tab: secondTab, documentId: "doc-tab-5" } as chrome.runtime.MessageSender,
() => {},
);
await secondStartBegan;
@@ -1,4 +1,5 @@
import { type RenderedRef, renderVom, type VomOptions } from "@browser-skill/vom";
import { readRecordDocumentTokenFromAttrs } from "@/lib/record-document-token";
import type { CdpAxNode } from "./observation";
import { buildVomScene } from "./observation";
import type { CdpRunner } from "./shared";
@@ -20,9 +21,28 @@ export interface CaptureVomObservationResult {
refs: RenderedRef[];
truncated: boolean;
captured: CapturedNode[];
/** All nodes including iframe sub-documents, for geometric target matching. */
matchNodes: CapturedNode[];
/** Maps ephemeral record document tokens to immediate iframe owner backend ids. */
documentTokenOwners: Map<string, number | null>;
surfaceProbes?: CapturedSurfaceProbe[];
}
export function flattenCapturedNodes(vm: CapturedViewModel): CapturedNode[] {
return [...vm.nodes, ...[...vm.iframeNodes.values()].flat()];
}
export function collectDocumentTokenOwners(vm: CapturedViewModel): Map<string, number | null> {
const owners = new Map<string, number | null>();
for (const node of flattenCapturedNodes(vm)) {
if (node.tag !== "html") continue;
const token = readRecordDocumentTokenFromAttrs(node.attrs);
if (!token) continue;
owners.set(token, node.ownerFrameBackendNodeId ?? null);
}
return owners;
}
function emptyCapturedViewModel(viewport = { width: 0, height: 0 }): CapturedViewModel {
return { viewport, nodes: [], iframeNodes: new Map(), excludedBackendNodeIds: new Set() };
}
@@ -91,11 +111,14 @@ export async function captureVomObservation(
redactValues: opts.redactValues,
activeRegionPolicy: opts.activeRegionPolicy ?? true,
});
const matchNodes = flattenCapturedNodes(captured);
return {
text: rendered.text,
refs: rendered.refs,
truncated: rendered.truncated,
captured: captured.nodes,
matchNodes,
documentTokenOwners: collectDocumentTokenOwners(captured),
surfaceProbes: captured.surfaceProbes,
};
}
+277 -77
View File
@@ -7,16 +7,28 @@ import {
isRecordQueryMessage,
isRecordStepMessage,
RECORD_CANCEL,
RECORD_CAPTURE_STATUS,
RECORD_START,
RECORD_STEP,
RECORD_STOP,
type RecordCancelMessage,
type RecordCaptureStatusMessage,
type RecordFinishMessage,
type RecordQueryResponse,
type RecordStartAck,
type RecordStartMessage,
type RecordStepPayload,
type RecordStopMessage,
} from "@/lib/record-bridge";
import {
armAllDocumentsForTab,
armDocumentCapture,
cancelDocumentCapture,
type ArmedDocument,
listInjectableFrames,
type RecordFrameSendDeps,
stopDocumentCapture,
} from "@/lib/record-frame-arming";
import { DEFAULT_MAX_PAGE_TOKENS } from "@/lib/record-constants";
import {
applyTargetMatching,
@@ -39,6 +51,9 @@ import type { SessionManager } from "@/session-manager/manager";
import { EXTENSION_VERSION } from "@/transport/handshake";
import type {
DraftTraceStep,
FrameCaptureFailure,
FrameCaptureInfo,
FrameCaptureStatus,
RecordAwaitParams,
RecordAwaitResult,
RecordStartParams,
@@ -80,8 +95,12 @@ interface ActiveRecording {
stoppedBy: StopReason;
maxPageTokens: number;
redactValues: boolean;
/** Tabs whose content capture acknowledged START and still need STOP. */
armedTabIds: Set<number>;
/** Documents whose capture acknowledged START and still need STOP. */
armedDocuments: Map<string, ArmedDocument>;
frameCaptureFailures: FrameCaptureFailure[];
frameCaptureStatus: FrameCaptureStatus;
/** When false, ignore subframe rearm requests during finish. */
acceptingNewFrames: boolean;
/** Rearms already sending START when finish begins. */
rearmCallbacks: Set<Promise<void>>;
/** Navigation callbacks tracked from event receipt through action enqueue. */
@@ -203,6 +222,15 @@ async function sendRecordStartWithAck(
}
function buildTrace(recording: ActiveRecording): Trace {
const frameCapture: FrameCaptureInfo | undefined =
recording.frameCaptureFailures.length > 0 || recording.frameCaptureStatus === "partial"
? {
status: recording.frameCaptureStatus,
...(recording.frameCaptureFailures.length > 0
? { failures: recording.frameCaptureFailures }
: {}),
}
: undefined;
return buildTraceV3({
obs: recording.observation,
steps: recording.steps,
@@ -211,9 +239,51 @@ function buildTrace(recording: ActiveRecording): Trace {
startUrl: recording.startUrl,
stoppedBy: recording.stoppedBy,
bskVersion: BSK_TRACE_VERSION,
...(frameCapture ? { frameCapture } : {}),
});
}
function recordFrameFailure(recording: ActiveRecording, failure: FrameCaptureFailure): void {
recording.frameCaptureFailures.push(failure);
recording.frameCaptureStatus = "partial";
}
function enrichRecordedStep(
recording: ActiveRecording,
step: RecordStepPayload,
): RecordStepPayload {
const enriched: RecordStepPayload = { ...step };
if (enriched.geometry && enriched.documentToken) {
const owner = recording.observation.documentTokenOwners.get(enriched.documentToken);
if (owner !== undefined) {
enriched.geometry = { ...enriched.geometry, ownerFrameBackendNodeId: owner };
}
}
if (!enriched.page_url && recording.currentUrl) {
enriched.page_url = recording.currentUrl;
}
return enriched;
}
async function publishCaptureStatus(
recording: ActiveRecording,
deps: RecordDeps,
): Promise<void> {
if (!deps.broadcastCaptureStatus) return;
const msg: RecordCaptureStatusMessage = {
type: RECORD_CAPTURE_STATUS,
status: recording.frameCaptureStatus,
...(recording.frameCaptureFailures.length > 0
? { failures: recording.frameCaptureFailures }
: {}),
};
try {
await deps.broadcastCaptureStatus(recording.tabId, msg);
} catch {
// Overlay is best-effort.
}
}
async function processRecordedStep(
recording: ActiveRecording,
deps: RecordDeps,
@@ -249,17 +319,67 @@ export interface RecordDeps {
tabId: number,
msg: RecordStartMessage | RecordStopMessage | RecordCancelMessage,
): Promise<unknown>;
sendToDocument?(
tabId: number,
documentId: string,
msg: RecordStartMessage | RecordStopMessage | RecordCancelMessage,
): Promise<unknown>;
getAllFrames?(tabId: number): Promise<
Array<{
frameId: number;
documentId?: string;
url?: string;
parentFrameId: number;
}>
>;
broadcastCaptureStatus?(
tabId: number,
msg: RecordCaptureStatusMessage,
): Promise<void>;
bypassOverlay?: (tabId: number, enabled: boolean) => Promise<void>;
cdp?: CdpRunner;
signal?: AbortSignal;
}
function frameSendDeps(deps: RecordDeps): RecordFrameSendDeps {
return {
sendToDocument: (tabId, documentId, msg) => {
if (deps.sendToDocument) return deps.sendToDocument(tabId, documentId, msg);
return deps.sendToTab(tabId, msg);
},
getAllFrames: async (tabId) => {
if (deps.getAllFrames) return deps.getAllFrames(tabId);
if (!chrome.webNavigation?.getAllFrames) return [];
const frames = await chrome.webNavigation.getAllFrames({ tabId });
return (frames ?? []).map((frame) => ({
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
parentFrameId: frame.parentFrameId,
}));
},
};
}
let defaultDeps: RecordDeps | null = null;
function getDefaultDeps(): RecordDeps {
if (!defaultDeps) {
defaultDeps = {
tabsApi: chromeTabsApi,
sendToTab: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg),
sendToDocument: (tabId, documentId, msg) =>
chrome.tabs.sendMessage(tabId, msg, { documentId }),
getAllFrames: async (tabId) => {
if (!chrome.webNavigation?.getAllFrames) return [];
const frames = await chrome.webNavigation.getAllFrames({ tabId });
return (frames ?? []).map((frame) => ({
frameId: frame.frameId,
documentId: frame.documentId,
url: frame.url,
parentFrameId: frame.parentFrameId,
}));
},
broadcastCaptureStatus: (tabId, msg) => chrome.tabs.sendMessage(tabId, msg, { frameId: 0 }),
};
}
return defaultDeps;
@@ -320,7 +440,7 @@ export function releaseBrowserObservationListenersIfIdle(): void {
export function attachRecordStepListener(deps: RecordDeps = getDefaultDeps()): () => void {
const listener = (
message: unknown,
_sender: chrome.runtime.MessageSender,
sender: chrome.runtime.MessageSender,
_sendResponse: () => void,
) => {
if (!isRecordStepMessage(message)) return false;
@@ -328,9 +448,6 @@ export function attachRecordStepListener(deps: RecordDeps = getDefaultDeps()): (
if (recording.requestId !== message.requestId) continue;
enqueueRecordingAction(recording, async () => {
const cdp = recording.cdp ?? deps.cdp;
// Flush before appending so a coalesced OAuth navigate is ordered
// ahead of the click that landed on the destination page. The flush
// itself waits for that navigate's observation before returning.
await flushPendingRedirectLanding(
recording.observation,
recording.steps,
@@ -339,7 +456,8 @@ export function attachRecordStepListener(deps: RecordDeps = getDefaultDeps()): (
deps.tabsApi,
{ cancelled: () => recording.settled },
);
const draftIndex = appendRecordedPayload(recording, message.step);
const enriched = enrichRecordedStep(recording, message.step);
const draftIndex = appendRecordedPayload(recording, enriched);
if (draftIndex !== null) await processRecordedStep(recording, deps, draftIndex);
});
return false;
@@ -368,8 +486,10 @@ export function attachRecordFinishListener(deps: RecordDeps = getDefaultDeps()):
function findRecordingByTabId(tabId: number): ActiveRecording | null {
for (const recording of recordings.values()) {
if (!recording.settled && (recording.tabId === tabId || recording.armedTabIds.has(tabId))) {
return recording;
if (recording.settled) continue;
if (recording.tabId === tabId) return recording;
for (const armed of recording.armedDocuments.values()) {
if (armed.tabId === tabId) return recording;
}
}
return null;
@@ -418,47 +538,64 @@ async function clearRearmTimersForRecording(
}
}
async function stopRecordingOnAllAgentTabs(
async function stopRecordingOnAllDocuments(
recording: ActiveRecording,
deps: RecordDeps,
): Promise<void> {
recording.acceptingNewFrames = false;
const stopMsg: RecordStopMessage = { type: RECORD_STOP, requestId: recording.requestId };
let failed = false;
for (const tabId of [...recording.armedTabIds]) {
try {
await deps.tabsApi.get(tabId);
} catch {
// A closed tab no longer has content capture to flush.
recording.armedTabIds.delete(tabId);
const frameDeps = frameSendDeps(deps);
const armed = [...recording.armedDocuments.values()];
for (const doc of armed) {
const result = await stopDocumentCapture(doc, stopMsg, frameDeps);
if ("ok" in result) {
recording.armedDocuments.delete(doc.documentId);
continue;
}
try {
const response = await deps.sendToTab(tabId, stopMsg);
if (!isRecordStartAck(response)) {
throw new Error("content script did not confirm recorded steps");
}
recording.armedTabIds.delete(tabId);
} catch {
failed = true;
continue;
}
if (deps.bypassOverlay) {
try {
await deps.bypassOverlay(tabId, false);
} catch {
// Best-effort cleanup.
}
}
recordFrameFailure(recording, result);
}
if (failed || recording.armedTabIds.size > 0) {
throw new Error("failed to flush recorded steps");
if (recording.armedDocuments.size > 0) {
for (const doc of [...recording.armedDocuments.values()]) {
recordFrameFailure(
recording,
{
reason: "flush_failed",
frame_id: doc.frameId,
document_id: doc.documentId,
url: doc.url,
detail: "document still armed after STOP",
},
);
}
recording.armedDocuments.clear();
}
await publishCaptureStatus(recording, deps);
if (deps.bypassOverlay) {
try {
await deps.bypassOverlay(recording.tabId, false);
} catch {
// Best-effort cleanup.
}
}
}
async function rearmRecording(
async function cancelRecordingOnAllDocuments(
recording: ActiveRecording,
deps: RecordDeps,
): Promise<void> {
const cancelMsg: RecordCancelMessage = { type: RECORD_CANCEL, requestId: recording.requestId };
const frameDeps = frameSendDeps(deps);
for (const doc of [...recording.armedDocuments.values()]) {
await cancelDocumentCapture(doc, cancelMsg, frameDeps);
}
recording.armedDocuments.clear();
}
async function rearmRecordingDocuments(
recording: ActiveRecording,
targetTabId: number,
deps: RecordDeps,
targetDocumentId?: string,
): Promise<boolean> {
let resolveTracked!: () => void;
const tracked = new Promise<void>((resolve) => {
@@ -466,29 +603,65 @@ async function rearmRecording(
});
recording.rearmCallbacks.add(tracked);
// Do NOT toggle automation-bypass here: each retry used to increment the
// content-script counter, and a single stop decrement left the ControlOverlay
// stuck with pointer-events:none (page usable, Interrupt dead). RecordOverlay
// already hides the control chrome while activeRecord is set.
const frameDeps = frameSendDeps(deps);
try {
for (let attempt = 0; attempt < RECORD_REARM_MAX_ATTEMPTS; attempt += 1) {
if (recording.settled || recording.finishing) return false;
const startMsg: RecordStartMessage = {
type: RECORD_START,
requestId: recording.requestId,
startedAtMs: recording.startedAtMs,
};
try {
await sendRecordStartWithAck(targetTabId, startMsg, deps.sendToTab);
recording.armedTabIds.add(targetTabId);
recording.tabId = targetTabId;
return true;
} catch {
if (attempt + 1 < RECORD_REARM_MAX_ATTEMPTS) {
await sleep(RECORD_REARM_RETRY_DELAY_MS);
const attemptLimit = targetDocumentId ? 1 : RECORD_REARM_MAX_ATTEMPTS;
for (let attempt = 0; attempt < attemptLimit; attempt += 1) {
if (recording.settled || recording.finishing || !recording.acceptingNewFrames) return false;
const { injectable, failures } = await listInjectableFrames(targetTabId, frameDeps);
for (const failure of failures) recordFrameFailure(recording, failure);
const targets = targetDocumentId
? injectable.filter((frame) => frame.documentId === targetDocumentId)
: injectable.filter((frame) => !recording.armedDocuments.has(frame.documentId ?? ""));
if (targets.length === 0) {
if (targetDocumentId) {
recordFrameFailure(recording, {
reason: "rearm_failed",
frame_id: 0,
document_id: targetDocumentId,
detail: "document not injectable",
});
await publishCaptureStatus(recording, deps);
return false;
}
recording.tabId = targetTabId;
return injectable.some((frame) => recording.armedDocuments.has(frame.documentId ?? ""));
}
let armedAny = false;
for (const frame of targets) {
const startMsg: RecordStartMessage = {
type: RECORD_START,
requestId: recording.requestId,
startedAtMs: recording.startedAtMs,
showOverlay: frame.frameId === 0,
frameMode: frame.frameId === 0 ? "top" : "child",
};
const result = await armDocumentCapture(targetTabId, frame, startMsg, frameDeps);
if (result.armed) {
recording.armedDocuments.set(result.armed.documentId, result.armed);
armedAny = true;
}
if (result.failure) recordFrameFailure(recording, result.failure);
}
if (armedAny) {
recording.tabId = targetTabId;
await publishCaptureStatus(recording, deps);
if (deps.cdp && recording.acceptingNewFrames) {
void captureAndRegisterObservation(
recording.observation,
deps.cdp,
targetTabId,
deps.tabsApi,
).catch(() => {});
}
return true;
}
if (attempt + 1 < RECORD_REARM_MAX_ATTEMPTS) {
await sleep(RECORD_REARM_RETRY_DELAY_MS);
}
}
await publishCaptureStatus(recording, deps);
return false;
} finally {
recording.rearmCallbacks.delete(tracked);
@@ -505,7 +678,7 @@ function scheduleRearmForTab(tabId: number, deps: RecordDeps): void {
rearmTimers.delete(tabId);
void (async () => {
const current = await findRecordingForTab(tabId, deps);
if (current) await rearmRecording(current, tabId, deps);
if (current) await rearmRecordingDocuments(current, tabId, deps);
})();
}, RECORD_REARM_DEBOUNCE_MS),
);
@@ -626,12 +799,24 @@ export function attachRecordNavigationListener(deps: RecordDeps = getDefaultDeps
})();
};
const onSubFrameComplete = (tabId: number, documentId?: string) => {
if (!documentId) return;
void (async () => {
const recording = await findRecordingForTab(tabId, deps);
if (!recording || !recording.acceptingNewFrames) return;
await rearmRecordingDocuments(recording, tabId, deps, documentId);
})();
};
if (chrome.webNavigation?.onCompleted) {
const completedListener = (
details: chrome.webNavigation.WebNavigationFramedCallbackDetails,
) => {
if (details.frameId !== 0) return;
onMainFrameComplete(details.tabId, details.url);
if (details.frameId === 0) {
onMainFrameComplete(details.tabId, details.url);
return;
}
onSubFrameComplete(details.tabId, details.documentId);
};
const committedListener = (
details: chrome.webNavigation.WebNavigationTransitionCallbackDetails,
@@ -719,11 +904,15 @@ export function attachRecordQueryListener(deps: RecordDeps = getDefaultDeps()):
sendResponse({ active: false });
return;
}
await rearmRecording(recording, tabId, deps);
await rearmRecordingDocuments(recording, tabId, deps, sender.documentId);
sendResponse({
active: true,
requestId: recording.requestId,
startedAtMs: recording.startedAtMs,
captureStatus: recording.frameCaptureStatus,
...(recording.frameCaptureFailures.length > 0
? { captureFailures: recording.frameCaptureFailures }
: {}),
});
})();
return true;
@@ -760,10 +949,7 @@ async function finishRecording(
await clearRearmTimersForRecording(recording, deps);
await Promise.all([...recording.rearmCallbacks]);
try {
// Disposing content capture may commit a final dirty fill. Flush content
// first so all resulting RECORD_STEP messages enter actionQueue before it
// and the observation queues are drained.
await stopRecordingOnAllAgentTabs(recording, deps);
await stopRecordingOnAllDocuments(recording, deps);
} catch {
recording.finishing = false;
return null;
@@ -845,7 +1031,10 @@ export async function handleRecordStart(
stoppedBy: "user_finish",
maxPageTokens: maxPageTokens ?? DEFAULT_MAX_PAGE_TOKENS,
redactValues,
armedTabIds: new Set(),
armedDocuments: new Map(),
frameCaptureFailures: [],
frameCaptureStatus: "complete",
acceptingNewFrames: true,
rearmCallbacks: new Set(),
navigationCallbacks: new Set(),
acceptingNavigation: true,
@@ -857,17 +1046,11 @@ export async function handleRecordStart(
ensureBrowserObservationListeners(deps);
const abortPending = async (notifyContent: boolean) => {
const pending = recordings.get(params.session_id);
recordings.delete(params.session_id);
releaseBrowserObservationListenersIfIdle();
if (notifyContent) {
try {
await deps.sendToTab(target.tabId, {
type: RECORD_CANCEL,
requestId,
});
} catch {
// Content may never have received RECORD_START.
}
if (notifyContent && pending) {
await cancelRecordingOnAllDocuments(pending, deps);
}
if (deps.bypassOverlay) {
try {
@@ -970,11 +1153,28 @@ export async function handleRecordStart(
if (cancelled) return cancelled;
}
const startMsg: RecordStartMessage = { type: RECORD_START, requestId, startedAtMs };
const startMsg: RecordStartMessage = {
type: RECORD_START,
requestId,
startedAtMs,
showOverlay: true,
frameMode: "top",
};
try {
await sendRecordStartWithAck(target.tabId, startMsg, deps.sendToTab);
active.armedTabIds.add(target.tabId);
const frameDeps = frameSendDeps(deps);
const { armed, failures } = await armAllDocumentsForTab(target.tabId, startMsg, frameDeps);
for (const failure of failures) recordFrameFailure(active, failure);
for (const doc of armed) active.armedDocuments.set(doc.documentId, doc);
if (active.armedDocuments.size === 0) {
await abortPending(true);
return {
code: "protocol_error",
message:
"failed to start recording in any frame — reload the BrowserSkill extension, then retry",
};
}
await publishCaptureStatus(active, deps);
} catch {
await abortPending(true);
return {
+24
View File
@@ -681,6 +681,28 @@ export interface RecorderInfo {
vom: number;
}
export type FrameCaptureStatus = "complete" | "partial";
export type FrameCaptureFailureReason =
| "not_injectable"
| "arm_failed"
| "rearm_failed"
| "drain_failed"
| "flush_failed";
export interface FrameCaptureFailure {
reason: FrameCaptureFailureReason;
frame_id: number;
document_id?: string;
url?: string;
detail?: string;
}
export interface FrameCaptureInfo {
status: FrameCaptureStatus;
failures?: FrameCaptureFailure[];
}
export type StopReason = "user_finish" | "cli_stop";
export interface TraceState {
@@ -864,6 +886,8 @@ export interface Trace {
stopped_by: StopReason;
entry: TraceEntry;
recorder: RecorderInfo;
/** Present when one or more frames could not be armed, rearmed, or flushed. */
frame_capture?: FrameCaptureInfo;
states: TraceState[];
steps: Step[];
}
+1
View File
@@ -285,6 +285,7 @@ bsk record stop [--output trace] # terminal fallback if the browser panel is u
- `target.ref` values like `@e12` exist **only inside** the bundle for disambiguation; do **not** copy `@eN` refs into SKILL.md or agent runbooks — use visible names from the observation text instead.
- `--purpose` is optional context metadata; it does **not** change what gets captured.
- `--redact-values` masks all form values in page files as `[filled]` / `[empty]`.
- Recording captures interactions in the **top frame and injectable child frames** (same-origin and cross-origin where the extension can attach). Embedded frames that cannot be injected are omitted; the trace may include `frame_capture.status: "partial"` with failure details.
- Do **not** record on banking/SSO/password-manager pages; passwords are redacted but traces may still contain sensitive text.
## Error handling
+10 -2
View File
@@ -8,8 +8,8 @@ use std::time::Duration;
use anyhow::Context;
use bsk_protocol::tools::{
RecordAwaitParams, RecordAwaitResult, RecordStartParams, RecordStartResult, RecordStopParams,
RecordStopResult, TRACE_VERSION, Trace, TraceState,
FrameCaptureStatus, RecordAwaitParams, RecordAwaitResult, RecordStartParams, RecordStartResult,
RecordStopParams, RecordStopResult, TRACE_VERSION, Trace, TraceState,
};
use bsk_protocol::{ErrorCode, Method};
use clap::{Args, Subcommand};
@@ -550,6 +550,13 @@ fn render_finish(
trace_path.display(),
pages_dir.display()
);
if let Some(frame_capture) = &trace.frame_capture {
if matches!(frame_capture.status, FrameCaptureStatus::Partial) {
eprintln!(
"warning: trace is partial — some embedded frames were not recorded (see frame_capture.failures in trace.json)"
);
}
}
}
}
Ok(())
@@ -584,6 +591,7 @@ mod tests {
bsk: "0.1.10".into(),
vom: VOM_FORMAT_VERSION,
},
frame_capture: None,
states: vec![TraceState {
id: state_id.into(),
url: "https://example.com/".into(),
@@ -96,6 +96,7 @@ fn sample_trace() -> Trace {
bsk: "0.1.10".into(),
vom: VOM_FORMAT_VERSION,
},
frame_capture: None,
states: vec![TraceState {
id: "s1".into(),
url: "https://example.com/".into(),
+81
View File
@@ -16,6 +16,16 @@
"entry": {
"$ref": "#/definitions/TraceEntry"
},
"frame_capture": {
"anyOf": [
{
"$ref": "#/definitions/FrameCaptureInfo"
},
{
"type": "null"
}
]
},
"purpose": {
"type": [
"string",
@@ -64,6 +74,77 @@
"blur"
]
},
"FrameCaptureFailure": {
"type": "object",
"required": [
"frame_id",
"reason"
],
"properties": {
"detail": {
"type": [
"string",
"null"
]
},
"document_id": {
"type": [
"string",
"null"
]
},
"frame_id": {
"type": "integer",
"format": "int64"
},
"reason": {
"$ref": "#/definitions/FrameCaptureFailureReason"
},
"url": {
"type": [
"string",
"null"
]
}
}
},
"FrameCaptureFailureReason": {
"type": "string",
"enum": [
"not_injectable",
"arm_failed",
"rearm_failed",
"drain_failed",
"flush_failed"
]
},
"FrameCaptureInfo": {
"type": "object",
"required": [
"status"
],
"properties": {
"failures": {
"type": [
"array",
"null"
],
"items": {
"$ref": "#/definitions/FrameCaptureFailure"
}
},
"status": {
"$ref": "#/definitions/FrameCaptureStatus"
}
}
},
"FrameCaptureStatus": {
"type": "string",
"enum": [
"complete",
"partial"
]
},
"KeyModifier": {
"description": "Keyboard modifier flags. Multiple flags may be combined; the extension folds them into CDP's bitfield (`alt=1, ctrl=2, meta=4, shift=8`).",
"type": "string",
+78
View File
@@ -167,6 +167,46 @@ pub enum Step {
},
}
// ---------------------------------------------------------------------------
// Frame capture metadata
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum FrameCaptureStatus {
Complete,
Partial,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum FrameCaptureFailureReason {
NotInjectable,
ArmFailed,
RearmFailed,
DrainFailed,
FlushFailed,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct FrameCaptureFailure {
pub reason: FrameCaptureFailureReason,
pub frame_id: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub document_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
pub struct FrameCaptureInfo {
pub status: FrameCaptureStatus,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub failures: Option<Vec<FrameCaptureFailure>>,
}
// ---------------------------------------------------------------------------
// Trace root
// ---------------------------------------------------------------------------
@@ -183,6 +223,8 @@ pub struct Trace {
pub stopped_by: StopReason,
pub entry: TraceEntry,
pub recorder: RecorderInfo,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub frame_capture: Option<FrameCaptureInfo>,
pub states: Vec<TraceState>,
pub steps: Vec<Step>,
}
@@ -277,6 +319,7 @@ mod tests {
bsk: "0.1.10".into(),
vom: VOM_FORMAT_VERSION,
},
frame_capture: None,
states: vec![
TraceState {
id: "s1".into(),
@@ -479,4 +522,39 @@ mod tests {
assert_eq!(trace.states.len(), 2);
assert_eq!(trace.steps.len(), 2);
}
#[test]
fn trace_deserializes_optional_frame_capture_metadata() {
let v = json!({
"version": 3,
"recorded_at": "2026-07-21T08:00:00Z",
"stopped_by": "cli_stop",
"entry": { "start_url": "https://example.com/editor" },
"recorder": { "bsk": "0.1.10", "vom": 1 },
"frame_capture": {
"status": "partial",
"failures": [
{
"reason": "not_injectable",
"frame_id": 2,
"url": "chrome://settings",
"detail": "restricted frame url"
}
]
},
"states": [],
"steps": []
});
let trace: Trace = serde_json::from_value(v).unwrap();
let frame_capture = trace.frame_capture.expect("frame_capture");
assert_eq!(frame_capture.status, FrameCaptureStatus::Partial);
let failures = frame_capture.failures.expect("failures");
assert_eq!(failures.len(), 1);
assert_eq!(
failures[0].reason,
FrameCaptureFailureReason::NotInjectable
);
assert_eq!(failures[0].frame_id, 2);
assert_eq!(failures[0].url.as_deref(), Some("chrome://settings"));
}
}
@@ -90,6 +90,7 @@
},
"recordOverlay": {
"recording": "Recording your actions",
"finish": "Finish"
"finish": "Finish",
"partialCapture": "Some embedded frames may not be recorded."
}
}
@@ -90,6 +90,7 @@
},
"recordOverlay": {
"recording": "正在录制你的操作",
"finish": "结束"
"finish": "结束",
"partialCapture": "部分嵌入页面可能未被录制。"
}
}
+1
View File
@@ -285,6 +285,7 @@ bsk record stop [--output trace] # terminal fallback if the browser panel is u
- `target.ref` values like `@e12` exist **only inside** the bundle for disambiguation; do **not** copy `@eN` refs into SKILL.md or agent runbooks — use visible names from the observation text instead.
- `--purpose` is optional context metadata; it does **not** change what gets captured.
- `--redact-values` masks all form values in page files as `[filled]` / `[empty]`.
- Recording captures interactions in the **top frame and injectable child frames** (same-origin and cross-origin where the extension can attach). Embedded frames that cannot be injected are omitted; the trace may include `frame_capture.status: "partial"` with failure details.
- Do **not** record on banking/SSO/password-manager pages; passwords are redacted but traces may still contain sensitive text.
## Error handling