From a9c7444139e39ca3344da1e9fdf15723f8eb06d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chaonan=E2=80=9D?= Date: Wed, 2 Sep 2026 18:56:08 +0800 Subject: [PATCH 1/4] feat(extension): make the local daemon port configurable in the popup The extension was hardwired to 52800; persist a custom loopback port and reconnect when it changes so users can match a non-default daemon. Co-authored-by: Cursor --- apps/extension/PRIVACY.md | 4 +- apps/extension/PRIVACY.zh-CN.md | 4 +- apps/extension/src/entrypoints/background.ts | 31 +++- .../src/entrypoints/popup/App.test.tsx | 155 +++++++++++++++++- apps/extension/src/entrypoints/popup/App.tsx | 79 ++++++++- .../entrypoints/popup/use-connection-state.ts | 9 +- .../src/entrypoints/popup/use-daemon-port.ts | 71 ++++++++ .../src/lib/__tests__/instance-id.test.ts | 20 +++ apps/extension/src/lib/instance-id.ts | 17 ++ apps/extension/src/lib/popup-bridge.ts | 9 +- .../__tests__/daemon-endpoint.test.ts | 43 +++++ .../transport/__tests__/ws-transport.test.ts | 15 ++ .../src/transport/daemon-endpoint.ts | 39 +++++ apps/extension/src/transport/ws-transport.ts | 9 +- docs/architecture.md | 2 +- .../i18n/src/locales/en-US/extension.json | 5 + .../i18n/src/locales/zh-CN/extension.json | 5 + 17 files changed, 488 insertions(+), 29 deletions(-) create mode 100644 apps/extension/src/entrypoints/popup/use-daemon-port.ts create mode 100644 apps/extension/src/transport/__tests__/daemon-endpoint.test.ts create mode 100644 apps/extension/src/transport/daemon-endpoint.ts diff --git a/apps/extension/PRIVACY.md b/apps/extension/PRIVACY.md index bfe46754..f3b3c94d 100644 --- a/apps/extension/PRIVACY.md +++ b/apps/extension/PRIVACY.md @@ -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 @@ -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 diff --git a/apps/extension/PRIVACY.zh-CN.md b/apps/extension/PRIVACY.zh-CN.md index 1cfa0082..e2591c07 100644 --- a/apps/extension/PRIVACY.zh-CN.md +++ b/apps/extension/PRIVACY.zh-CN.md @@ -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. 数据保留 @@ -76,7 +76,7 @@ BrowserSkill **不会**: ## 10. 安全性 -由于本扩展仅与 `127.0.0.1` 通信,因此不会向网络暴露任何数据。但用户仍应避免在不可信的环境中运行 BrowserSkill —— 任何能够绑定到 `127.0.0.1:52800` 的本地进程理论上都可以向本扩展发送指令。请仅在您本人控制的机器上运行 BrowserSkill。 +由于本扩展仅与 `127.0.0.1` 通信,因此不会向网络暴露任何数据。但用户仍应避免在不可信的环境中运行 BrowserSkill —— 任何能够绑定到所配置回环端口的本地进程理论上都可以向本扩展发送指令。请仅在您本人控制的机器上运行 BrowserSkill。 ## 11. 开源与可审计性 diff --git a/apps/extension/src/entrypoints/background.ts b/apps/extension/src/entrypoints/background.ts index 4edd0a24..36a4e06d 100644 --- a/apps/extension/src/entrypoints/background.ts +++ b/apps/extension/src/entrypoints/background.ts @@ -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"; @@ -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"; @@ -54,6 +57,27 @@ export default defineBackground(() => { let overlayGeneration = 0; const controlModes = new Map(); + async function applyDaemonPort(port: number): Promise { + 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); + }); + } + function setControlMode(sessionId: string, mode: OverlayMode): void { if (controlModes.get(sessionId) === mode) return; controlModes.set(sessionId, mode); @@ -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(); @@ -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) diff --git a/apps/extension/src/entrypoints/popup/App.test.tsx b/apps/extension/src/entrypoints/popup/App.test.tsx index daec6379..6fa9145c 100644 --- a/apps/extension/src/entrypoints/popup/App.test.tsx +++ b/apps/extension/src/entrypoints/popup/App.test.tsx @@ -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"; @@ -66,9 +67,32 @@ describe("App", () => { render(); 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(); + + 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(); @@ -136,6 +160,13 @@ describe("App", () => { }); it("renders the connection toggle with switch semantics", () => { + mockUseConnectionState.mockReturnValue({ + snapshot: { ...baseSnapshot, state: "connected" }, + statusState: "connected", + setLabel, + setConnectionEnabled, + }); + render(); const toggle = screen.getByRole("switch", { name: "BrowserSkill 连接" }); @@ -143,6 +174,13 @@ describe("App", () => { }); it("calls setConnectionEnabled(false) when the toggle is turned off", () => { + mockUseConnectionState.mockReturnValue({ + snapshot: { ...baseSnapshot, state: "connected" }, + statusState: "connected", + setLabel, + setConnectionEnabled, + }); + render(); fireEvent.click(screen.getByRole("switch", { name: "BrowserSkill 连接" })); @@ -160,6 +198,7 @@ describe("App", () => { render(); expect(screen.getByText("连接已关闭")).toBeTruthy(); + expect(screen.queryByText("无法连接,请确认 daemon 已启动且端口一致。")).toBeNull(); expect( screen.getByRole("switch", { name: "BrowserSkill 连接" }).getAttribute("aria-checked"), ).toBe("false"); @@ -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(); @@ -374,3 +419,109 @@ describe("control hints toggle", () => { expect(hintsToggle.className).toBe(connectionToggle.className); }); }); + +describe("daemon port input", () => { + function stubChromeStorage(initial: Record = {}) { + const store = { ...initial }; + vi.stubGlobal("chrome", { + runtime: { lastError: undefined }, + storage: { + local: { + get: (keys: string | string[], cb: (items: Record) => void) => { + const items: Record = {}; + for (const k of Array.isArray(keys) ? keys : [keys]) { + if (k in store) items[k] = store[k]; + } + cb(items); + }, + set: (items: Record, 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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"); + }); +}); diff --git a/apps/extension/src/entrypoints/popup/App.tsx b/apps/extension/src/entrypoints/popup/App.tsx index 0756e9cb..f773cce4 100644 --- a/apps/extension/src/entrypoints/popup/App.tsx +++ b/apps/extension/src/entrypoints/popup/App.tsx @@ -15,6 +15,7 @@ import { POPUP_FEATURES, type PopupView } from "./features"; import { Switch } from "./switch"; import { type PopupStatusState, useConnectionState } from "./use-connection-state"; import { useControlHintsHidden } from "./use-control-hints-hidden"; +import { useDaemonPort } from "./use-daemon-port"; const STATE_LABEL_KEYS = { disconnected: "popup.stateLabel.disconnected", @@ -41,6 +42,12 @@ export function App() { const { t } = useTranslation("extension"); const { snapshot, statusState, setConnectionEnabled } = useConnectionState(); const [controlHintsHidden, setControlHintsHidden] = useControlHintsHidden(); + const { + draft: daemonPortDraft, + setDraft: setDaemonPortDraft, + commit: commitDaemonPort, + invalid: daemonPortInvalid, + } = useDaemonPort(); const [view, setView] = useState("main"); const [copiedInstanceId, setCopiedInstanceId] = useState(false); const [purposeDraft, setPurposeDraft] = useState(""); @@ -79,6 +86,7 @@ export function App() { }, [copiedTick]); const isSkewed = statusState === "version_skew"; + const isDisconnected = statusState === "disconnected"; const connectionLive = statusState === "connected" || isSkewed; const daemonVersion = snapshot.handshake?.version ?? "—"; const daemonProtocol = snapshot.handshake?.protocol_version ?? "—"; @@ -198,13 +206,21 @@ export function App() { {t(STATE_BADGE_KEYS[statusState])} + {isDisconnected && ( +

+ {t("popup.daemonUnreachable")} +

+ )} {isSkewed && (

- {snapshot.lastError && ( +

+
+ + + + + + {t("popup.daemonPortHint")} + + + + ) => + setDaemonPortDraft(event.target.value) + } + onBlur={commitDaemonPort} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + commitDaemonPort(); + } + }} + className="mt-0 h-5 w-24 shrink-0 rounded-md px-2 py-0 text-center text-sm leading-none shadow-none" + aria-invalid={daemonPortInvalid || undefined} + data-slot="popup-daemon-port-input" + /> +
+ {daemonPortInvalid && ( +

+ {t("popup.daemonPortInvalid")} +

+ )} +
+ + {snapshot.lastError && !isDisconnected && (
void; + commit: () => void; + invalid: boolean; +} { + const [draft, setDraftState] = useState(""); + const [invalid, setInvalid] = useState(false); + const draftRef = useRef(draft); + draftRef.current = draft; + + useEffect(() => { + if (typeof chrome === "undefined" || !chrome.storage?.local) return undefined; + let cancelled = false; + getDaemonPort() + .then((port) => { + if (!cancelled) setDraftState(String(port)); + }) + .catch((err) => { + console.debug("[browser-skill] daemon port read failed", err); + }); + const onChanged = (changes: Record, areaName: string) => { + if (areaName !== "local") return; + const change = changes[STORAGE_KEYS.DAEMON_PORT]; + if (change && typeof change.newValue === "number") { + setDraftState(String(change.newValue)); + setInvalid(false); + } + }; + chrome.storage.onChanged.addListener(onChanged); + return () => { + cancelled = true; + chrome.storage.onChanged.removeListener(onChanged); + }; + }, []); + + const commit = useCallback(() => { + const parsed = parseDaemonPortInput(draftRef.current); + if (parsed === null) { + setInvalid(true); + return; + } + setInvalid(false); + setDraftState(String(parsed)); + if (typeof chrome === "undefined" || !chrome.storage?.local) return; + setDaemonPort(parsed).catch((err) => { + console.debug("[browser-skill] daemon port write failed", err); + }); + }, []); + + useEffect(() => { + const onPageHide = () => commit(); + window.addEventListener("pagehide", onPageHide); + return () => window.removeEventListener("pagehide", onPageHide); + }, [commit]); + + const setDraft = useCallback((value: string) => { + setDraftState(value); + setInvalid(false); + }, []); + + return { draft, setDraft, commit, invalid }; +} diff --git a/apps/extension/src/lib/__tests__/instance-id.test.ts b/apps/extension/src/lib/__tests__/instance-id.test.ts index 39544865..4629eee9 100644 --- a/apps/extension/src/lib/__tests__/instance-id.test.ts +++ b/apps/extension/src/lib/__tests__/instance-id.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it, vi } from "vitest"; +import { DEFAULT_DAEMON_PORT } from "@/transport/daemon-endpoint"; import { getConnectionEnabled, getControlHintsHidden, + getDaemonPort, getLabel, getOrCreateInstanceId, STORAGE_KEYS, setConnectionEnabled, setControlHintsHidden, + setDaemonPort, setLabel, } from "../instance-id"; @@ -117,4 +120,21 @@ describe("instance-id", () => { expect(store[STORAGE_KEYS.CONTROL_HINTS_HIDDEN]).toBe(true); expect(await getControlHintsHidden(backend)).toBe(true); }); + + it("getDaemonPort returns default when storage is empty", async () => { + const { backend } = fakeStorage(); + expect(await getDaemonPort(backend)).toBe(DEFAULT_DAEMON_PORT); + }); + + it("getDaemonPort normalizes invalid stored values to default", async () => { + const { backend } = fakeStorage({ [STORAGE_KEYS.DAEMON_PORT]: 0 }); + expect(await getDaemonPort(backend)).toBe(DEFAULT_DAEMON_PORT); + }); + + it("setDaemonPort persists the value retrievable by getDaemonPort", async () => { + const { backend, store } = fakeStorage(); + await setDaemonPort(53200, backend); + expect(store[STORAGE_KEYS.DAEMON_PORT]).toBe(53200); + expect(await getDaemonPort(backend)).toBe(53200); + }); }); diff --git a/apps/extension/src/lib/instance-id.ts b/apps/extension/src/lib/instance-id.ts index 087d15f0..8e8d4574 100644 --- a/apps/extension/src/lib/instance-id.ts +++ b/apps/extension/src/lib/instance-id.ts @@ -1,7 +1,10 @@ +import { normalizeDaemonPort } from "@/transport/daemon-endpoint"; + const STORAGE_KEY = "bsk_instance_id"; const LABEL_STORAGE_KEY = "bh_label"; const CONNECTION_ENABLED_KEY = "bh_connection_enabled"; const CONTROL_HINTS_HIDDEN_KEY = "bsk_control_hints_hidden"; +const DAEMON_PORT_KEY = "bsk_daemon_port"; export interface StorageBackend { get(keys: string | string[]): Promise>; @@ -127,9 +130,23 @@ export async function setControlHintsHidden( await storage.set({ [CONTROL_HINTS_HIDDEN_KEY]: hidden }); } +/** Defaults to {@link DEFAULT_DAEMON_PORT} when unset or invalid. */ +export async function getDaemonPort(storage: StorageBackend = defaultStorage()): Promise { + const items = await storage.get(DAEMON_PORT_KEY); + return normalizeDaemonPort(items[DAEMON_PORT_KEY]); +} + +export async function setDaemonPort( + port: number, + storage: StorageBackend = defaultStorage(), +): Promise { + await storage.set({ [DAEMON_PORT_KEY]: port }); +} + export const STORAGE_KEYS = { INSTANCE_ID: STORAGE_KEY, LABEL: LABEL_STORAGE_KEY, CONNECTION_ENABLED: CONNECTION_ENABLED_KEY, CONTROL_HINTS_HIDDEN: CONTROL_HINTS_HIDDEN_KEY, + DAEMON_PORT: DAEMON_PORT_KEY, } as const; diff --git a/apps/extension/src/lib/popup-bridge.ts b/apps/extension/src/lib/popup-bridge.ts index fdb8a01a..09df08c3 100644 --- a/apps/extension/src/lib/popup-bridge.ts +++ b/apps/extension/src/lib/popup-bridge.ts @@ -5,19 +5,14 @@ import type { SnapshotInfo } from "./connection-controller"; * - 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. + * Daemon port preference is persisted via `chrome.storage.local` instead + * of this bridge (see `use-daemon-port.ts`). */ 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 }; diff --git a/apps/extension/src/transport/__tests__/daemon-endpoint.test.ts b/apps/extension/src/transport/__tests__/daemon-endpoint.test.ts new file mode 100644 index 00000000..02590467 --- /dev/null +++ b/apps/extension/src/transport/__tests__/daemon-endpoint.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_DAEMON_PORT, + normalizeDaemonPort, + parseDaemonPortInput, + resolveDaemonWsUrl, +} from "../daemon-endpoint"; + +describe("daemon-endpoint", () => { + it("resolveDaemonWsUrl(DEFAULT) matches the build-time constant", () => { + expect(resolveDaemonWsUrl(DEFAULT_DAEMON_PORT)).toBe(__BSK_DAEMON_WS_URL__); + }); + + it("resolveDaemonWsUrl replaces only the port", () => { + expect(resolveDaemonWsUrl(53200)).toBe("ws://127.0.0.1:53200"); + }); + + it("normalizeDaemonPort falls back to default for invalid values", () => { + expect(normalizeDaemonPort(undefined)).toBe(DEFAULT_DAEMON_PORT); + expect(normalizeDaemonPort(0)).toBe(DEFAULT_DAEMON_PORT); + expect(normalizeDaemonPort(65536)).toBe(DEFAULT_DAEMON_PORT); + expect(normalizeDaemonPort("abc")).toBe(DEFAULT_DAEMON_PORT); + expect(normalizeDaemonPort(1.5)).toBe(DEFAULT_DAEMON_PORT); + }); + + it("normalizeDaemonPort accepts valid numbers and numeric strings", () => { + expect(normalizeDaemonPort(53200)).toBe(53200); + expect(normalizeDaemonPort("53200")).toBe(53200); + }); + + it("parseDaemonPortInput treats empty as default and rejects invalid", () => { + expect(parseDaemonPortInput("")).toBe(DEFAULT_DAEMON_PORT); + expect(parseDaemonPortInput(" ")).toBe(DEFAULT_DAEMON_PORT); + expect(parseDaemonPortInput("abc")).toBeNull(); + expect(parseDaemonPortInput("0")).toBeNull(); + expect(parseDaemonPortInput("65536")).toBeNull(); + }); + + it("parseDaemonPortInput accepts valid ports", () => { + expect(parseDaemonPortInput("53200")).toBe(53200); + expect(parseDaemonPortInput(" 52800 ")).toBe(52800); + }); +}); diff --git a/apps/extension/src/transport/__tests__/ws-transport.test.ts b/apps/extension/src/transport/__tests__/ws-transport.test.ts index 566ab8ab..f314358c 100644 --- a/apps/extension/src/transport/__tests__/ws-transport.test.ts +++ b/apps/extension/src/transport/__tests__/ws-transport.test.ts @@ -270,4 +270,19 @@ describe("WSTransport", () => { lastSocket().receive({ id: "2", result: 2 }); expect(handler).toHaveBeenCalledTimes(1); }); + + it("setUrl returns false for the same URL and uses the new URL on the next connect", async () => { + const t = new WSTransport({ + url: "ws://127.0.0.1:52800", + webSocketFactory: (url) => new FakeSocket(url) as unknown as WebSocket, + }); + expect(t.setUrl("ws://127.0.0.1:52800")).toBe(false); + expect(t.setUrl("ws://127.0.0.1:53200")).toBe(true); + + const p = t.connect(); + expect(lastSocket().url).toBe("ws://127.0.0.1:53200"); + lastSocket().open(); + await p; + expect(t.state).toBe("connected"); + }); }); diff --git a/apps/extension/src/transport/daemon-endpoint.ts b/apps/extension/src/transport/daemon-endpoint.ts new file mode 100644 index 00000000..311fbe32 --- /dev/null +++ b/apps/extension/src/transport/daemon-endpoint.ts @@ -0,0 +1,39 @@ +/** Build-time default WebSocket URL (see wxt.config.ts). */ +const base = new URL(__BSK_DAEMON_WS_URL__); + +export const DEFAULT_DAEMON_PORT = Number(base.port) || 52800; + +const MIN_PORT = 1; +const MAX_PORT = 65535; + +function isValidPort(n: number): boolean { + return Number.isInteger(n) && n >= MIN_PORT && n <= MAX_PORT; +} + +/** + * Compose the daemon WebSocket URL for a loopback port, preserving the + * build-time host and path from {@link __BSK_DAEMON_WS_URL__}. + */ +export function resolveDaemonWsUrl(port: number): string { + const path = base.pathname === "/" ? "" : base.pathname; + return `${base.protocol}//${base.hostname}:${port}${path}`; +} + +/** Non-integer / out-of-range / unset → {@link DEFAULT_DAEMON_PORT}. */ +export function normalizeDaemonPort(raw: unknown): number { + if (typeof raw === "number" && isValidPort(raw)) return raw; + if (typeof raw === "string" && raw.trim() !== "") { + const parsed = Number.parseInt(raw.trim(), 10); + if (isValidPort(parsed)) return parsed; + } + return DEFAULT_DAEMON_PORT; +} + +/** Input semantics: empty string → default port; invalid → null. */ +export function parseDaemonPortInput(text: string): number | null { + const trimmed = text.trim(); + if (trimmed === "") return DEFAULT_DAEMON_PORT; + const parsed = Number.parseInt(trimmed, 10); + if (!isValidPort(parsed)) return null; + return parsed; +} diff --git a/apps/extension/src/transport/ws-transport.ts b/apps/extension/src/transport/ws-transport.ts index 2f577820..6974b6a9 100644 --- a/apps/extension/src/transport/ws-transport.ts +++ b/apps/extension/src/transport/ws-transport.ts @@ -40,7 +40,7 @@ interface MessageLikeEvent { * (1s, 2s, 4s, …, capped at 5s) until `disconnect()` is called. */ export class WSTransport implements Transport { - private readonly url: string; + private url: string; private readonly factory: WebSocketFactory; private readonly initialDelayMs: number; private readonly maxDelayMs: number; @@ -69,6 +69,13 @@ export class WSTransport implements Transport { return this.currentState; } + /** Returns whether the URL changed. Does not reconnect. */ + setUrl(url: string): boolean { + if (url === this.url) return false; + this.url = url; + return true; + } + connect(): Promise { if (this.currentState === "connected") return Promise.resolve(); diff --git a/docs/architecture.md b/docs/architecture.md index cfaf9e59..e318fb63 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -43,7 +43,7 @@ Key modules: ### bsk daemon (same binary: `bsk daemon`) -- Listens on loopback WebSocket (default **52800**) for extensions. +- Listens on loopback WebSocket (default **52800**, configurable in the extension popup) for extensions. - Validates `Origin: chrome-extension://…` on handshake. - Maintains `browsers` (connected extensions) and `sessions` (Agent Window bindings). - **Per-session queue** serializes tool calls targeting one session. diff --git a/packages/i18n/src/locales/en-US/extension.json b/packages/i18n/src/locales/en-US/extension.json index 62851c84..b1b1cfd1 100644 --- a/packages/i18n/src/locales/en-US/extension.json +++ b/packages/i18n/src/locales/en-US/extension.json @@ -26,6 +26,11 @@ "controlHintsToggleTitle": "Control hints", "controlHintsToggleHint": "Show the status pill and orange glow while the Agent controls a page.", "controlHintsInfoLabel": "About control hints", + "daemonPortLabel": "Connection port", + "daemonPortHint": "This port is used to connect to the local daemon. Do not change it unless necessary.", + "daemonPortInfoLabel": "About connection port", + "daemonPortInvalid": "Enter a port between 1 and 65535.", + "daemonUnreachable": "Can't connect. Check the daemon is running and the port matches.", "versionSkewWarning": "Extension protocol v{{extensionProtocol}}, CLI protocol v{{cliProtocol}}. Protocol versions differ — please upgrade.", "upgradeAvailable": "Upgradable", "launcher": { diff --git a/packages/i18n/src/locales/zh-CN/extension.json b/packages/i18n/src/locales/zh-CN/extension.json index 338d5df1..b6043235 100644 --- a/packages/i18n/src/locales/zh-CN/extension.json +++ b/packages/i18n/src/locales/zh-CN/extension.json @@ -26,6 +26,11 @@ "controlHintsToggleTitle": "控制提示", "controlHintsToggleHint": "Agent 控制页面时显示提示条和橙色闪光。", "controlHintsInfoLabel": "控制提示说明", + "daemonPortLabel": "连接端口", + "daemonPortHint": "扩展通过此端口连接本机 daemon。非必要请勿修改。", + "daemonPortInfoLabel": "连接端口说明", + "daemonPortInvalid": "请输入 1 到 65535 之间的端口号。", + "daemonUnreachable": "无法连接,请确认 daemon 已启动且端口一致。", "versionSkewWarning": "扩展协议 v{{extensionProtocol}},CLI 协议 v{{cliProtocol}}。协议版本不同,请及时升级。", "upgradeAvailable": "可升级", "launcher": { From 873971f4cbac5b8c47af6bc60ccd10749551f66f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9Chaonan=E2=80=9D?= Date: Wed, 2 Sep 2026 19:18:03 +0800 Subject: [PATCH 2/4] fix(extension): enlarge the popup daemon port input vertically The field was too short to read; grow its height without widening the card. Co-authored-by: Cursor --- apps/extension/src/entrypoints/popup/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/extension/src/entrypoints/popup/App.tsx b/apps/extension/src/entrypoints/popup/App.tsx index f773cce4..906ba977 100644 --- a/apps/extension/src/entrypoints/popup/App.tsx +++ b/apps/extension/src/entrypoints/popup/App.tsx @@ -313,7 +313,7 @@ export function App() { commitDaemonPort(); } }} - className="mt-0 h-5 w-24 shrink-0 rounded-md px-2 py-0 text-center text-sm leading-none shadow-none" + className="mt-0 h-7.5 w-19 shrink-0 rounded-md px-2 py-0 text-center text-sm leading-none shadow-none" aria-invalid={daemonPortInvalid || undefined} data-slot="popup-daemon-port-input" /> From 11cd12c9cdc9a954e1139bc32f760053ed0691a0 Mon Sep 17 00:00:00 2001 From: polarday <61319465+shnpd@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:57:12 +0800 Subject: [PATCH 3/4] Fix regex for validating daemon port input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 如果有字符就报错,不直接转为数字 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/extension/src/transport/daemon-endpoint.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/extension/src/transport/daemon-endpoint.ts b/apps/extension/src/transport/daemon-endpoint.ts index 311fbe32..6ca38224 100644 --- a/apps/extension/src/transport/daemon-endpoint.ts +++ b/apps/extension/src/transport/daemon-endpoint.ts @@ -33,6 +33,7 @@ export function normalizeDaemonPort(raw: unknown): number { export function parseDaemonPortInput(text: string): number | null { const trimmed = text.trim(); if (trimmed === "") return DEFAULT_DAEMON_PORT; + if (!/^\d+$/.test(trimmed)) return null; const parsed = Number.parseInt(trimmed, 10); if (!isValidPort(parsed)) return null; return parsed; From 9a5d45d156a8942dfec4b33d30c2937da8025c6f Mon Sep 17 00:00:00 2001 From: polarday <61319465+shnpd@users.noreply.github.com> Date: Thu, 3 Sep 2026 19:58:49 +0800 Subject: [PATCH 4/4] Fix comment formatting in useDaemonPort function MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修复注释不准确 Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/extension/src/entrypoints/popup/use-daemon-port.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/extension/src/entrypoints/popup/use-daemon-port.ts b/apps/extension/src/entrypoints/popup/use-daemon-port.ts index 7a6efcb6..64eb4fa1 100644 --- a/apps/extension/src/entrypoints/popup/use-daemon-port.ts +++ b/apps/extension/src/entrypoints/popup/use-daemon-port.ts @@ -4,7 +4,7 @@ import { parseDaemonPortInput } from "@/transport/daemon-endpoint"; /** * Popup-side daemon port preference stored in `chrome.storage.local`. - * Commits on blur, Enter (via blur), and pagehide. + * Commits on blur, Enter, and pagehide. */ export function useDaemonPort(): { draft: string;