Skip to content
Merged
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
4 changes: 2 additions & 2 deletions apps/extension/PRIVACY.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ The Extension requests the following Chrome permissions. Each is used solely for

## 6. Where Data Goes

All Extension activity stays on the user's local device. The only network traffic the Extension generates is a WebSocket connection to `ws://127.0.0.1:52800` (loopback only). What the AI agent connected to that local daemon does with the data afterwards (for example, sending a screenshot to an LLM provider) is governed by the privacy policy of that agent or LLM provider, **not** by this policy. BrowserSkill is not a party to those communications.
All Extension activity stays on the user's local device. The only network traffic the Extension generates is a WebSocket connection to the local bsk daemon on `127.0.0.1` (loopback only; default port **52800**, configurable in the extension popup). What the AI agent connected to that local daemon does with the data afterwards (for example, sending a screenshot to an LLM provider) is governed by the privacy policy of that agent or LLM provider, **not** by this policy. BrowserSkill is not a party to those communications.

## 7. Data Retention

Expand All @@ -80,7 +80,7 @@ The Extension is a developer tool and is not directed at children under 13. It d

## 10. Security

Because the Extension communicates only with `127.0.0.1`, no data is exposed to the network. Users should still avoid running BrowserSkill in untrusted environments, since any local process able to bind to `127.0.0.1:52800` could send commands to the Extension. Run BrowserSkill only on machines you control.
Because the Extension communicates only with `127.0.0.1`, no data is exposed to the network. Users should still avoid running BrowserSkill in untrusted environments, since any local process able to bind to the configured loopback port could send commands to the Extension. Run BrowserSkill only on machines you control.

## 11. Open Source and Auditability

Expand Down
4 changes: 2 additions & 2 deletions apps/extension/PRIVACY.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ BrowserSkill **不会**:

## 6. 数据流向

本扩展的所有活动都停留在用户本地设备上。本扩展产生的唯一网络流量是与 `ws://127.0.0.1:52800`(仅回环地址)的 WebSocket 连接。连接到该本地守护进程的 AI 助手在拿到数据之后如何处理(例如将截图发送给某个 LLM 服务),由该助手或 LLM 提供商自身的隐私政策约束,**不在本政策范围内**。BrowserSkill 不参与那些通信。
本扩展的所有活动都停留在用户本地设备上。本扩展产生的唯一网络流量是与本地 bsk 守护进程在 `127.0.0.1`(仅回环地址;默认端口 **52800**,可在扩展弹窗中配置)上的 WebSocket 连接。连接到该本地守护进程的 AI 助手在拿到数据之后如何处理(例如将截图发送给某个 LLM 服务),由该助手或 LLM 提供商自身的隐私政策约束,**不在本政策范围内**。BrowserSkill 不参与那些通信。

## 7. 数据保留

Expand All @@ -76,7 +76,7 @@ BrowserSkill **不会**:

## 10. 安全性

由于本扩展仅与 `127.0.0.1` 通信,因此不会向网络暴露任何数据。但用户仍应避免在不可信的环境中运行 BrowserSkill —— 任何能够绑定到 `127.0.0.1:52800` 的本地进程理论上都可以向本扩展发送指令。请仅在您本人控制的机器上运行 BrowserSkill。
由于本扩展仅与 `127.0.0.1` 通信,因此不会向网络暴露任何数据。但用户仍应避免在不可信的环境中运行 BrowserSkill —— 任何能够绑定到所配置回环端口的本地进程理论上都可以向本扩展发送指令。请仅在您本人控制的机器上运行 BrowserSkill。

## 11. 开源与可审计性

Expand Down
59 changes: 30 additions & 29 deletions apps/extension/src/entrypoints/background.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { i18n } from "@browser-skill/i18n";
import { ChromiumCdp } from "@/browser-driver/chromium-cdp";
import { ConnectionController } from "@/lib/connection-controller";
import { watchDaemonPort } from "@/lib/daemon-port-preference";
import { startHeartbeat } from "@/lib/heartbeat";
import {
getConnectionEnabled,
Expand Down Expand Up @@ -43,6 +44,7 @@ import {
} from "@/tools/record";
import { chromeTabsApi } from "@/tools/shared";
import { chromeTabMutationApi } from "@/tools/tabs";
import { resolveDaemonWsUrl } from "@/transport/daemon-endpoint";
import { detectBrowserMeta } from "@/transport/handshake";
import type { Transport } from "@/transport/transport";
import { WSTransport } from "@/transport/ws-transport";
Expand All @@ -61,6 +63,14 @@ export default defineBackground(() => {
let overlayGeneration = 0;
const controlModes = new Map<string, OverlayMode>();

const daemonPort = watchDaemonPort((port) => {
const url = resolveDaemonWsUrl(port);
void controller.reconfigureTransport(url, () => {
transport.setUrl(url);
});
});
let preferenceWrites = Promise.resolve();

function setControlMode(sessionId: string, mode: OverlayMode): void {
if (controlModes.get(sessionId) === mode) return;
controlModes.set(sessionId, mode);
Expand Down Expand Up @@ -285,7 +295,7 @@ export default defineBackground(() => {
// the BrowserSkill connection.
startKeepalive({
transport,
shouldConnect: () => controller.isConnectionEnabled,
requestConnect: () => controller.requestConnect(),
});

// Application-level heartbeat (Chrome 116+): while the post-handshake
Expand All @@ -307,13 +317,7 @@ export default defineBackground(() => {
// the worker and let us reconnect immediately instead of waiting for
// the next 30s alarm tick. They only help when the daemon is actually
// running; a cold daemon is (re)spawned by the next `bsk` command.
const reconnectIfNeeded = () => {
if (!controller.isConnectionEnabled) return;
if (transport.state === "connected") return;
void transport.connect().catch((err) => {
console.debug("[browser-skill] wake reconnect attempt failed", err);
});
};
const reconnectIfNeeded = () => controller.requestConnect();
if (typeof chrome.runtime?.onStartup?.addListener === "function") {
chrome.runtime.onStartup.addListener(reconnectIfNeeded);
}
Expand All @@ -328,20 +332,18 @@ export default defineBackground(() => {
}

void (async () => {
const connectionEnabled = await getConnectionEnabled();
const [connectionEnabled] = await Promise.all([getConnectionEnabled(), daemonPort.ready]);
const cleanup = async () => {
const report = await cleanupAfterDisconnect();
if (report.failures.length > 0) {
throw new Error(
`Session cleanup incomplete: ${report.failures.map((failure) => failure.message).join("; ")}`,
);
}
};
await controller.attach(transport, detectBrowserMeta(), connectionEnabled, {
beforeDisconnect: async () => {
const report = await cleanupAfterDisconnect();
if (report.failures.length > 0) {
console.warn("[browser-skill] session cleanup before disconnect was incomplete", report);
}
},
onDisconnected: async () => {
const report = await cleanupAfterDisconnect();
if (report.failures.length > 0) {
console.warn("[browser-skill] session cleanup after disconnect was incomplete", report);
}
},
beforeDisconnect: cleanup,
onDisconnected: cleanup,
});
})().catch((err) => {
console.error("[browser-skill] controller failed to attach", err);
Expand Down Expand Up @@ -399,15 +401,14 @@ export default defineBackground(() => {
if (msg && typeof msg === "object" && "kind" in msg) {
if (msg.kind === "set_label") {
void setLabel(msg.value).then(() => controller.refreshLabel());
} else if (msg.kind === "set_port") {
// Placeholder for the future custom-port UI; warn loudly so
// any reintroduced popup control is caught instead of
// silently doing nothing (review M4/M5 C2).
console.warn("[browser-skill] set_port is not wired yet; ignoring", msg.value);
} else if (msg.kind === "set_connection_enabled") {
void controller
.setConnectionEnabled(msg.value)
.then(() => persistConnectionEnabled(msg.value));
void controller.setConnectionEnabled(msg.value);
// Persist user intent in message order, independently of slow cleanup.
preferenceWrites = preferenceWrites
.then(() => persistConnectionEnabled(msg.value))
.catch((err) => {
console.error("[browser-skill] connection preference write failed", err);
});
}
}
});
Expand Down
163 changes: 161 additions & 2 deletions apps/extension/src/entrypoints/popup/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SnapshotInfo } from "@/lib/connection-controller";
import { STORAGE_KEYS } from "@/lib/instance-id";
import { DEFAULT_DAEMON_PORT } from "@/transport/daemon-endpoint";
import { EXTENSION_VERSION, PROTOCOL_VERSION } from "@/transport/handshake";
import { App } from "./App";
import { useConnectionState } from "./use-connection-state";
Expand Down Expand Up @@ -68,9 +69,34 @@ describe("App", () => {
render(<App />);

expect(screen.getByText("未连接")).toBeTruthy();
expect(screen.getByText("无法连接,请确认 daemon 已启动且端口一致。")).toBeTruthy();
expect(screen.queryByText("请先打开 BrowserSkill。")).toBeNull();
});

it("keeps the connection switch usable and shows protocol errors when disconnected", () => {
mockUseConnectionState.mockReturnValue({
snapshot: {
...baseSnapshot,
lastError: "version_too_old: protocol-major mismatch",
},
statusState: "disconnected",
setLabel,
setConnectionEnabled,
});

render(<App />);

expect(screen.getByText("未连接")).toBeTruthy();
expect(screen.queryByText("无法连接,请确认 daemon 已启动且端口一致。")).toBeNull();
expect(screen.queryByText("端口不匹配")).toBeNull();
expect(
screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked"),
).toBe("true");
expect(screen.getByText("version_too_old: protocol-major mismatch")).toBeTruthy();
fireEvent.click(screen.getByRole("switch", { name: "BrowserSkill 连接" }));
expect(setConnectionEnabled).toHaveBeenCalledWith(false);
});

it("does not render record UI on the main view", () => {
render(<App />);

Expand Down Expand Up @@ -138,13 +164,27 @@ describe("App", () => {
});

it("renders the connection toggle with switch semantics", () => {
mockUseConnectionState.mockReturnValue({
snapshot: { ...baseSnapshot, state: "connected" },
statusState: "connected",
setLabel,
setConnectionEnabled,
});

render(<App />);

const toggle = screen.getByRole("switch", { name: "BrowserSkill 连接" });
expect(toggle.getAttribute("aria-checked")).toBe("true");
});

it("calls setConnectionEnabled(false) when the toggle is turned off", () => {
mockUseConnectionState.mockReturnValue({
snapshot: { ...baseSnapshot, state: "connected" },
statusState: "connected",
setLabel,
setConnectionEnabled,
});

render(<App />);

fireEvent.click(screen.getByRole("switch", { name: "BrowserSkill 连接" }));
Expand All @@ -162,6 +202,7 @@ describe("App", () => {
render(<App />);

expect(screen.getByText("连接已关闭")).toBeTruthy();
expect(screen.queryByText("无法连接,请确认 daemon 已启动且端口一致。")).toBeNull();
expect(
screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked"),
).toBe("false");
Expand Down Expand Up @@ -402,14 +443,20 @@ describe("control hints toggle", () => {

const info = await screen.findByRole("button", { name: "控制提示说明" });
expect(info).toBeTruthy();
const tooltip = screen.getByRole("tooltip");
expect(tooltip.textContent).toBe("Agent 控制页面时显示提示条和橙色闪光。");
const tooltip = screen.getByText("Agent 控制页面时显示提示条和橙色闪光。");
expect(tooltip.getAttribute("role")).toBe("tooltip");
// Hidden until the info button is hovered or focused.
expect(tooltip.className).toContain("opacity-0");
});

it("uses the same switch component and size for both settings rows", async () => {
stubChromeStorage();
mockUseConnectionState.mockReturnValue({
snapshot: { ...baseSnapshot, state: "connected" },
statusState: "connected",
setLabel: vi.fn(),
setConnectionEnabled: vi.fn(),
});

render(<App />);

Expand All @@ -422,3 +469,115 @@ describe("control hints toggle", () => {
expect(hintsToggle.className).toBe(connectionToggle.className);
});
});

describe("daemon port input", () => {
function stubChromeStorage(initial: Record<string, unknown> = {}) {
const store = { ...initial };
vi.stubGlobal("chrome", {
runtime: { lastError: undefined },
storage: {
local: {
get: (keys: string | string[], cb: (items: Record<string, unknown>) => void) => {
const items: Record<string, unknown> = {};
for (const k of Array.isArray(keys) ? keys : [keys]) {
if (k in store) items[k] = store[k];
}
cb(items);
},
set: (items: Record<string, unknown>, cb?: () => void) => {
Object.assign(store, items);
cb?.();
},
},
onChanged: {
addListener: vi.fn(),
removeListener: vi.fn(),
},
},
});
return store;
}

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

it("prefills the port from storage", async () => {
stubChromeStorage({ [STORAGE_KEYS.DAEMON_PORT]: 53200 });

render(<App />);

const input = await screen.findByRole("textbox", { name: "连接端口" });
await waitFor(() => expect((input as HTMLInputElement).value).toBe("53200"));
});

it("persists a valid port with the save button", async () => {
const store = stubChromeStorage();

render(<App />);

const input = await screen.findByRole("textbox", { name: "连接端口" });
await waitFor(() => expect((input as HTMLInputElement).disabled).toBe(false));
fireEvent.change(input, { target: { value: "53200" } });
fireEvent.click(screen.getByRole("button", { name: "保存端口" }));

await waitFor(() => expect(store[STORAGE_KEYS.DAEMON_PORT]).toBe(53200));
expect((input as HTMLInputElement).value).toBe("53200");
});

it("persists a valid port on Enter", async () => {
const store = stubChromeStorage();

render(<App />);

const input = await screen.findByRole("textbox", { name: "连接端口" });
await waitFor(() => expect((input as HTMLInputElement).disabled).toBe(false));
fireEvent.change(input, { target: { value: "53200" } });
fireEvent.keyDown(input, { key: "Enter" });

await waitFor(() => expect(store[STORAGE_KEYS.DAEMON_PORT]).toBe(53200));
});

it("shows an error and does not write invalid ports", async () => {
const store = stubChromeStorage();

render(<App />);

const input = await screen.findByRole("textbox", { name: "连接端口" });
await waitFor(() => expect((input as HTMLInputElement).disabled).toBe(false));
fireEvent.change(input, { target: { value: "abc" } });
fireEvent.click(screen.getByRole("button", { name: "保存端口" }));

expect(screen.getByText("请输入 1 到 65535 之间的端口号。")).toBeTruthy();
expect(store[STORAGE_KEYS.DAEMON_PORT]).toBeUndefined();
});

it("stores the default port when the field is cleared", async () => {
const store = stubChromeStorage({ [STORAGE_KEYS.DAEMON_PORT]: 53200 });

render(<App />);

const input = await screen.findByRole("textbox", { name: "连接端口" });
await waitFor(() => expect((input as HTMLInputElement).disabled).toBe(false));
fireEvent.change(input, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: "保存端口" }));

await waitFor(() => expect(store[STORAGE_KEYS.DAEMON_PORT]).toBe(DEFAULT_DAEMON_PORT));
expect((input as HTMLInputElement).value).toBe(String(DEFAULT_DAEMON_PORT));
});

it("keeps the port hint copy in an accessible info tooltip", async () => {
stubChromeStorage();

render(<App />);

const info = await screen.findByRole("button", { name: "连接端口说明" });
expect(info).toBeTruthy();
const tooltip = screen.getByText(
"通过此端口连接本机 daemon,请先启动 daemon 并让其监听此端口。此设置仅更改扩展的连接地址,不会修改本地 CLI 配置。保存修改会结束当前会话,并在连接开关开启时重新连接。",
);
expect(tooltip.getAttribute("role")).toBe("tooltip");
expect(tooltip.className).toContain("opacity-0");
});
});
Loading