Skip to content
Open
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
6 changes: 5 additions & 1 deletion apps/extension/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@browser-skill/extension",
"version": "0.3.2",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
Expand All @@ -27,14 +27,18 @@
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/chrome": "^0.0.287",
"@types/node": "^22.20.1",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/ws": "^8.18.2",
"@vitejs/plugin-react": "^6.0.0",
"@wxt-dev/module-react": "^1.1.3",
"fake-indexeddb": "^6.2.5",
"happy-dom": "^15.11.7",
"tailwindcss": "^4.3.0",
"typescript": "^5.8.0",
"vitest": "^4.1.6",
"ws": "^8.22.0",
"wxt": "^0.20.0"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ vi.mock("@/lib/heartbeat");
vi.mock("@/lib/instance-id");
vi.mock("@/lib/interaction-preferences");
vi.mock("@/lib/keepalive");
vi.mock("@/lib/local-pairing-bridge");
vi.mock("@/lib/recording/frame-coordinator");
vi.mock("@/lib/sessions-live-flag");
vi.mock("@/long-screenshot/background");
Expand Down Expand Up @@ -85,7 +86,11 @@ async function fixture() {
},
debugger: { onDetach: event() },
webNavigation: { onBeforeNavigate: event(), onCompleted: event(), onErrorOccurred: event() },
runtime: { onMessage: event(), onConnect: event() },
runtime: {
onMessage: event(),
onConnect: event(),
getURL: (path: string) => `chrome-extension://test/${path}`,
},
notifications: { onClicked: event(), onButtonClicked: event() },
});
vi.mocked(watchDaemonConnection).mockReturnValue({ ready: Promise.resolve(), dispose() {} });
Expand Down
21 changes: 21 additions & 0 deletions apps/extension/src/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ import { watchDaemonConnection } from "@/lib/daemon-connection-preference";
import { startHeartbeat } from "@/lib/heartbeat";
import {
getConnectionEnabled,
getOrCreateInstanceId,
setConnectionEnabled as persistConnectionEnabled,
setLabel,
} from "@/lib/instance-id";
import { interactionPolicy, interactionPreferences } from "@/lib/interaction-preferences";
import { startKeepalive } from "@/lib/keepalive";
import { attachLocalPairing } from "@/lib/local-pairing-bridge";
import {
OVERLAY_AGENT_STATE,
OVERLAY_AUTOMATION_BYPASS,
Expand Down Expand Up @@ -55,6 +57,7 @@ import {
import { cdpBlockedUrlReason, chromeTabsApi } from "@/tools/shared";
import { chromeTabMutationApi } from "@/tools/tabs";
import { detectBrowserMeta } from "@/transport/handshake";
import { createLocalAuthenticator } from "@/transport/local-auth";
import { watchRemoteAuthorization } from "@/transport/remote-authorization";
import { type RemoteEndpoint, remoteSocket } from "@/transport/remote-endpoint";
import type { Transport } from "@/transport/transport";
Expand All @@ -69,8 +72,25 @@ export default defineBackground(() => {
let remoteEndpoint: RemoteEndpoint | null = null;
let connectionPreferenceValid = false;
let requestedConnection: { key: string; remote: boolean } | null = null;
const authenticateLocal = createLocalAuthenticator({
instanceId: () => getOrCreateInstanceId(),
origin: chrome.runtime.getURL("").replace(/\/$/, ""),
status: (status) => controller.setLocalAuth(status),
});
const transport = new WSTransport({
url: __BSK_DAEMON_WS_URL__,
onAuthenticationError: () => {
const state = controller.snapshot().localAuth?.state;
if (!remoteEndpoint && (state === "verifying" || state === "verified"))
controller.setLocalAuth({ state: "server_unverified" });
},
authenticate: (socket, signal) => {
if (remoteEndpoint) {
controller.setLocalAuth(null);
return;
}
return authenticateLocal(socket, signal);
},
webSocketFactory: (url) => {
if (!connectionPreferenceValid) throw new Error("Connection settings are unavailable");
const socket = remoteSocket(url, remoteEndpoint);
Expand Down Expand Up @@ -160,6 +180,7 @@ export default defineBackground(() => {
const popupSnapshotRefreshers = new Set<() => void>();
const controlModes = new Map<string, OverlayMode>();

attachLocalPairing(controller, transport);
watchRemoteAuthorization();
const daemonPort = watchDaemonConnection(
(url, remote) => {
Expand Down
13 changes: 13 additions & 0 deletions apps/extension/src/lib/connection-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
PROTOCOL_VERSION,
performHandshake,
} from "../transport/handshake";
import type { LocalAuthStatus } from "../transport/local-auth";
import type { Transport } from "../transport/transport";
import type { ConnectionState, HandshakeResult } from "../transport/types";
import { getLabel, getOrCreateInstanceId } from "./instance-id";
Expand All @@ -19,6 +20,7 @@ export interface SnapshotInfo {
handshake: HandshakeResult | null;
lastError: string | null;
connectionEnabled: boolean;
localAuth?: LocalAuthStatus | null;
}

type Listener = (s: SnapshotInfo) => void;
Expand Down Expand Up @@ -48,6 +50,16 @@ export class ConnectionController {
this.auditEnabled = enabled;
}
private lastError: string | null = null;
private localAuth: LocalAuthStatus | null = null;

setLocalAuth(status: LocalAuthStatus | null): void {
this.localAuth = status;
this.fire();
}

async retryConnection(): Promise<void> {
if (this.ready && this.connectionEnabled) await this.teardown(false);
}
private connectionEnabled = true;
private listeners = new Set<Listener>();
private lifecycleHooks: ConnectionLifecycleHooks = {};
Expand Down Expand Up @@ -86,6 +98,7 @@ export class ConnectionController {
handshake: this.handshake,
lastError: this.lastError,
connectionEnabled: this.connectionEnabled,
localAuth: this.localAuth,
};
}

Expand Down
14 changes: 14 additions & 0 deletions apps/extension/src/lib/local-pairing-bridge.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { afterEach, expect, it, vi } from "vitest";
import { trustedPopup } from "./local-pairing-bridge";

afterEach(() => vi.unstubAllGlobals());
it("accepts only this extension's popup, never a content script or tab", () => {
vi.stubGlobal("chrome", {
runtime: { id: "test", getURL: (path: string) => `chrome-extension://test/${path}` },
});
const sender = { id: "test", url: "chrome-extension://test/popup.html" };
expect(trustedPopup(sender)).toBe(true);
expect(trustedPopup({ ...sender, tab: { id: 1 } as chrome.tabs.Tab })).toBe(false);
expect(trustedPopup({ ...sender, id: "other" })).toBe(false);
expect(trustedPopup({ ...sender, url: "https://example.test/popup.html" })).toBe(false);
});
82 changes: 82 additions & 0 deletions apps/extension/src/lib/local-pairing-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { LocalAuthError } from "@/transport/local-auth";
import {
initializeLocalMigration,
readLocalState,
setSkipLocalVerification,
} from "@/transport/local-credentials";
import { verifyLocalPairing } from "@/transport/pairing-verifier";
import { writeRemoteConnection } from "@/transport/remote-storage";
import type { WSTransport } from "@/transport/ws-transport";
import type { ConnectionController } from "./connection-controller";
import { getOrCreateInstanceId, setDaemonPort } from "./instance-id";

export const LOCAL_PAIRING_MESSAGE = "bsk-local-pairing";
export function trustedPopup(sender: chrome.runtime.MessageSender): boolean {
return (
sender.id === chrome.runtime.id &&
sender.url === chrome.runtime.getURL("popup.html") &&
!sender.tab
);
}
export function attachLocalPairing(controller: ConnectionController, transport: WSTransport): void {
const initialize = () =>
initializeLocalMigration().catch(() => controller.setLocalAuth({ state: "server_unverified" }));
void initialize();
chrome.runtime.onInstalled.addListener((details) => {
void initializeLocalMigration(details).catch(() =>
controller.setLocalAuth({ state: "server_unverified" }),
);
});
chrome.runtime.onMessage.addListener((message, sender, reply) => {
if (message?.kind !== LOCAL_PAIRING_MESSAGE) return false;
if (!trustedPopup(sender)) {
reply({ error: "Invalid pairing request" });
return false;
}
const action = async () => {
switch (message.action) {
case "info": {
const state = await readLocalState();
return {
policy: state.policy,
devices: state.credentials
.filter((c) => c.role === "active")
.map((c) => ({ daemonId: c.daemonId, deviceId: c.deviceId, endpoint: c.endpoint })),
};
}
case "pair": {
if (typeof message.link !== "string") throw new Error("Invalid pairing request");
const result = await verifyLocalPairing(
message.link,
await getOrCreateInstanceId(),
chrome.runtime.getURL("").replace(/\/$/, ""),
);
await setDaemonPort(Number(new URL(result.endpoint).port || "80"));
await writeRemoteConnection(null);
transport.resetAuthenticationBackoff();
await controller.retryConnection();
return { ok: true };
}
case "skip":
if (typeof message.value !== "boolean") throw new Error("Invalid pairing request");
await setSkipLocalVerification(message.value);
transport.resetAuthenticationBackoff();
await controller.retryConnection();
return { ok: true };
case "retry":
transport.resetAuthenticationBackoff();
await controller.retryConnection();
return { ok: true };
default:
throw new Error("Invalid pairing request");
}
};
void action().then(reply, (error) =>
reply({
error: error instanceof LocalAuthError ? error.status.state : "pairing_failed",
...(error instanceof LocalAuthError ? { browserId: error.status.browserId } : {}),
}),
);
return true;
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -175,3 +175,20 @@ it("only schedules renewal alarms while a remote connection is selected", async
await vi.waitFor(() => expect(chrome.alarms.clear).toHaveBeenCalledTimes(2));
watcher.dispose();
});

it("L20: a renewal failure cannot switch a newly paired local connection back to remote", async () => {
let reject!: (reason: Error) => void;
vi.mocked(fetch).mockImplementationOnce(
() =>
new Promise((_resolve, fail) => {
reject = fail;
}),
);
const renewing = renewRemoteAuthorization();
const failed = expect(renewing).rejects.toThrow("offline");
await vi.waitFor(() => expect(fetch).toHaveBeenCalled());
values[REMOTE_ENDPOINT_KEY] = null;
reject(new Error("offline"));
await failed;
expect(values[REMOTE_ENDPOINT_KEY]).toBeNull();
});
8 changes: 8 additions & 0 deletions apps/extension/src/transport/__tests__/remote-storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,3 +121,11 @@ it("migrates a legacy grant before restoring ordinary settings access", async ()
expect(values.bsk_remote_endpoint).toBeUndefined();
expect(chrome.storage.local.setAccessLevel).toHaveBeenCalledOnce();
});

it("L20: keeps a remote authorization when switching to local", async () => {
availableDatabase();
await storage.writeRemoteConnection(endpoint);
await storage.writeRemoteConnection(null);
expect(await storage.readRemoteConnection()).toBeNull();
expect(await storage.readSavedRemoteConnection()).toEqual(endpoint);
});
56 changes: 56 additions & 0 deletions apps/extension/src/transport/__tests__/ws-transport.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,3 +317,59 @@ describe("WSTransport", () => {
expect(t.state).toBe("connected");
});
});

it("authenticates before connected, rejects early sends, and closes a second final frame", async () => {
let complete!: () => void;
const gate = new Promise<void>((resolve) => {
complete = resolve;
});
const t = new WSTransport({
url: "ws://127.0.0.1:52800",
authenticate: () => gate,
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const handler = vi.fn();
t.onMessage(handler);
const pending = t.connect();
lastSocket().open();
expect(t.state).toBe("connecting");
expect(() => t.send({ id: "early", method: "system.handshake" })).toThrow();
lastSocket().receive({ id: "early", result: {} });
expect(handler).not.toHaveBeenCalled();
complete();
await pending;
expect(t.state).toBe("connected");
lastSocket().receive({
type: "local.auth.ready",
v: 1,
rotation: null,
commit: null,
} as unknown as ProtocolFrame);
expect(handler).not.toHaveBeenCalled();
expect(t.state).toBe("disconnected");
await t.disconnect();
});
it("auth failures back off for 30 seconds and explicit retry resets the delay", async () => {
vi.useFakeTimers();
const authenticate = vi.fn().mockRejectedValue(new Error("server_unverified"));
const t = new WSTransport({
url: "ws://127.0.0.1:52800",
authenticate,
webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket,
});
const pending = t.connect();
lastSocket().open();
await expect(pending).rejects.toThrow("server_unverified");
const count = FakeSocket.instances.length;
await vi.advanceTimersByTimeAsync(29_999);
expect(FakeSocket.instances).toHaveLength(count);
await expect(t.connect()).rejects.toThrow("backoff");
t.resetAuthenticationBackoff();
authenticate.mockResolvedValue(undefined);
const retried = t.connect();
lastSocket().open();
await retried;
expect(t.state).toBe("connected");
await t.disconnect();
vi.useRealTimers();
});
24 changes: 24 additions & 0 deletions apps/extension/src/transport/credential-db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/** Extension-origin IndexedDB is shared by local and remote credentials. */
export function openCredentialDatabase(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open("bsk-remote-authorization", 2);
request.onupgradeneeded = () => {
for (const name of ["connection", "local", "migration"]) {
if (!request.result.objectStoreNames.contains(name)) request.result.createObjectStore(name);
}
};
request.onerror = () => reject(new Error("Credential storage is unavailable"));
let blocked = false;
request.onblocked = () => {
blocked = true;
reject(new Error("Credential storage is blocked"));
};
request.onsuccess = () => {
if (blocked) request.result.close();
else {
request.result.onversionchange = () => request.result.close();
resolve(request.result);
}
};
});
}
Loading