Initial public release

This commit is contained in:
BB-fat
2026-06-22 17:36:26 +08:00
commit f1036a860c
289 changed files with 54589 additions and 0 deletions
@@ -0,0 +1,176 @@
import { ConnectionController, __testing__ } from "../connection-controller";
import type { Transport } from "../../transport/transport";
import type { ConnectionState } from "../../transport/types";
import type { ConnectionStateHandler } from "../../transport/transport";
import { describe, expect, it, vi, beforeEach } from "vitest";
import { MIN_COMPATIBLE_PROTOCOL } from "../../transport/handshake";
import type { HandshakeResult } from "../../transport/types";
vi.mock("../instance-id", () => ({
getOrCreateInstanceId: vi.fn(async () => "a1b2c3d4"),
getLabel: vi.fn(async () => "test-label"),
}));
const { computeConnectedState } = __testing__;
function handshake(
daemonProtocol: string,
minCompatibleProtocol?: string,
daemonAppVersion = "0.1.0",
): HandshakeResult {
return {
server: "browser-skill-daemon",
version: daemonAppVersion,
protocol_version: daemonProtocol,
min_compatible_peer: "0.0.0",
min_compatible_protocol: minCompatibleProtocol,
};
}
describe("computeConnectedState (protocol-based compat)", () => {
it("returns connected when protocol strings match", () => {
expect(computeConnectedState(handshake("1.0", "1.0"), MIN_COMPATIBLE_PROTOCOL)).toEqual({
kind: "connected",
});
});
it("returns version_skew when daemon protocol minor is newer", () => {
expect(computeConnectedState(handshake("1.1", "1.0"))).toEqual({
kind: "version_skew",
});
});
it("returns version_skew when daemon protocol string differs but floor is satisfied", () => {
expect(computeConnectedState(handshake("1", "1.0"))).toEqual({
kind: "version_skew",
});
});
it("rejects when protocol major differs", () => {
const result = computeConnectedState(handshake("2.0", "1.0"));
expect(result.kind).toBe("rejected");
if (result.kind === "rejected") {
expect(result.reason).toContain("protocol-major mismatch");
}
});
it("rejects when extension is below daemon min_compatible_protocol", () => {
const result = computeConnectedState(handshake("1.0", "1.5"));
expect(result.kind).toBe("rejected");
if (result.kind === "rejected") {
expect(result.reason).toContain("min_compatible_protocol");
expect(result.reason).toContain("1.5");
}
});
it("does not reject when min_compatible_protocol is missing (legacy daemon)", () => {
const result = computeConnectedState({
server: "browser-skill-daemon",
version: "0.1.0",
protocol_version: "1.0",
min_compatible_peer: "0.1.0",
});
expect(result).toEqual({ kind: "connected" });
});
it("rejects when daemon protocol is below extension floor", () => {
const result = computeConnectedState(handshake("1.0", "1.0"), "1.1");
expect(result.kind).toBe("rejected");
if (result.kind === "rejected") {
expect(result.reason).toContain("below extension min_compatible_protocol");
}
});
it("rejects malformed daemon min_compatible_protocol with a daemon-floor reason", () => {
const result = computeConnectedState(handshake("1.0", "not-a-protocol"));
expect(result.kind).toBe("rejected");
if (result.kind === "rejected") {
expect(result.reason).toContain("daemon min_compatible_protocol");
expect(result.reason).toContain("not-a-protocol");
}
});
it("rejects unparseable daemon protocol", () => {
const result = computeConnectedState(handshake("oops", "1.0"));
expect(result.kind).toBe("rejected");
});
});
function makeMockTransport(initialState: ConnectionState = "disconnected") {
let state = initialState;
const stateHandlers = new Set<ConnectionStateHandler>();
const transport = {
get state() {
return state;
},
connect: vi.fn(async () => {
state = "connected";
for (const h of stateHandlers) h("connected");
}),
disconnect: vi.fn(async () => {
state = "disconnected";
for (const h of stateHandlers) h("disconnected");
}),
send: vi.fn(),
onMessage: vi.fn(() => ({ dispose: () => {} })),
onConnectionStateChange: vi.fn((handler: ConnectionStateHandler) => {
stateHandlers.add(handler);
return { dispose: () => stateHandlers.delete(handler) };
}),
emitState(next: ConnectionState) {
state = next;
for (const h of stateHandlers) h(next);
},
};
return transport as typeof transport & Transport;
}
describe("ConnectionController connectionEnabled", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not connect on attach when connection is disabled", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, false);
expect(transport.connect).not.toHaveBeenCalled();
expect(controller.snapshot().connectionEnabled).toBe(false);
expect(controller.snapshot().state).toBe("disconnected");
});
it("disconnects when connection is disabled", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, true);
expect(transport.connect).toHaveBeenCalled();
await controller.setConnectionEnabled(false);
expect(transport.disconnect).toHaveBeenCalled();
expect(controller.snapshot().connectionEnabled).toBe(false);
expect(controller.snapshot().handshake).toBeNull();
expect(controller.snapshot().lastError).toBeNull();
});
it("reconnects when connection is re-enabled", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, false);
await controller.setConnectionEnabled(true);
expect(transport.connect).toHaveBeenCalled();
expect(controller.snapshot().connectionEnabled).toBe(true);
});
it("ignores transport state changes while disabled", async () => {
const controller = new ConnectionController();
const transport = makeMockTransport();
await controller.attach(transport, { name: "Chrome", version: "120" }, false);
transport.emitState("connected");
expect(controller.snapshot().state).toBe("disconnected");
});
});
@@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import {
HELP_CANCEL,
HELP_REQUEST,
HELP_RESPONSE,
isHelpCancelMessage,
isHelpRequestMessage,
} from "../help-bridge";
describe("help-bridge", () => {
it("exposes stable message type constants", () => {
expect(HELP_REQUEST).toBe("bsk-help-request");
expect(HELP_RESPONSE).toBe("bsk-help-response");
expect(HELP_CANCEL).toBe("bsk-help-cancel");
});
it("type-guards a help-request message", () => {
expect(
isHelpRequestMessage({
type: HELP_REQUEST,
requestId: "r1",
prompt: "log in",
selectors: ["#login"],
timeoutMs: 1000,
}),
).toBe(true);
expect(isHelpRequestMessage({ type: "borrow-request" })).toBe(false);
expect(isHelpRequestMessage(null)).toBe(false);
});
it("rejects a help-request message with missing/wrong fields", () => {
expect(isHelpRequestMessage({ type: HELP_REQUEST })).toBe(false);
expect(
isHelpRequestMessage({
type: HELP_REQUEST,
requestId: "r1",
prompt: "p",
selectors: "nope",
timeoutMs: 1,
}),
).toBe(false);
expect(
isHelpRequestMessage({
type: HELP_REQUEST,
requestId: "r1",
prompt: "p",
selectors: [123],
timeoutMs: 1,
}),
).toBe(false);
});
it("type-guards a help-cancel message", () => {
expect(
isHelpCancelMessage({ type: HELP_CANCEL, requestId: "r1" }),
).toBe(true);
expect(isHelpCancelMessage({ type: HELP_REQUEST, requestId: "r1" })).toBe(
false,
);
expect(isHelpCancelMessage(null)).toBe(false);
});
});
@@ -0,0 +1,96 @@
import { describe, expect, it, vi } from "vitest";
import {
getConnectionEnabled,
getLabel,
getOrCreateInstanceId,
STORAGE_KEYS,
setConnectionEnabled,
setLabel,
} from "../instance-id";
function fakeStorage(initial: Record<string, unknown> = {}) {
const store = { ...initial };
return {
store,
backend: {
get: vi.fn(async (keys: string | string[]) => {
const result: Record<string, unknown> = {};
const list = Array.isArray(keys) ? keys : [keys];
for (const k of list) if (k in store) result[k] = store[k];
return result;
}),
set: vi.fn(async (items: Record<string, unknown>) => {
Object.assign(store, items);
}),
},
};
}
describe("instance-id", () => {
it("generates a new 8-char hex id when storage is empty and persists it", async () => {
const { store, backend } = fakeStorage();
const id = await getOrCreateInstanceId(backend);
expect(id).toMatch(/^[0-9a-f]{8}$/);
expect(store[STORAGE_KEYS.INSTANCE_ID]).toBe(id);
expect(backend.set).toHaveBeenCalledOnce();
});
it("returns the persisted short id on subsequent calls", async () => {
const existing = "a7f32e1c";
const { backend } = fakeStorage({ [STORAGE_KEYS.INSTANCE_ID]: existing });
const id = await getOrCreateInstanceId(backend);
expect(id).toBe(existing);
expect(backend.set).not.toHaveBeenCalled();
});
it("replaces legacy UUID storage with a short id", async () => {
const legacy = "abcdef01-2345-4678-89ab-cdef01234567";
const { backend, store } = fakeStorage({ [STORAGE_KEYS.INSTANCE_ID]: legacy });
const id = await getOrCreateInstanceId(backend);
expect(id).toMatch(/^[0-9a-f]{8}$/);
expect(id).not.toBe(legacy);
expect(store[STORAGE_KEYS.INSTANCE_ID]).toBe(id);
expect(backend.set).toHaveBeenCalledOnce();
});
it("treats non-string stored values as missing", async () => {
const { backend, store } = fakeStorage({ [STORAGE_KEYS.INSTANCE_ID]: 42 });
const id = await getOrCreateInstanceId(backend);
expect(id).toMatch(/^[0-9a-f]{8}$/);
expect(store[STORAGE_KEYS.INSTANCE_ID]).toBe(id);
});
it("getLabel returns empty string when label is unset", async () => {
const { backend } = fakeStorage();
expect(await getLabel(backend)).toBe("");
});
it("setLabel persists the value retrievable by getLabel", async () => {
const { backend, store } = fakeStorage();
await setLabel("Personal Chrome", backend);
expect(store[STORAGE_KEYS.LABEL]).toBe("Personal Chrome");
expect(await getLabel(backend)).toBe("Personal Chrome");
});
it("getConnectionEnabled returns true when storage is empty", async () => {
const { backend } = fakeStorage();
expect(await getConnectionEnabled(backend)).toBe(true);
});
it("getConnectionEnabled returns persisted boolean values", async () => {
const { backend } = fakeStorage({ [STORAGE_KEYS.CONNECTION_ENABLED]: false });
expect(await getConnectionEnabled(backend)).toBe(false);
});
it("getConnectionEnabled treats non-boolean stored values as enabled", async () => {
const { backend } = fakeStorage({ [STORAGE_KEYS.CONNECTION_ENABLED]: "false" });
expect(await getConnectionEnabled(backend)).toBe(true);
});
it("setConnectionEnabled persists the value retrievable by getConnectionEnabled", async () => {
const { backend, store } = fakeStorage();
await setConnectionEnabled(false, backend);
expect(store[STORAGE_KEYS.CONNECTION_ENABLED]).toBe(false);
expect(await getConnectionEnabled(backend)).toBe(false);
});
});
@@ -0,0 +1,150 @@
import { describe, expect, it, vi } from "vitest";
import type { Transport } from "@/transport/transport";
import type { ConnectionState } from "@/transport/types";
import { type AlarmsApi, KEEPALIVE_ALARM_NAME, startKeepalive } from "../keepalive";
function makeAlarms(): AlarmsApi & {
triggers: Array<(alarm: { name: string }) => void>;
created: Array<{ name: string; info: { periodInMinutes: number } }>;
cleared: string[];
} {
const triggers: Array<(alarm: { name: string }) => void> = [];
const created: Array<{ name: string; info: { periodInMinutes: number } }> = [];
const cleared: string[] = [];
return {
triggers,
created,
cleared,
create: (name, info) => {
created.push({ name, info });
},
clear: (name) => {
cleared.push(name);
},
onAlarm: {
addListener: (cb) => triggers.push(cb),
removeListener: (cb) => {
const idx = triggers.indexOf(cb);
if (idx >= 0) triggers.splice(idx, 1);
},
},
};
}
function makeTransport(initialState: ConnectionState = "disconnected"): Transport & {
connectCalls: number;
} {
let state = initialState;
const transport = {
connectCalls: 0,
get state() {
return state;
},
connect: vi.fn(async () => {
state = "connected";
transport.connectCalls += 1;
}),
disconnect: vi.fn(async () => {
state = "disconnected";
}),
send: vi.fn(),
onMessage: vi.fn(() => ({ dispose: () => {} })),
onConnectionStateChange: vi.fn(() => ({ dispose: () => {} })),
} as unknown as Transport & { connectCalls: number };
return transport;
}
describe("startKeepalive", () => {
it("registers a 30s alarm and a listener on creation", () => {
const alarms = makeAlarms();
const transport = makeTransport();
const handle = startKeepalive({ transport, alarms });
expect(alarms.created).toEqual([
{ name: KEEPALIVE_ALARM_NAME, info: { periodInMinutes: 0.5 } },
]);
expect(alarms.triggers).toHaveLength(1);
handle.dispose();
expect(alarms.cleared).toEqual([KEEPALIVE_ALARM_NAME]);
expect(alarms.triggers).toHaveLength(0);
});
it("kicks transport.connect() when the alarm fires and we are disconnected", async () => {
const alarms = makeAlarms();
const transport = makeTransport("disconnected");
startKeepalive({ transport, alarms });
await Promise.resolve();
expect(transport.connectCalls).toBe(0);
// Fire the alarm; allow microtasks to drain.
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
await Promise.resolve();
await Promise.resolve();
expect(transport.connectCalls).toBe(1);
});
it("does not call transport.connect() while connected", async () => {
const alarms = makeAlarms();
const transport = makeTransport("connected");
startKeepalive({ transport, alarms });
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
await Promise.resolve();
await Promise.resolve();
expect(transport.connectCalls).toBe(0);
});
it("ignores alarms for other names", async () => {
const alarms = makeAlarms();
const transport = makeTransport("disconnected");
startKeepalive({ transport, alarms });
alarms.triggers[0]({ name: "someone-else" });
await Promise.resolve();
await Promise.resolve();
expect(transport.connectCalls).toBe(0);
});
it("swallows connect rejections so the alarm loop keeps running", async () => {
const alarms = makeAlarms();
const transport = makeTransport("disconnected");
const mockedConnect = transport.connect as unknown as {
mockImplementation: (fn: () => Promise<void>) => void;
};
mockedConnect.mockImplementation(async () => {
throw new Error("boom");
});
const handle = startKeepalive({ transport, alarms });
await expect(handle.tickForTest()).resolves.toBeUndefined();
});
it("does not reconnect when shouldConnect returns false", async () => {
const alarms = makeAlarms();
const transport = makeTransport("disconnected");
startKeepalive({ transport, alarms, shouldConnect: () => false });
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
await Promise.resolve();
await Promise.resolve();
expect(transport.connectCalls).toBe(0);
});
it("reconnects when shouldConnect returns true", async () => {
const alarms = makeAlarms();
const transport = makeTransport("disconnected");
let enabled = false;
startKeepalive({ transport, alarms, shouldConnect: () => enabled });
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
await Promise.resolve();
await Promise.resolve();
expect(transport.connectCalls).toBe(0);
enabled = true;
alarms.triggers[0]({ name: KEEPALIVE_ALARM_NAME });
await Promise.resolve();
await Promise.resolve();
expect(transport.connectCalls).toBe(1);
});
});
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import {
OVERLAY_AGENT_OVERLAY_RESET,
OVERLAY_MSG_INTERRUPT,
type OverlayInterruptRequest,
type OverlayInterruptResponse,
isOverlayAgentOverlayResetMessage,
} from "@/lib/overlay-bridge";
describe("OVERLAY_MSG_INTERRUPT", () => {
it("constant matches the wire string content scripts will send", () => {
expect(OVERLAY_MSG_INTERRUPT).toBe("overlay.interrupt");
});
it("OverlayInterruptRequest type carries kind + sessionId", () => {
const req: OverlayInterruptRequest = {
kind: OVERLAY_MSG_INTERRUPT,
sessionId: "sess-1",
};
expect(req.kind).toBe("overlay.interrupt");
expect(req.sessionId).toBe("sess-1");
});
it("OverlayInterruptResponse carries ok flag", () => {
const res: OverlayInterruptResponse = { ok: true };
expect(res.ok).toBe(true);
});
});
describe("isOverlayAgentOverlayResetMessage", () => {
it("accepts reset messages with a session id", () => {
expect(
isOverlayAgentOverlayResetMessage({
type: OVERLAY_AGENT_OVERLAY_RESET,
sessionId: "sess-1",
}),
).toBe(true);
});
it("rejects reset messages without a session id", () => {
expect(
isOverlayAgentOverlayResetMessage({
type: OVERLAY_AGENT_OVERLAY_RESET,
}),
).toBe(false);
});
});
@@ -0,0 +1,36 @@
import { describe, it, expect, vi } from "vitest";
import { sendInterrupt } from "@/lib/overlay-interrupt-client";
describe("sendInterrupt", () => {
it("sends OVERLAY_MSG_INTERRUPT with the session id and resolves to the reply", async () => {
const sendMessage = vi.fn().mockResolvedValue({ ok: true });
const result = await sendInterrupt(sendMessage, "sess-9");
expect(sendMessage).toHaveBeenCalledWith({
kind: "overlay.interrupt",
sessionId: "sess-9",
});
expect(result).toEqual({ ok: true });
});
it("treats an absent reply as failure", async () => {
const sendMessage = vi.fn().mockResolvedValue(undefined);
const result = await sendInterrupt(sendMessage, "sess-1");
expect(result).toEqual({ ok: false });
});
it("treats a thrown sendMessage as failure", async () => {
const sendMessage = vi.fn().mockRejectedValue(new Error("boom"));
const result = await sendInterrupt(sendMessage, "sess-1");
expect(result).toEqual({ ok: false });
});
it("resolves to ok=false if the round trip exceeds the timeout", async () => {
vi.useFakeTimers();
const sendMessage = vi.fn(() => new Promise(() => {})); // never resolves
const promise = sendInterrupt(sendMessage, "sess-t", { timeoutMs: 2000 });
vi.advanceTimersByTime(2000);
const result = await promise;
expect(result).toEqual({ ok: false });
vi.useRealTimers();
});
});
@@ -0,0 +1,124 @@
import { describe, expect, it } from "vitest";
import { compareProtocol, parseProtocolMajor } from "../semver";
describe("compareProtocol", () => {
it("orders minor versions", () => {
expect(compareProtocol("1.1", "1.0")).toBeGreaterThan(0);
expect(compareProtocol("1.0", "1.1")).toBeLessThan(0);
});
it("treats major-only as zero minor", () => {
expect(compareProtocol("1", "1.0")).toBe(0);
});
it("returns null for unparseable input", () => {
expect(compareProtocol("oops", "1.0")).toBeNull();
expect(compareProtocol("1.0", "1.x")).toBeNull();
});
});
describe("parseProtocolMajor (review round 3 I2: match Rust parse_major)", () => {
// The daemon uses `value.split('.').next().and_then(|x|
// x.parse::<u64>().ok())` (see `bsk-protocol::system::parse_major`).
// The extension's parser MUST behave identically — any input the
// daemon accepts has to round-trip to the same major here, and any
// input the daemon rejects must return `null`. Pre-fix the regex
// `/^(\d+)/` happily peeled off `1` from "1x" / "1e3" / " 1.0",
// which would let `computeConnectedState` pass the protocol-major
// gate on inputs that `evaluate_handshake_compat` would reject —
// the same asymmetry round 3 I1 fought on the app-version axis.
it("rejects trailing non-digit characters (e.g. '1x')", () => {
// Rust: 'split(.) -> "1x"' -> u64::parse fails -> None
expect(parseProtocolMajor("1x")).toBeNull();
});
it("rejects scientific notation (e.g. '1e3')", () => {
// Rust u64 has no concept of "1e3"; ours must not silently
// accept it either.
expect(parseProtocolMajor("1e3")).toBeNull();
});
it("rejects empty strings", () => {
expect(parseProtocolMajor("")).toBeNull();
});
it("rejects non-numeric leading field (e.g. 'x.0')", () => {
expect(parseProtocolMajor("x.0")).toBeNull();
});
it("rejects negative numbers (e.g. '-1.0')", () => {
// The daemon's strict digit parser rejects '-1' (the sign is not
// an ASCII digit) before delegating to `u64::from_str`; /^\d+$/
// rejects it here too.
expect(parseProtocolMajor("-1.0")).toBeNull();
});
it("rejects leading whitespace (e.g. ' 1.0')", () => {
// The daemon's strict digit parser rejects leading whitespace
// (space is not an ASCII digit); we must too.
expect(parseProtocolMajor(" 1.0")).toBeNull();
});
it("rejects an explicit + sign (e.g. '+1.0')", () => {
// `u64::from_str("+1")` actually returns `Ok(1)` — the std-lib
// parser peels off a leading `+`. The daemon's strict digit
// parser (see `parse_major` in `crates/bsk-protocol/src/system.rs`)
// is what rejects this input, by requiring `/^\d+$/` on the head
// segment before delegating to `u64::from_str`. We mirror that
// reject here so the two parsers reach the same verdict.
expect(parseProtocolMajor("+1.0")).toBeNull();
});
it("rejects values past Number.MAX_SAFE_INTEGER conservatively", () => {
// Round 5 I1: the daemon now caps `parse_major` at
// `Number.MAX_SAFE_INTEGER` for parser symmetry, so both peers
// reject the entire `MAX_SAFE_INTEGER < n <= u64::MAX` band
// (previously the daemon accepted those values while the TS
// side's `!Number.isSafeInteger` guard rejected them).
const tooBig = "99999999999999999999"; // 20 digits, > 2^64 too
expect(parseProtocolMajor(tooBig)).toBeNull();
expect(parseProtocolMajor(`${tooBig}.0`)).toBeNull();
});
it("rejects MAX_SAFE_INTEGER + 1 ('9007199254740992')", () => {
// First value above the cap. Matches the daemon's
// `rejects_value_above_max_safe_integer` test on the Rust side.
expect(parseProtocolMajor("9007199254740992")).toBeNull();
});
it("rejects u64::MAX ('18446744073709551615')", () => {
// A valid u64 literal but well above `Number.MAX_SAFE_INTEGER`.
// Pre round 5 the daemon accepted this as `Some(u64::MAX)` while
// the TS side rejected it via `!Number.isSafeInteger` — exactly
// the asymmetry round 5 I1 closes.
expect(parseProtocolMajor("18446744073709551615")).toBeNull();
});
it("accepts leading zeros (e.g. '01.0' -> 1)", () => {
// The daemon's `u64::from_str` accepts leading zeros after the
// digit guard passes, so we must too.
expect(parseProtocolMajor("01.0")).toBe(1);
});
it("ignores everything past the first dot ('1.x' -> 1)", () => {
// The daemon takes only `split('.').next()`, so the minor
// segment's shape never affects the major.
expect(parseProtocolMajor("1.x")).toBe(1);
});
it("returns the parsed major for the canonical wire format ('1.0.0' -> 1)", () => {
expect(parseProtocolMajor("1.0.0")).toBe(1);
});
it("returns the parsed major for a single-segment input ('123' -> 123)", () => {
expect(parseProtocolMajor("123")).toBe(123);
});
it("accepts exactly Number.MAX_SAFE_INTEGER as the upper bound", () => {
// Boundary case: at MAX_SAFE_INTEGER the value still round-trips
// through `Number()` without precision loss, so we accept it.
// The daemon's matching test is `accepts_value_at_max_safe_integer`.
expect(parseProtocolMajor(`${Number.MAX_SAFE_INTEGER}`)).toBe(Number.MAX_SAFE_INTEGER);
});
});
@@ -0,0 +1,260 @@
import {
EXTENSION_VERSION,
MIN_COMPATIBLE_PROTOCOL,
PROTOCOL_VERSION,
performHandshake,
} from "../transport/handshake";
import type { Transport } from "../transport/transport";
import type { ConnectionState, HandshakeResult } from "../transport/types";
import { getLabel, getOrCreateInstanceId } from "./instance-id";
import { compareProtocol, parseProtocolMajor } from "./semver";
export interface SnapshotInfo {
state: ConnectionState;
instanceId: string;
label: string;
extensionVersion: string;
handshake: HandshakeResult | null;
lastError: string | null;
connectionEnabled: boolean;
}
type Listener = (s: SnapshotInfo) => void;
/**
* Orchestrates Transport + handshake lifecycle for the background SW.
*
* Owns the canonical `ConnectionState` and `HandshakeResult` cache that
* the popup queries via `chrome.runtime.connect` (wired in M4.5).
*/
export class ConnectionController {
private transport: Transport | null = null;
private currentState: ConnectionState = "disconnected";
private handshake: HandshakeResult | null = null;
private instanceId = "";
private label = "";
private lastError: string | null = null;
private connectionEnabled = true;
private listeners = new Set<Listener>();
private handshakeInFlight = false;
get isConnectionEnabled(): boolean {
return this.connectionEnabled;
}
/**
* Subscribe to snapshot changes. Fires immediately with the current
* snapshot, then again on every transition.
*/
subscribe(listener: Listener): () => void {
this.listeners.add(listener);
listener(this.snapshot());
return () => this.listeners.delete(listener);
}
snapshot(): SnapshotInfo {
return {
state: this.currentState,
instanceId: this.instanceId,
label: this.label,
extensionVersion: EXTENSION_VERSION,
handshake: this.handshake,
lastError: this.lastError,
connectionEnabled: this.connectionEnabled,
};
}
async attach(
transport: Transport,
browser: { name: string; version: string },
connectionEnabled = true,
): Promise<void> {
this.transport = transport;
this.connectionEnabled = connectionEnabled;
this.instanceId = await getOrCreateInstanceId();
this.label = await getLabel();
transport.onConnectionStateChange((s) => {
if (!this.connectionEnabled) return;
if (s === "connected") {
void this.runHandshake(browser);
return;
}
if (s === "disconnected") {
this.handshake = null;
}
this.setState(s);
});
transport.onMessage(() => {
// Real RPC dispatching is wired in M5 (tools/dispatcher.ts).
});
if (this.connectionEnabled) {
try {
await transport.connect();
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err);
this.setState("disconnected");
}
} else {
this.applyDisabledState();
}
}
async setConnectionEnabled(enabled: boolean): Promise<void> {
if (this.connectionEnabled === enabled) return;
this.connectionEnabled = enabled;
if (!enabled) {
await this.applyDisabledState();
return;
}
this.fire();
if (!this.transport) return;
try {
await this.transport.connect();
} catch (err) {
this.lastError = err instanceof Error ? err.message : String(err);
this.setState("disconnected");
}
}
async refreshLabel(): Promise<void> {
this.label = await getLabel();
this.fire();
}
private async runHandshake(browser: { name: string; version: string }): Promise<void> {
if (!this.transport) return;
if (!this.connectionEnabled) return;
if (this.handshakeInFlight) return;
this.handshakeInFlight = true;
this.setState("connecting");
try {
const outcome = await performHandshake(this.transport, {
instanceId: this.instanceId,
browser,
label: this.label,
});
this.handshake = outcome.result;
const verdict = computeConnectedState(outcome.result);
if (verdict.kind === "rejected") {
this.lastError = `version_too_old: ${verdict.reason}`;
this.handshake = null;
await this.transport.disconnect().catch(() => {});
this.setState("disconnected");
return;
}
this.lastError = null;
this.setState(verdict.kind);
} catch (err) {
this.handshake = null;
this.lastError = err instanceof Error ? err.message : String(err);
this.setState("disconnected");
} finally {
this.handshakeInFlight = false;
}
}
private async applyDisabledState(): Promise<void> {
this.handshake = null;
this.lastError = null;
if (this.transport) {
await this.transport.disconnect().catch(() => {});
}
const was = this.currentState;
this.setState("disconnected");
if (was === "disconnected") this.fire();
}
private setState(next: ConnectionState): void {
if (this.currentState === next) return;
this.currentState = next;
this.fire();
}
private fire(): void {
const snap = this.snapshot();
for (const l of this.listeners) {
try {
l(snap);
} catch (err) {
console.error("[bh] connection snapshot listener threw", err);
}
}
}
}
/**
* Verdict of the symmetric post-handshake compat check.
*/
export type HandshakeVerdict =
| { kind: "connected" }
| { kind: "version_skew" }
| { kind: "rejected"; reason: string };
/**
* Symmetric extension-side **protocol** compat check.
*
* Mirrors `evaluate_handshake_compat` in `crates/bsk-protocol/src/system.rs`.
* Application semvers are not compared — CLI and extension ship on
* independent version lines.
*
* Ordering:
* 1. protocol major mismatch → `rejected`
* 2. daemon protocol < extension's protocol floor → `rejected`
* 3. extension protocol < daemon's protocol floor → `rejected` (skipped when absent)
* 4. protocol strings equal → `connected`
* 5. otherwise (same major, minor drift) → `version_skew`
*
* `localMinCompatibleProtocol` defaults to {@link MIN_COMPATIBLE_PROTOCOL};
* tests may inject a different floor.
*/
export function computeConnectedState(
handshake: HandshakeResult,
localMinCompatibleProtocol: string = MIN_COMPATIBLE_PROTOCOL,
): HandshakeVerdict {
const daemonMajor = parseProtocolMajor(handshake.protocol_version);
const ourMajor = parseProtocolMajor(PROTOCOL_VERSION);
if (daemonMajor === null || ourMajor === null || daemonMajor !== ourMajor) {
return {
kind: "rejected",
reason: `protocol-major mismatch (daemon protocol v${handshake.protocol_version}, extension protocol v${PROTOCOL_VERSION})`,
};
}
if (compareProtocol(handshake.protocol_version, localMinCompatibleProtocol) === null) {
return {
kind: "rejected",
reason: `daemon protocol v${handshake.protocol_version} is unparseable`,
};
}
if (compareProtocol(handshake.protocol_version, localMinCompatibleProtocol)! < 0) {
return {
kind: "rejected",
reason: `daemon protocol v${handshake.protocol_version} below extension min_compatible_protocol ${localMinCompatibleProtocol}`,
};
}
const peerFloor = handshake.min_compatible_protocol;
if (peerFloor) {
if (compareProtocol(PROTOCOL_VERSION, peerFloor) === null) {
return {
kind: "rejected",
reason: `daemon min_compatible_protocol ${peerFloor} is unparseable`,
};
}
if (compareProtocol(PROTOCOL_VERSION, peerFloor)! < 0) {
return {
kind: "rejected",
reason: `extension protocol v${PROTOCOL_VERSION} below daemon min_compatible_protocol ${peerFloor}`,
};
}
}
if (handshake.protocol_version === PROTOCOL_VERSION) {
return { kind: "connected" };
}
return { kind: "version_skew" };
}
export const __testing__ = { computeConnectedState };
+60
View File
@@ -0,0 +1,60 @@
/**
* Wire protocol for the human-in-loop overlay, sent between the
* background service worker and a tab's content script via
* `chrome.runtime.sendMessage` / `chrome.tabs.sendMessage`.
*
* Mirrors the borrow-confirmation message style (see
* `@/tools/borrow-confirmation`). The background asks a specific tab to
* enter "help mode" (render HelpRequestOverlay + hide ControlOverlay);
* the content script replies once the user clicks Continue / Cancel.
*/
export const HELP_REQUEST = "bsk-help-request";
export const HELP_RESPONSE = "bsk-help-response";
export const HELP_CANCEL = "bsk-help-cancel";
export interface HelpRequestMessage {
type: typeof HELP_REQUEST;
requestId: string;
prompt: string;
/** Custom overlay title; omitted when the extension should use its default. */
title?: string;
/** CSS selectors to scroll to + flash-highlight (may be empty). */
selectors: string[];
timeoutMs: number;
}
export interface HelpResponseMessage {
type: typeof HELP_RESPONSE;
outcome: "continued" | "cancelled";
note?: string;
}
export interface HelpCancelMessage {
type: typeof HELP_CANCEL;
requestId: string;
}
export function isHelpRequestMessage(msg: unknown): msg is HelpRequestMessage {
if (typeof msg !== "object" || msg === null) {
return false;
}
const m = msg as Record<string, unknown>;
return (
m.type === HELP_REQUEST &&
typeof m.requestId === "string" &&
typeof m.prompt === "string" &&
(m.title === undefined || typeof m.title === "string") &&
Array.isArray(m.selectors) &&
m.selectors.every((selector) => typeof selector === "string") &&
typeof m.timeoutMs === "number"
);
}
export function isHelpCancelMessage(msg: unknown): msg is HelpCancelMessage {
if (typeof msg !== "object" || msg === null) {
return false;
}
const m = msg as Record<string, unknown>;
return m.type === HELP_CANCEL && typeof m.requestId === "string";
}
+112
View File
@@ -0,0 +1,112 @@
const STORAGE_KEY = "bsk_instance_id";
const LABEL_STORAGE_KEY = "bh_label";
const CONNECTION_ENABLED_KEY = "bh_connection_enabled";
export interface StorageBackend {
get(keys: string | string[]): Promise<Record<string, unknown>>;
set(items: Record<string, unknown>): Promise<void>;
}
export function defaultStorage(): StorageBackend {
return {
get: (keys) =>
new Promise((resolve, reject) => {
try {
chrome.storage.local.get(keys, (items) => {
const err = chrome.runtime.lastError;
if (err) reject(new Error(err.message));
else resolve(items as Record<string, unknown>);
});
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
}),
set: (items) =>
new Promise((resolve, reject) => {
try {
chrome.storage.local.set(items, () => {
const err = chrome.runtime.lastError;
if (err) reject(new Error(err.message));
else resolve();
});
} catch (err) {
reject(err instanceof Error ? err : new Error(String(err)));
}
}),
};
}
/** Length of newly generated browser instance ids (8 hex chars → 32 bits). */
export const INSTANCE_ID_LENGTH = 8;
/** Matches ids produced by {@link generateShortInstanceId}. */
export const SHORT_INSTANCE_ID_PATTERN = /^[0-9a-f]{8}$/;
function generateShortInstanceId(): string {
const buf = new Uint8Array(INSTANCE_ID_LENGTH / 2);
if (typeof crypto !== "undefined") {
crypto.getRandomValues(buf);
} else {
for (let i = 0; i < buf.length; i += 1) buf[i] = Math.floor(Math.random() * 256);
}
return Array.from(buf, (b) => b.toString(16).padStart(2, "0")).join("");
}
function isPersistedInstanceId(value: unknown): value is string {
return typeof value === "string" && SHORT_INSTANCE_ID_PATTERN.test(value);
}
/**
* Read the persistent extension instance_id from `chrome.storage.local`,
* generating + persisting a fresh 8-char hex id on first use.
*
* The id is opaque to the daemon — it just needs to be stable across SW
* restarts so the daemon can correlate reconnects to the same browser.
* Legacy UUID values in storage are replaced on read so agents can use
* short `--browser` arguments.
*/
export async function getOrCreateInstanceId(
storage: StorageBackend = defaultStorage(),
): Promise<string> {
const items = await storage.get(STORAGE_KEY);
const existing = items[STORAGE_KEY];
if (isPersistedInstanceId(existing)) return existing;
const fresh = generateShortInstanceId();
await storage.set({ [STORAGE_KEY]: fresh });
return fresh;
}
export async function getLabel(storage: StorageBackend = defaultStorage()): Promise<string> {
const items = await storage.get(LABEL_STORAGE_KEY);
const raw = items[LABEL_STORAGE_KEY];
return typeof raw === "string" ? raw : "";
}
export async function setLabel(
label: string,
storage: StorageBackend = defaultStorage(),
): Promise<void> {
await storage.set({ [LABEL_STORAGE_KEY]: label });
}
/** Defaults to enabled when unset or non-boolean. */
export async function getConnectionEnabled(
storage: StorageBackend = defaultStorage(),
): Promise<boolean> {
const items = await storage.get(CONNECTION_ENABLED_KEY);
const raw = items[CONNECTION_ENABLED_KEY];
return typeof raw === "boolean" ? raw : true;
}
export async function setConnectionEnabled(
enabled: boolean,
storage: StorageBackend = defaultStorage(),
): Promise<void> {
await storage.set({ [CONNECTION_ENABLED_KEY]: enabled });
}
export const STORAGE_KEYS = {
INSTANCE_ID: STORAGE_KEY,
LABEL: LABEL_STORAGE_KEY,
CONNECTION_ENABLED: CONNECTION_ENABLED_KEY,
} as const;
+114
View File
@@ -0,0 +1,114 @@
/**
* MV3 service-worker keepalive + transport supervisor.
*
* Address review M4/M5 C3 + C4: an MV3 service worker is evicted after
* ~30s of no events. WebSocket I/O on its own does not keep the SW
* alive, so an idle daemon connection eventually loses the SW, drops
* the underlying socket, and the daemon then purges every session
* bound to this browser. `WSTransport.scheduleReconnect` uses
* `setTimeout` which also does not survive eviction.
*
* This module owns both halves of the mitigation:
* - A 30s `chrome.alarms` heartbeat that keeps the SW awake whenever
* we want the daemon link to stay live.
* - On each tick, if the transport is not connected, kick a fresh
* `connect()` so we never sit on a stale `disconnected` state just
* because the only `setTimeout` reconnect timer was lost.
*
* The alarm period is the minimum allowed by Chrome stable
* (`periodInMinutes: 0.5` → 30s), which lines up with the documented
* SW idle threshold.
*/
import type { Transport } from "@/transport/transport";
export const KEEPALIVE_ALARM_NAME = "bh-keepalive";
export const KEEPALIVE_PERIOD_MIN = 0.5; // 30s, the smallest Chrome accepts.
/** Subset of `chrome.alarms` we actually use; lets vitest inject a fake. */
export interface AlarmsApi {
create(name: string, info: { periodInMinutes: number }): void;
clear(name: string, cb?: (wasCleared: boolean) => void): void;
onAlarm: {
addListener(cb: (alarm: { name: string }) => void): void;
removeListener(cb: (alarm: { name: string }) => void): void;
};
}
function defaultAlarms(): AlarmsApi | null {
if (typeof chrome === "undefined") return null;
if (!chrome.alarms?.create || !chrome.alarms?.onAlarm) return null;
return {
create: (name, info) => chrome.alarms.create(name, info),
clear: (name, cb) => {
void chrome.alarms.clear(name).then((wasCleared) => cb?.(wasCleared));
},
onAlarm: {
addListener: (cb) => chrome.alarms.onAlarm.addListener(cb),
removeListener: (cb) => chrome.alarms.onAlarm.removeListener(cb),
},
};
}
export interface KeepaliveOptions {
transport: Transport;
/** When provided and returns false, skip reconnect attempts on alarm ticks. */
shouldConnect?: () => boolean;
alarms?: AlarmsApi;
/** Override the alarm name (tests). */
alarmName?: string;
/** Override the alarm period (tests). */
periodMin?: number;
}
export interface KeepaliveHandle {
/** Detach the alarm listener and clear the alarm. */
dispose(): void;
/** Exposed for tests; simulates one alarm tick. */
tickForTest(): Promise<void>;
}
/**
* Install a SW-revival heartbeat that also forces an idle reconnect on
* every tick. The heartbeat itself is the alarm callback firing —
* which counts as activity and keeps the SW alive.
*/
export function startKeepalive(options: KeepaliveOptions): KeepaliveHandle {
const alarms = options.alarms ?? defaultAlarms();
const name = options.alarmName ?? KEEPALIVE_ALARM_NAME;
const period = options.periodMin ?? KEEPALIVE_PERIOD_MIN;
const tick = async () => {
if (options.shouldConnect && !options.shouldConnect()) return;
if (options.transport.state === "connected") return;
try {
await options.transport.connect();
} catch (err) {
console.debug("[bsk keepalive] connect attempt failed", err);
}
};
if (!alarms) {
// No chrome.alarms in this environment (e.g. unit tests that didn't
// provide a fake). Disposing is a no-op.
return {
dispose: () => {},
tickForTest: tick,
};
}
const handler = (alarm: { name: string }) => {
if (alarm.name !== name) return;
void tick();
};
alarms.onAlarm.addListener(handler);
alarms.create(name, { periodInMinutes: period });
return {
dispose: () => {
alarms.onAlarm.removeListener(handler);
alarms.clear(name);
},
tickForTest: tick,
};
}
+62
View File
@@ -0,0 +1,62 @@
/**
* Wire protocol for `chrome.runtime.sendMessage`-driven communication
* between the content-script control overlay and the background SW.
*
* Content script → background:
* - `{ kind: "overlay.who_am_i", tabId, windowId }` → background
* replies `{ sessionId: string | null }` so the overlay knows
* whether to render itself.
* - `{ kind: "overlay.interrupt", sessionId }` → background asks the
* daemon (via a `session.user_interrupt` WS event) to cancel
* every inflight + queued tool call for that session with
* `ErrorCode::UserAborted`. The Agent Window, CDP attachment,
* and conversation context are preserved.
*/
export const OVERLAY_MSG_WHO_AM_I = "overlay.who_am_i";
export const OVERLAY_MSG_INTERRUPT = "overlay.interrupt";
export interface OverlayWhoAmIRequest {
kind: typeof OVERLAY_MSG_WHO_AM_I;
tabId?: number;
windowId?: number;
}
export interface OverlayWhoAmIResponse {
sessionId: string | null;
}
export interface OverlayInterruptRequest {
kind: typeof OVERLAY_MSG_INTERRUPT;
sessionId: string;
}
export interface OverlayInterruptResponse {
ok: boolean;
}
/** Background → content: temporarily disable overlay click blocker for CDP clicks. */
export const OVERLAY_AUTOMATION_BYPASS = "bh-automation-bypass";
export interface OverlayAutomationBypassMessage {
type: typeof OVERLAY_AUTOMATION_BYPASS;
enabled: boolean;
}
/** Background → content: clear overlays that belong only inside an Agent tab. */
export const OVERLAY_AGENT_OVERLAY_RESET = "bh-agent-overlay-reset";
export interface OverlayAgentOverlayResetMessage {
type: typeof OVERLAY_AGENT_OVERLAY_RESET;
sessionId: string;
}
export function isOverlayAgentOverlayResetMessage(
message: unknown,
): message is OverlayAgentOverlayResetMessage {
if (!message || typeof message !== "object") return false;
const candidate = message as { type?: unknown; sessionId?: unknown };
return candidate.type === OVERLAY_AGENT_OVERLAY_RESET && typeof candidate.sessionId === "string";
}
export type OverlayMessage = OverlayWhoAmIRequest | OverlayInterruptRequest;
@@ -0,0 +1,48 @@
import {
OVERLAY_MSG_INTERRUPT,
type OverlayInterruptRequest,
type OverlayInterruptResponse,
} from "@/lib/overlay-bridge";
const DEFAULT_TIMEOUT_MS = 2000;
export interface SendInterruptOptions {
timeoutMs?: number;
}
/**
* Round-trip an `overlay.interrupt` message to the background SW.
*
* Resolves to `{ ok: true }` only when the SW explicitly replies
* with `ok: true`. All other outcomes — undefined reply, thrown
* sendMessage, timeout — collapse to `{ ok: false }` so the caller
* can take the same retract-and-retry path regardless of cause.
*
* The 2 s soft timeout matches the design doc: the user must never
* wait longer than that to see the mask retract, even if the
* daemon is unreachable. The cancellation itself is fire-and-forget
* on the daemon side — a slow ack does not invalidate it.
*/
export async function sendInterrupt(
sendMessage: (msg: OverlayInterruptRequest) => Promise<unknown>,
sessionId: string,
options: SendInterruptOptions = {},
): Promise<OverlayInterruptResponse> {
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
const req: OverlayInterruptRequest = { kind: OVERLAY_MSG_INTERRUPT, sessionId };
let timer: ReturnType<typeof setTimeout> | null = null;
const timeout = new Promise<OverlayInterruptResponse>((resolve) => {
timer = setTimeout(() => resolve({ ok: false }), timeoutMs);
});
const send = (async () => {
try {
const reply = (await sendMessage(req)) as OverlayInterruptResponse | undefined;
return reply?.ok === true ? { ok: true } : { ok: false };
} catch {
return { ok: false };
}
})();
const result = await Promise.race([send, timeout]);
if (timer !== null) clearTimeout(timer);
return result;
}
+23
View File
@@ -0,0 +1,23 @@
import type { SnapshotInfo } from "./connection-controller";
/**
* Wire protocol for `chrome.runtime.connect({ name: "popup" })`:
* - Background pushes `{ kind: "snapshot", data: SnapshotInfo }`.
* - Popup sends `{ kind: "set_label" }`, `{ kind: "set_connection_enabled" }`, etc.
*
* NOTE(review M4/M5 C2): the `set_port` variant is defined as a
* placeholder so the future custom-port UI does not have to re-design
* the bridge, but the popup does NOT render a control that emits it
* yet and the background does NOT route it. The wiring requires
* ConnectionController to dispose its current Transport and persist
* the port in chrome.storage; tracked as a follow-up.
*/
export const POPUP_PORT_NAME = "popup";
export type PopupOutbound =
| { kind: "set_label"; value: string }
| { kind: "set_port"; value: number }
| { kind: "set_connection_enabled"; value: boolean };
export type PopupInbound = { kind: "snapshot"; data: SnapshotInfo };
+68
View File
@@ -0,0 +1,68 @@
// Protocol-version parsing and comparison used by M10.4 version-skew detection.
//
// Wire format is `MAJOR.MINOR` (see `bsk-protocol::system` in Rust), not full
// app semver — we deliberately avoid a third-party semver package.
/**
* Compare two protocol-version strings on `(major, minor)`. Returns
* `null` when either side is unparseable — mirrors
* `bsk_protocol::system::compare_protocol`.
*/
export function compareProtocol(a: string, b: string): number | null {
const pa = parseProtocolParts(a);
const pb = parseProtocolParts(b);
if (!pa || !pb) return null;
if (pa.major !== pb.major) return pa.major - pb.major;
return pa.minor - pb.minor;
}
function parseProtocolParts(value: string): { major: number; minor: number } | null {
const major = parseProtocolMajor(value);
if (major === null) return null;
const rest = value.split(".")[1];
if (rest === undefined || rest === "") {
return { major, minor: 0 };
}
if (!/^\d+$/.test(rest)) return null;
const minor = Number(rest);
if (!Number.isSafeInteger(minor) || minor < 0) return null;
return { major, minor };
}
/**
* Extract the leading numeric major component from a protocol-version
* string. The wire format is `MAJOR.MINOR` rather than full semver
* (see `bsk-protocol::system::parse_major` in
* `crates/bsk-protocol/src/system.rs`). Returns `null` when the leading
* field is missing or non-numeric — callers treat that as a hard
* reject, mirroring the daemon's `evaluate_handshake_compat`.
*
* Behaviour matches the daemon's `parse_major` exactly (review round 3
* I2, tightened in round 4 I1, capped at `MAX_SAFE_INTEGER` on the
* daemon side in round 5 I1):
*
* * Take the substring before the first '.'.
* * The substring must be a non-empty run of ASCII digits (`/^\d+$/`).
* Anything else — signs, whitespace, scientific notation, decimal
* points, suffixes such as `1x` / `1e3` — fails. Note that
* `u64::from_str("+1")` would otherwise return `Ok(1)` (the
* std-lib parser peels off a leading `+`); the daemon's
* `parse_major` enforces the same ASCII-digit guard *before*
* delegating to `u64::from_str` so an input like `"+1.0"` is
* rejected by both sides.
* * Convert via `Number`. Reject values outside the JS safe-integer
* range conservatively — parsing a u64 that big and then comparing
* equality of "major-only" protocols here would lose precision.
* The daemon caps its own `parse_major` at the same value
* (`MAX_PROTOCOL_MAJOR = 2^53 - 1`) so the entire
* `MAX_SAFE_INTEGER < n <= u64::MAX` band is rejected on both
* peers — i.e. "daemon accepts ⇒ extension accepts" and
* "daemon rejects ⇒ extension rejects" both hold byte-for-byte.
*/
export function parseProtocolMajor(value: string): number | null {
const head = value.split(".")[0];
if (!/^\d+$/.test(head)) return null;
const n = Number(head);
if (!Number.isSafeInteger(n) || n < 0) return null;
return n;
}
@@ -0,0 +1,95 @@
/**
* Boolean flag that mirrors "is at least one session live right now?"
* into `chrome.storage.session`. Read by the all-urls content script
* as a cheap pre-flight before issuing the `who_am_i` runtime message
* (review M4/M5 I3): when no session is live we never wake the SW on
* routine page loads.
*
* `chrome.storage.session` defaults to TRUSTED_CONTEXTS only, so we
* explicitly opt the area into TRUSTED_AND_UNTRUSTED_CONTEXTS the
* first time we touch it. The stored value is a single boolean and
* carries no user data.
*/
import type { SessionManager } from "@/session-manager/manager";
export const SESSIONS_LIVE_FLAG_KEY = "bh.sessions_live";
/** Subset of `chrome.storage.session` we use; lets tests inject a fake. */
export interface SessionStorageApi {
setAccessLevel?(options: { accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" }): Promise<void>;
set(items: Record<string, unknown>): Promise<void>;
remove(keys: string | string[]): Promise<void>;
}
function defaultSessionStorage(): SessionStorageApi | null {
if (typeof chrome === "undefined") return null;
if (!chrome.storage?.session?.set || !chrome.storage?.session?.remove) return null;
return {
setAccessLevel: chrome.storage.session.setAccessLevel?.bind(chrome.storage.session),
set: (items) => chrome.storage.session.set(items),
remove: (keys) => chrome.storage.session.remove(keys),
};
}
export interface SessionsLiveFlagOptions {
manager: SessionManager;
storage?: SessionStorageApi;
/** Override the storage key (tests). */
flagKey?: string;
}
export interface SessionsLiveFlagHandle {
/** Push current state to storage; useful right after attaching. */
refresh(): Promise<void>;
/** Call when SessionManager changes (start/stop/stopAll). */
syncFromManager(): Promise<void>;
}
/**
* Bind a SessionManager to a `chrome.storage.session` boolean so the
* content-script overlay can short-circuit before sending a runtime
* message. SessionManager does not currently emit change events so the
* caller is expected to invoke `syncFromManager()` after every
* start/stop. (M6 will move this to an event-driven hook.)
*/
export function attachSessionsLiveFlag(options: SessionsLiveFlagOptions): SessionsLiveFlagHandle {
const storage = options.storage ?? defaultSessionStorage();
const key = options.flagKey ?? SESSIONS_LIVE_FLAG_KEY;
let lastWritten: boolean | null = null;
const ensureAccessLevel = async () => {
if (!storage?.setAccessLevel) return;
try {
await storage.setAccessLevel({ accessLevel: "TRUSTED_AND_UNTRUSTED_CONTEXTS" });
} catch (err) {
console.debug("[bh] storage.session.setAccessLevel failed (continuing)", err);
}
};
void ensureAccessLevel();
const write = async (live: boolean) => {
if (!storage) return;
if (lastWritten === live) return;
try {
if (live) {
await storage.set({ [key]: true });
} else {
await storage.remove(key);
}
lastWritten = live;
} catch (err) {
console.debug("[bh] sessions-live flag write failed", err);
}
};
const syncFromManager = async () => {
const live = options.manager.list().length > 0;
await write(live);
};
return {
refresh: syncFromManager,
syncFromManager,
};
}