Skip to content

✨ 新增 sctl Browser 扩展与浏览器控制(第 1 期:地基) - #4

Merged
CodFrm merged 25 commits into
mainfrom
feat/browser-foundation
Sep 29, 2026
Merged

CodFrm merged 25 commits into
mainfrom
feat/browser-foundation

Conversation

@CodFrm

@CodFrm CodFrm commented Sep 29, 2026

Copy link
Copy Markdown
Member

概要

sctl Browser 第 1 期(地基)。新增一个独立的 Chromium 扩展 sctl Browser,和 ScriptCat 同时配对到同一个 sctl serve。用户日常使用的一个或多个浏览器,每个都有自己起的名字,可以通过 CLI 和 MCP 列出、打开、关闭、激活标签页,也可以列出窗口。

需求和设计决策见 spec:docs/specs/2026-09-28-browser-foundation.md。

主要改动

  • 协议按对端生成:protocol.json 仍是唯一的权威来源,每个方法、错误码、握手常量都标注了归属。生成器输出三套代码:Go 侧拿全部定义;ScriptCat 用的 TS 与改动前逐字节一致,CI 里针对固定版本 ScriptCat 的比对照常通过;浏览器扩展另有一套自己的 TS。
  • daemon 支持多个扩展连接:ScriptCat 和多个浏览器实例可以同时在线。每个实例有独立的密钥,登记在 browsers.json,写盘经 fsutil.WriteFileAtomic。握手 MAC 绑定实例的类型和 ID;名称在所有已配对实例里唯一,冲突时返回 CONFLICT。ScriptCat 的 pairing.key 不受影响。
  • 路由与目标选择:请求按方法归属路由(scripts.* 发给 ScriptCat,浏览器方法发给目标实例)。支持 --browser(名称或实例 ID 前缀)和 SCTL_BROWSER。多个浏览器在线时,列表类命令合并结果,操作类命令要求指定目标;目标离线、不存在或 ID 前缀有歧义时,返回各自的错误码。
  • CLI:新增 sctl browsers [list] / forget、sctl tabs list / open / close / activate、sctl windows list;sctl status 会列出浏览器实例;sctl connect 的提示同时覆盖两个扩展。
  • MCP:新增 browsers_list、tabs_*、windows_list,除 browsers_list 外都带可选的 browser 参数。工具描述是静态文本,页面标题和 URL 只作为数据返回,不会拼进描述。
  • 扩展工程 extension/:pnpm、Vite、React、TypeScript、Tailwind v4、shadcn/ui,配 ESLint、Prettier、Vitest。WebSocket 由 offscreen 文档持有。MV3,最低 Chrome 116,权限只有 tabs、storage、offscreen。弹窗采用「同门」风格,共 11 个状态,支持深浅色和中英文;配色满足 4.5:1 对比度。
  • CI 与发布:新增 extension CI 任务;Release 附带 sctl-browser-extension-<version>.zip。
  • 文档:更新 AGENTS、architecture、protocol(新增 §3.1 路由)、threat-model(写明浏览器控制按设计没有人工审批,凭据清单加入 browsers.json)、development、mcp 和两份 README。

期间修掉的问题

  • daemon 注册竞态:daemon 以前在回复 capabilities 之后才登记连接,紧接着的调用偶尔会得到「未连接」。这也是基线里那次偶发失败的原因,现已复现并修复。
  • 重连卡顿:同一个实例重连时,旧连接在持锁状态下同步关闭,最多卡 5 秒。现在改为在后台关闭。
  • 配对码可被并发复用:两个浏览器可以用同一个配对码并发配对成功。现在先作废配对码,再下发密钥。
  • 偶发测试失败:TestBlockingProgressAndCancel 的偶发失败在基线上就存在,根因是测试用 sleep 控制顺序,已改为基于信号的同步。

验证

自动化检查(交付树 7074c44)

  • go build、go vet、go test ./... -race、golangci-lint、make protocol-check 全部 exit 0。
  • 扩展的 install、lint、format:check、typecheck、test(198/198)、build 全部 exit 0。

真机验证

  • 环境:隔离的 Playwright Chrome for Testing,同时加载本分支构建的 sctl Browser 和现有的 ScriptCat 构建;daemon 使用临时数据目录,端口 18643。
  • 结果:63 项检查全部通过。覆盖配对(成功 / 错码 / daemon 不可达)、改名与名称冲突、两个浏览器同时在线时的目标选择与合并、页面标题中控制字符的转义、MCP 工具、离线目标、重连与立即重试、修改地址后配对保留、daemon 侧 forget 后停止自动重试、扩展侧「断开并忘记」、设置持久化、焦点框、减少动态效果、配对码草稿(包括 forget 后重新配对的路径)。全程 ScriptCat 保持在线,pairing.key 不变。
  • 本地按 release.yaml 的步骤打包:得到 sctl-browser-extension-0.2.0-rc.1.zip,manifest 中 version 为 0.2.0,version_name 为 0.2.0-rc.1。

已知未观察到的项(已接受,作为已知缺口)

  • 以下几项只有单元测试覆盖,真机上没有构造出来:握手绑定实例身份(换 ID 重放失败)、调用中途浏览器断开返回 OPERATION_EXPIRED、两个浏览器并发使用同一配对码。
  • 以下场景本次没有跑到:0 个浏览器在线、实例 ID 前缀有歧义、弹窗的「配对中」转圈状态、首次配对时默认名已被占用。
  • 不在本次验证范围内:GitHub 上实际运行的 CI 与 Release 工作流(本 PR 的 CI 就是第一次实跑);日常 Chrome 的真实配置和 Edge。

不在本期范围

第 2 期浏览器数据管理(书签、阅读列表、历史、下载、Cookie 等)、第 3 期页面自动化(CDP)、第 4 期调试、第 5 期 Playwright 端点,各自另写 spec。

Add browser tab and window methods, browser target error codes and browser handshake contexts to protocol.json; generate Go for every peer, ScriptCat TypeScript byte-identical to the paired copy, and browser TypeScript under extension/src/protocol/generated.
Browser instances authenticate with their own per-instance key, bound to
peer kind and instance ID in the handshake MAC, and are recorded in a
0600 registry (browsers.json) written through fsutil.WriteFileAtomic.
Names are unique across paired instances; a taken name is answered with
CONFLICT on $session.capabilities. Connections are registered before the
capabilities reply, fixing the race where an immediate Call got
ErrNotConnected. ScriptCat keeps pairing.key and its
one-connection-replaces-the-previous behaviour.
…page data

MCP tool descriptions for browser tools are static text per spec — tab
titles and URLs returned from tabs.list are never included in tool
descriptions. Add TestToolDescriptionsAreStatic to verify this: list
tools before and after calling tabs.list with marker strings in tab
data, confirm descriptions remain identical and contain no markers.

Closes task 6 gap: MCP tool descriptions are static text.
…nd user docs

architecture.md now documents the second extension kind, simultaneous browser
instances, routing by method ownership, the extension/ directory, and the
current store/ contents (browsers.json plus per-instance keys, not just K).
AGENTS.md scopes "the authority always lives on the extension side" to
ScriptCat's write/source-disclosure gates and calls out that browser control
has no human gate by design. README.md, docs/README_zh-CN.md, and docs/mcp.md
add installing and pairing the sctl Browser extension and the new
browsers/tabs/windows commands and MCP tools, cross-linking to the owning
docs instead of duplicating details.
- popup connected state shows browser, daemon version and the real
  connected-since time carried in ConnectionState, plus the instance name
- pairing always registers the default name; the pairing-code draft is kept
  until pairing succeeds
- merged list calls skip instances that answer NOT_FOUND (window IDs are
  per browser); forget of an unknown instance returns BROWSER_NOT_FOUND
- CLI asks for --browser on BROWSER_AMBIGUOUS; offline browsers omit
  connectedAt in JSON
- MCP browser tools describe list aggregation correctly and never report
  "waiting for approval"; tests cover browser argument forwarding
- threat model states browser control has no human gate; Noto Sans CJK
  fallback; dark danger button text meets contrast
Splits the brand decorative blue (#1296DB) from a darker, contrast-safe
--primary (#0E77AE) and darkens the light-mode success/warning/danger
tokens (#128155/#9D6504/#D2352C) per the revised spec (2b4dd1a); dark
mode is unchanged. The link panel's icons move to --brand since they
carry no text. A new test parses :root/.dark from globals.css and
asserts every text/background and white-on-fill pairing the popup uses
is >= 4.5:1 in both modes, and pins --brand to the spec values.
Button hover states also carry text, so they fall under the spec's
4.5:1 rule. The primary button's bg-primary/80 hover washed white text
down to 3.46:1 in light mode, the forget footer button's 10% red tint
left its red text at 4.23:1, and the dark danger confirm hover
(oklch black 12%) dropped dark text to 4.38:1. Hover fills are now
--primary-hover/--bad-hover tokens that move away from the text colour
in each mode, and the forget button uses the ghost muted hover. The
light --destructive token (aria-invalid borders) still held the
pre-revision #D6453D; it now equals the spec danger colour. The
contrast test covers both hover fills and pins --destructive to --bad.
- escape control characters in page titles and URLs printed by sctl tabs list
- log the cause when forgetting a browser fails to write the registry
- forget resolves names before IDs, matching --browser target selection
- settle a rename whose accepted name cannot be saved, and revert to the saved name
- show the rejection again after a re-pairing later gets rejected
- require the browser registry in bridge.NewServer and drop dead nil checks
- derive MCP browser routing and the browser parameter from protocol peer/mergeField
- merge control.Client Call/CallBrowser and share MCP schema compilation
- drop vacuous assertions from the static MCP description test
TestBlockingProgressAndCancel slept 50ms and assumed the tools/call had
reached the bridge caller by then. Under CPU load the cancellation can
arrive before the server dispatches the request; go-sdk then drops the
request, the bridge is never called, and the test failed with
"ctx 取消未传播到桥接调用" (reproduced at the base 49d076c too).
Wait for the fake caller to signal entry before cancelling.
…n to close

Registering a new ScriptCat or browser-instance connection closed the
connection it replaced synchronously, and the capabilities reply is only
sent after registration. coder/websocket's Close waits up to 5s for the
old peer's close frame whenever the daemon's read loop for that
connection is not parked in a read, so the new connection's reply was
held back for 5s (and, for browsers, regMu was held meanwhile).
TestBrowserInstancesCoexist hit this at server_test.go:80 when a2's
capabilities reply missed the 3s read deadline. The replaced connection
is now closed in the background.

closedByDaemon no longer counts a read deadline as a close, so a
replacement that never closes the old connection fails the tests.

Separately, the frame validator compiles its schemas on first use; under
heavy CPU load that one-time cost fell inside the first handshake's 3s
read deadline. Both test harnesses now pay it before any deadline.
…e docs

- the reconnect note counted the upcoming retry as attempt+1, so the first
  retry after a failed connection read as the second
- a pairing code is consumed by the first extension that pairs; README,
  README_zh-CN and the connect comment claimed the same code pairs both
- README command table still described connect and mcp as ScriptCat-only
- tabs_list description said it lists one window; it lists every window
  unless windowId is given
- ForgetInstance closed an online instance's connection synchronously while
  holding regMu; a peer that never answers the close frame held forget, and
  every other browser's registration, for up to 5s. It now closes in the
  background like a replaced connection (closeReplaced -> closeInBackground).
- handleRPCResponse left the pending entry in place after delivery; a
  repeated response arriving after the caller gave up blocked the read loop
  forever on the full respCh. Delivery now removes the entry first.
- tabs_close and tabs_activate MCP schemas dropped protocol.json's minItems
  and minimum constraints; a test now pins browser tool schemas to their
  protocol params types.
- TestBrowserToolsNeverReportWaitingForApproval slept 100ms without waiting
  for the call to arrive, so under load it could pass vacuously; it now
  waits for arrival and uses an approval call's progress as its clock.
- The popup address check accepted hosts no WebSocket URL can be built from
  (999.1.1.1, [:::]); saving one broke every later connection attempt.
- The popup applied the initial getState reply even after a newer state
  broadcast had arrived, freezing a stale status on screen.
@CodFrm
CodFrm merged commit a78f08b into main Sep 29, 2026
6 checks passed
@CodFrm
CodFrm deleted the feat/browser-foundation branch September 29, 2026 03:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant