Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions apps/extension/src/session-manager/__tests__/ref-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ describe("RefStore", () => {
backendNodeId: 42,
tabId: 7,
generation: 0,
kind: "dom",
capabilities: ["interact", "screenshot"],
});
expect(s.size()).toBe(1);
expect(s.isEmpty()).toBe(false);
Expand Down Expand Up @@ -48,4 +50,31 @@ describe("RefStore", () => {
cdpSessionId: "child-session",
});
});

it("preserves explicit capabilities for visual surface refs", () => {
const s = new RefStore();
s.set("e1", 42, {
tabId: 7,
kind: "surface",
capabilities: ["screenshot"],
});

expect(s.resolveEntry("e1")).toMatchObject({
kind: "surface",
capabilities: ["screenshot"],
});
});

it("never grants ordinary interaction to a surface through defaults or caller input", () => {
const s = new RefStore();
s.set("e1", 42, { tabId: 7, kind: "surface" });
s.set("e2", 43, {
tabId: 7,
kind: "surface",
capabilities: ["interact", "screenshot"],
});

expect(s.resolveEntry("e1")?.capabilities).toEqual(["screenshot"]);
expect(s.resolveEntry("e2")?.capabilities).toEqual(["screenshot"]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import { type SurfaceCaptureInput, SurfaceCaptureStore } from "../surface-capture-store";

function input(): SurfaceCaptureInput {
return {
sessionId: "aa11",
tabId: 7,
navigationIdentity: "navigation",
surface: {
ref: "e3",
frameId: "main",
backendNodeId: 99,
observationGeneration: 2,
},
topViewportRect: { x: 10, y: 20, w: 100, h: 50 },
imageWidth: 200,
imageHeight: 100,
viewportSignature: "viewport",
frameProjectionSignature: "frame",
};
}

describe("SurfaceCaptureStore", () => {
it("creates metadata-only, short-lived captures and consumes them once", () => {
let now = 1_000;
const store = new SurfaceCaptureStore({
now: () => now,
ttlMs: 500,
createId: () => "sc_test",
});
const capture = store.create(input());

expect(capture).toMatchObject({
id: "sc_test",
createdAt: 1_000,
expiresAt: 1_500,
consumed: false,
});
expect(capture).not.toHaveProperty("imageBase64");
expect(store.consume("sc_test")).toMatchObject({ ok: true });
expect(store.consume("sc_test")).toEqual({ ok: false, reason: "consumed" });

now = 1_600;
expect(store.size()).toBe(0);
});

it("rejects expired and unknown captures", () => {
let now = 1_000;
const store = new SurfaceCaptureStore({
now: () => now,
ttlMs: 10,
createId: () => "sc_expired",
});
store.create(input());
now = 1_010;

expect(store.consume("sc_expired")).toEqual({ ok: false, reason: "expired" });
expect(store.consume("sc_missing")).toEqual({ ok: false, reason: "not_found" });
});

it("clears every capture on session cleanup", () => {
const store = new SurfaceCaptureStore({ createId: () => "sc_clear" });
store.create(input());
store.clear();
expect(store.size()).toBe(0);
});
});
3 changes: 3 additions & 0 deletions apps/extension/src/session-manager/manager.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { AGENT_WINDOW_HOME, type AgentWindowApi, chromeAgentWindowApi } from "./agent-window";
import { RefStore } from "./ref-store";
import { SurfaceCaptureStore } from "./surface-capture-store";

export interface SessionContext {
sessionId: string;
agentWindowId: number;
refStore: RefStore;
surfaceCaptures: SurfaceCaptureStore;
borrowedTabs: Map<number, BorrowedTab>;
createdAtMs: number;
}
Expand Down Expand Up @@ -186,6 +188,7 @@ export class SessionManager {
sessionId,
agentWindowId: windowId,
refStore: new RefStore(),
surfaceCaptures: new SurfaceCaptureStore({ now: this.now }),
borrowedTabs: new Map(),
createdAtMs: this.now(),
};
Expand Down
31 changes: 31 additions & 0 deletions apps/extension/src/session-manager/ref-store.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Rect } from "@browser-skill/vom";

/**
* Per-session map from `@e<N>` snapshot refs to a CDP node address.
*
Expand All @@ -12,12 +14,17 @@
* inside the `SessionContext`.
*/
export type BackendNodeId = number;
export type RefCapability = "interact" | "screenshot";
export type RefTargetKind = "dom" | "surface";

export interface RefEntry {
backendNodeId: BackendNodeId;
tabId: number | null;
frameId?: string;
cdpSessionId?: string;
visibleRect?: Rect;
kind: RefTargetKind;
capabilities: RefCapability[];
generation: number;
}

Expand All @@ -28,8 +35,19 @@ export type RefInput =
tabId: number;
frameId?: string;
cdpSessionId?: string;
visibleRect?: Rect;
kind?: RefTargetKind;
capabilities?: RefCapability[];
};

function capabilitiesFor(
kind: RefTargetKind,
capabilities: RefCapability[] | undefined,
): RefCapability[] {
if (kind === "surface") return ["screenshot"];
return capabilities ?? ["interact", "screenshot"];
}

export class RefStore {
private readonly map = new Map<string, RefEntry>();
private generation = 0;
Expand Down Expand Up @@ -70,13 +88,20 @@ export class RefStore {
tabId?: number;
frameId?: string;
cdpSessionId?: string;
visibleRect?: Rect;
kind?: RefTargetKind;
capabilities?: RefCapability[];
} = {},
): void {
const kind = opts.kind ?? "dom";
this.map.set(normaliseRef(ref), {
backendNodeId: id,
tabId: opts.tabId ?? null,
...(opts.frameId ? { frameId: opts.frameId } : {}),
...(opts.cdpSessionId ? { cdpSessionId: opts.cdpSessionId } : {}),
...(opts.visibleRect ? { visibleRect: opts.visibleRect } : {}),
kind,
capabilities: capabilitiesFor(kind, opts.capabilities),
generation: this.generation,
});
}
Expand All @@ -94,14 +119,20 @@ export class RefStore {
return {
backendNodeId: input,
tabId: null,
kind: "dom",
capabilities: ["interact", "screenshot"],
generation: this.generation,
};
}
const kind = input.kind ?? "dom";
return {
backendNodeId: input.backendNodeId,
tabId: input.tabId,
...(input.frameId ? { frameId: input.frameId } : {}),
...(input.cdpSessionId ? { cdpSessionId: input.cdpSessionId } : {}),
...(input.visibleRect ? { visibleRect: input.visibleRect } : {}),
kind,
capabilities: capabilitiesFor(kind, input.capabilities),
generation: this.generation,
};
}
Expand Down
100 changes: 100 additions & 0 deletions apps/extension/src/session-manager/surface-capture-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { Rect } from "@browser-skill/vom";

export const SURFACE_CAPTURE_TTL_MS = 30_000;

export interface SurfaceCapture {
id: string;
sessionId: string;
tabId: number;
navigationIdentity: string;
surface: {
ref: string;
frameId?: string;
backendNodeId: number;
observationGeneration: number;
};
topViewportRect: Rect;
imageWidth: number;
imageHeight: number;
viewportSignature: string;
frameProjectionSignature: string;
createdAt: number;
expiresAt: number;
consumed: boolean;
}

export type SurfaceCaptureInput = Omit<
SurfaceCapture,
"id" | "createdAt" | "expiresAt" | "consumed"
>;

export type SurfaceCaptureConsumeResult =
| { ok: true; capture: SurfaceCapture }
| { ok: false; reason: "not_found" | "expired" | "consumed" };

export interface SurfaceCaptureStoreOptions {
now?: () => number;
ttlMs?: number;
createId?: () => string;
}

function randomCaptureId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(12));
return `sc_${Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
}

/** Per-session, metadata-only store for short-lived screenshot coordinate transactions. */
export class SurfaceCaptureStore {
private readonly captures = new Map<string, SurfaceCapture>();
private readonly now: () => number;
private readonly ttlMs: number;
private readonly createId: () => string;

constructor(options: SurfaceCaptureStoreOptions = {}) {
this.now = options.now ?? Date.now;
this.ttlMs = options.ttlMs ?? SURFACE_CAPTURE_TTL_MS;
this.createId = options.createId ?? randomCaptureId;
}

create(input: SurfaceCaptureInput): SurfaceCapture {
this.purgeExpired();
const createdAt = this.now();
const capture: SurfaceCapture = {
...input,
id: this.createId(),
createdAt,
expiresAt: createdAt + this.ttlMs,
consumed: false,
};
this.captures.set(capture.id, capture);
return capture;
}

consume(id: string): SurfaceCaptureConsumeResult {
const capture = this.captures.get(id);
if (!capture) return { ok: false, reason: "not_found" };
if (capture.expiresAt <= this.now()) {
this.captures.delete(id);
return { ok: false, reason: "expired" };
}
if (capture.consumed) return { ok: false, reason: "consumed" };
capture.consumed = true;
return { ok: true, capture };
}

clear(): void {
this.captures.clear();
}

size(): number {
this.purgeExpired();
return this.captures.size;
}

private purgeExpired(): void {
const now = this.now();
for (const [id, capture] of this.captures) {
if (capture.expiresAt <= now) this.captures.delete(id);
}
}
}
Loading