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
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
31 changes: 26 additions & 5 deletions apps/extension/src/entrypoints/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import { ConnectionController } from "@/lib/connection-controller";
import { startHeartbeat } from "@/lib/heartbeat";
import {
getConnectionEnabled,
getDaemonPort,
setConnectionEnabled as persistConnectionEnabled,
STORAGE_KEYS,
setLabel,
} from "@/lib/instance-id";
import { startKeepalive } from "@/lib/keepalive";
Expand Down Expand Up @@ -41,6 +43,7 @@ import {
attachRecordStepListener,
type RecordRuntimeDeps,
} from "@/tools/record";
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 @@ -54,6 +57,27 @@ export default defineBackground(() => {
let overlayGeneration = 0;
const controlModes = new Map<string, OverlayMode>();

async function applyDaemonPort(port: number): Promise<void> {
const url = resolveDaemonWsUrl(port);
if (!transport.setUrl(url)) return;
await transport.disconnect();
if (!controller.isConnectionEnabled) return;
try {
await transport.connect();
} catch (err) {
console.debug("[browser-skill] reconnect after port change failed", err);
}
}

if (typeof chrome !== "undefined" && chrome.storage?.onChanged) {
chrome.storage.onChanged.addListener((changes, areaName) => {
if (areaName !== "local") return;
const change = changes[STORAGE_KEYS.DAEMON_PORT];
if (!change || typeof change.newValue !== "number") return;
void applyDaemonPort(change.newValue);
});
Comment thread
shnpd marked this conversation as resolved.
}

function setControlMode(sessionId: string, mode: OverlayMode): void {
if (controlModes.get(sessionId) === mode) return;
controlModes.set(sessionId, mode);
Expand Down Expand Up @@ -279,6 +303,8 @@ export default defineBackground(() => {

void (async () => {
const connectionEnabled = await getConnectionEnabled();
const port = await getDaemonPort();
transport.setUrl(resolveDaemonWsUrl(port));
await controller.attach(transport, detectBrowserMeta(), connectionEnabled, {
beforeDisconnect: async () => {
const report = await cleanupAfterDisconnect();
Expand Down Expand Up @@ -345,11 +371,6 @@ 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)
Expand Down
155 changes: 153 additions & 2 deletions apps/extension/src/entrypoints/popup/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,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 } from "@/transport/handshake";
import { App } from "./App";
import { useConnectionState } from "./use-connection-state";
Expand Down Expand Up @@ -66,9 +67,32 @@ describe("App", () => {
render(<App />);

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

it("shows the connection switch off and hides transport errors when disconnected", () => {
mockUseConnectionState.mockReturnValue({
snapshot: {
...baseSnapshot,
lastError: "[WSTransport] disconnect during connect",
},
statusState: "disconnected",
setLabel,
setConnectionEnabled,
});

render(<App />);

expect(screen.getByText("未连接")).toBeTruthy();
expect(screen.getByText("无法连接,请确认 daemon 已启动且端口一致。")).toBeTruthy();
expect(screen.queryByText("端口不匹配")).toBeNull();
expect(
screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked"),
).toBe("false");
expect(screen.queryByText("[WSTransport] disconnect during connect")).toBeNull();
});

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

Expand Down Expand Up @@ -136,13 +160,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 @@ -160,6 +198,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 @@ -354,14 +393,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 @@ -374,3 +419,109 @@ 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 on blur", async () => {
const store = stubChromeStorage();

render(<App />);

const input = await screen.findByRole("textbox", { name: "连接端口" });
fireEvent.change(input, { target: { value: "53200" } });
fireEvent.blur(input);

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

it("persists a valid port when Enter blurs the field", async () => {
const store = stubChromeStorage();

render(<App />);

const input = await screen.findByRole("textbox", { name: "连接端口" });
fireEvent.change(input, { target: { value: "53200" } });
fireEvent.keyDown(input, { key: "Enter" });

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: "连接端口" });
fireEvent.change(input, { target: { value: "abc" } });
fireEvent.blur(input);

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: "连接端口" });
fireEvent.change(input, { target: { value: "" } });
fireEvent.blur(input);

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。非必要请勿修改。");
expect(tooltip.getAttribute("role")).toBe("tooltip");
expect(tooltip.className).toContain("opacity-0");
});
});
Loading