Skip to content

refactor(protocol): generate TypeScript contracts and enforce complete tool policies - #389

Draft
NianJiuZst wants to merge 1 commit into
Tencent:mainfrom
NianJiuZst:codex/protocol-contract-check
Draft

NianJiuZst wants to merge 1 commit into
Tencent:mainfrom
NianJiuZst:codex/protocol-contract-check

Conversation

@NianJiuZst

@NianJiuZst NianJiuZst commented Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Closes #388 when the agreed implementation is merged.

Draft implementation for design discussion. The issue is an RFC, and the architecture, compatibility policy, scope, and rollout still need maintainer agreement. This PR makes the proposal concrete; it is not a request to merge an already-approved design. The evaluation-settlement change below is a deliberate behavior change and a candidate for a separate PR if maintainers prefer staged delivery.

Problem and intended result

A protocol change currently requires coordinating Rust payload definitions, handwritten TypeScript mirrors, schema exports, dispatcher registrations, and several method classifications. These descriptions can drift independently: for example, Rust already defines user_aborted, while the old TypeScript error-code union omitted it. Type assertions at the JSON boundary also provide no runtime validation.

This prototype makes Rust the source for shared protocol facts, generates the extension's contract artifacts, validates payloads at the boundary, and makes missing handlers or policies a compilation error. Component-specific execution decisions remain explicit and owned by the component that executes them.

1. One method catalog, with typed payload roots

crates/bsk-protocol/src/method.rs now declares each method once:

ToolClick => ("tool.click", Extension, BrowserMutation, ClickParams, ClickResult),

The macro generates the Rust Method enum, Method::ALL, wire names, execution ownership, effect classifications, and the parameter/result schema registrations. Adding a method requires all five pieces of information.

src/catalog.rs traverses the complete catalog and uses one schema generator per direction, following the actual definition references assigned by Schemars. It also includes shared error, handshake, cancellation, and trace definitions. Debug-action ownership/effects and protocol-version constants are exported from Rust; the CLI parser and extension capability list consume those definitions.

dump-schema derives method exports from the same catalog, retaining named compatibility aliases for reusable documentation/trace schemas. Its --out-dir option allows inspection without adding generated schema files to the checkout.

Daemon-private payloads intentionally remain private; catalog entries using arbitrary JSON are not presented as fully validated extension DTOs.

2. Generate types and validators from the same definitions

Rust payloads + method catalog
           |
           v
export-protocol: JSON Schema + method metadata
           |
           v
scripts/protocol.mjs
           |
           +-- input.ts / output.ts / types.ts
           +-- methods.ts: ToolMap, ParamsOf, ResultOf, metadata
           +-- validators.js / validators.d.ts
           +-- normalizers.ts
           +-- manifest.json

The generator runs the Rust exporter with locked Cargo dependencies, checks catalog uniqueness and payload references, uses json-schema-to-typescript for declarations, and uses Ajv standalone compilation plus esbuild for browser-compatible validators. The current export contains 41 extension-owned methods and 193 schema definitions.

Schemars is upgraded from 0.8 to 1.2.2 so deserialization and serialization contracts can be exported separately. This matters because a defaulted field may be optional when received but always present when sent. Existing trace v2/v3 schema customizations are migrated with their classification rules preserved.

Generated immutable normalizers convert legacy null on optional typed fields to omission. Arbitrary JSON values and dictionary entries retain their null values. Conditional schemas are projected into TypeScript unions, while runtime validators retain constraints such as numeric bounds and array lengths.

The extension ships generated validation functions; it does not ship a runtime schema compiler or load schemas over the network.

3. Validate the transport boundary and use typed dispatch

incoming JSON
  -> frame shape guard
  -> known-method lookup
  -> raw wire-parameter validation
  -> immutable normalization
  -> normalized-parameter validation
  -> typed handler
  -> result validation
  -> outgoing response

transport/protocol.ts owns the decoding/validation boundary. Malformed frames are rejected by envelope guards, unsupported methods fail explicitly, and invalid parameters produce invalid_params before dispatch. Handshake responses use the same validation/normalization approach, and cancellation keeps its fast path with validated parameters.

Handwritten payload mirrors are replaced by generated imports/reexports in transport, debug, session, tab, and window modules. Transport envelopes and local interpretation helpers remain handwritten where appropriate.

A handler result that violates the sender contract becomes protocol_error with effect_state: "unknown": a bad response does not prove that the browser action had no effect. Failed session-start result validation also cleans up the newly allocated session. Handshake audit fields and session-stop window-release metadata receive named Rust envelope types instead of ad hoc JSON additions.

4. Require complete handlers and explicit local policies

ToolMap is generated from extension-owned catalog entries. The handler table must satisfy:

type ToolHandlerMap = {
  [M in ExtensionToolMethod]: (
    params: ParamsOf<M>,
    signal: AbortSignal,
    onInputSent?: (tabId: number) => void,
  ) => Promise<ResultOf<M> | RpcError>;
};

Every method key is required, with its corresponding parameter and result types. The dispatcher no longer maintains a separate switch full of per-method payload assertions.

Concern Owner and enforcement
Wire names, payload pairs, owner, shared browser effects Rust catalog; generated consumers
Extension implementations ToolHandlerMap requires every extension method
Background targeting, control resumption, popup tracking, remote support tools/policy.ts satisfies Record<ExtensionToolMethod, ToolPolicy>
Daemon deadlines, response grace, cancellation, outcome and settlement daemon/tool_policy.rs exhaustively matches Method, without a wildcard default

This intentionally does not combine all behavior into one universal policy table. For example, being accessible through a background-tab targeting path does not mean that arbitrary evaluate code is read-only. Shared facts come from the catalog; local decisions remain explicit. The compiler guarantees coverage and type compatibility, not the correctness of a policy's chosen value.

5. Make evaluation settlement explicit

The daemon's new policy distinguishes a caller deadline from the end of arbitrary page JavaScript execution. For tool.evaluate, a deadline sends cancellation and allows cleanup grace, but a cancel acknowledgement is not treated as proof that the evaluation stopped.

If the original response is still outstanding, the caller can receive execution_pending while the daemon retains the original response receiver and keeps the session busy. Activity is reported as settling; the original result releases the queue. The operation is not replayed. A lost connection produces an unconfirmed outcome, or the existing connection lifecycle invalidates the session; neither makes the old session safe for new commands. Other sessions remain independent.

This tracks the awaited evaluation only. A script can schedule later independent work, and cancellation cannot roll back browser effects. Maintainer feedback is specifically requested on keeping this change here versus separating it from contract generation.

6. Developer workflow and CI

# After editing Rust protocol definitions:
pnpm protocol:generate
pnpm protocol:check

# Optional standalone schema export:
cargo run -p bsk-protocol --bin dump-schema --locked -- --out-dir /tmp/bsk-schema

Commit the Rust changes and generated artifacts together. protocol:check regenerates expected content in memory and fails on missing, changed, or unexpected files. Frontend CI installs Rust and runs this check before typechecking.

Adding an extension tool now means: declare its contract and shared effect, regenerate, implement the handler, and deliberately choose both components' policies. Missing cases fail compilation. Generation is an explicit command, not a file watcher. There is one authoritative generation/check path; the earlier local comparison scripts are not included or referenced by the published change.

Validation and evidence

Validation was run locally on macOS with Rust 1.94.1, Node 26.10.0, and the repository's pnpm 10.17.0. Frontend checks used a clean export of the staged source files with the existing installed dependencies, so untracked local audit/prototype files were not part of the checked source.

Check Result
pnpm protocol:check Pass: 41 extension methods, 193 definitions
cargo fmt --all -- --check and staged whitespace check Pass
cargo test --workspace --locked 830 passed, 1 ignored
cargo clippy --workspace --lib --bins --locked -- -D warnings Pass
pnpm lint Pass, including DSH typechecking and 446 existing DSH tests
pnpm --filter @browser-skill/extension compile Pass in the clean source export
pnpm ext:test 2,421 passed; 122 browser-gated/configuration skips
pnpm ext:build Pass

Additional out-of-tree checks performed during implementation:

  • 23 contract/envelope checks, including invalid values, optional-null normalization, retained JSON/map nulls, and rejection of deliberately stale generated metadata.
  • An in-memory compiler probe removing tool.evaluate from the handler and policy maps produced missing-key errors for ToolHandlerMap and Record<ExtensionToolMethod, ToolPolicy>.
  • Existing extension tests also passed with external result-validation wrappers around 39 handlers.
  • A real daemon with a simulated WebSocket extension confirmed that a cancel acknowledgement did not release the busy session, a late original result did release it, and connection replacement invalidated the old session:
{"cancel_ack_did_not_unlock":true,"late_result":"idle","real_daemon_and_websocket":true}
{"cancel_ack_did_not_unlock":true,"late_result":"invalidated","real_daemon_and_websocket":true}

Known validation limits: all-target Clippy currently fails on the pre-existing redundant_iter_cloned warning in unchanged crates/bsk-cli/src/cli/update.rs:1428; production-target Clippy passes. Browser-gated tests were skipped, and the daemon settlement probe used a simulated extension, not a real Chrome end-to-end run. Cross-platform and released-peer compatibility are not established by these local results. Hosted CI is reported separately by GitHub checks.

No new test cases, test scripts, fixtures, screenshots, or local audit artifacts are committed. Existing tests receive only adaptations needed for the changed interfaces/schema API, required request fields, and asynchronous response timing; additional probe evidence is described here.

Compatibility and decisions before leaving Draft

  • The prototype keeps protocol 1.3 and minimum compatible protocol 1.0; this is not proof that every previously tolerated peer payload remains compatible. Strict validation may expose existing inconsistencies, so the compatibility matrix and any version change need discussion.
  • Empty console/network results consistently emit entries: [], while Serde readers retain default acceptance of omission.
  • Dynamic permissions, session ownership, tab readiness, URL/regex semantics, and real browser effects remain handler responsibilities.
  • Maintainers should decide the preferred generator, policy ownership, generated-code review conventions, rollout stages, evaluation-settlement scope, and required browser/platform evidence.

这是供设计讨论的 Draft 实现。 Issue 仍是 RFC,架构、兼容策略、改动范围和落地方式都需要与维护者讨论决定。本 PR 用具体实现帮助评估方案,不代表设计已经获准合并。下面的 evaluate 结束确认属于明确的行为变化;如果维护者希望分阶段推进,可以拆成独立 PR。

问题与预期结果

目前修改协议,需要协调 Rust 参数/结果定义、手写 TS 镜像、Schema 导出、处理函数注册和多处方法分类。这些描述可能独立漂移。例如 Rust 已有 user_aborted,旧 TS 错误码联合类型却没有;JSON 边界上的类型断言也不会提供运行时校验。

本原型以 Rust 作为共享协议信息的来源,生成扩展使用的协议产物,在边界检查数据,并让遗漏处理函数或策略成为编译错误。组件特有的执行决策仍由各自显式维护。

1. 用一份方法目录关联协议与类型

crates/bsk-protocol/src/method.rs 中,每个方法只登记一次:

ToolClick => ("tool.click", Extension, BrowserMutation, ClickParams, ClickResult),

宏据此生成 Rust Method 枚举、Method::ALL、通信方法名、执行归属、影响类别,以及参数和结果的 Schema 注册信息。新增方法必须提供完整信息。

src/catalog.rs 遍历完整目录,分别为输入、输出使用统一的 Schema 生成器,并沿用 Schemars 实际分配的引用名称。它还导出共享错误、握手、取消和录制轨迹定义。Debug action 的归属/影响,以及协议版本常量,也从 Rust 导出,由 CLI 参数解析和扩展能力列表消费。

dump-schema 从同一目录推导方法导出列表,为可复用的文档/轨迹 Schema 保留兼容文件名;通过 --out-dir 可以在仓库外检查结果。

Daemon 私有参数继续保持私有;目录中使用任意 JSON 的条目,不会被宣称为已经完整校验的扩展 DTO。

2. 从同一份定义生成类型与校验器

Rust 参数/结果类型 + 方法目录
           |
           v
export-protocol:JSON Schema + 方法元数据
           |
           v
scripts/protocol.mjs
           |
           +-- input.ts / output.ts / types.ts
           +-- methods.ts:ToolMap、ParamsOf、ResultOf、元数据
           +-- validators.js / validators.d.ts
           +-- normalizers.ts
           +-- manifest.json

生成器使用锁定的 Cargo 依赖运行 Rust 导出程序,检查方法唯一性和类型引用,再通过 json-schema-to-typescript 生成声明,通过 Ajv standalone 和 esbuild 生成浏览器可用的校验函数。当前覆盖 41 个由扩展执行的方法、193 个 Schema 定义。

Schemars 从 0.8 升级到 1.2.2,分别导出反序列化和序列化协议。例如带默认值的字段,接收时可以省略,发送时却可能始终存在,两者不能混为一谈。既有 trace v2/v3 自定义 Schema 已迁移,并保留原有分类规则。

生成的不可变规范化函数,把可选类型字段的旧式 null 转成字段省略;任意 JSON 和字典值中的 null 保留。条件 Schema 投影为 TS 联合类型,数值范围、数组长度等约束仍由运行时校验器检查。

扩展打包的是已生成的校验函数,不携带运行时 Schema 编译器,也不通过网络加载 Schema。

3. 在通信边界校验,再进入类型化分发

收到 JSON
  -> 检查消息封装
  -> 查找已知方法
  -> 校验原始参数
  -> 不可变规范化
  -> 校验规范化参数
  -> 调用类型匹配的处理函数
  -> 校验返回结果
  -> 发送响应

transport/protocol.ts 负责解码与校验边界。非法消息封装会被拒绝,未知方法明确报错,非法参数在进入处理函数之前返回 invalid_params。握手响应使用相同的校验/规范化过程;取消请求保留快速通道,同时校验参数。

Transport、debug、session、tab、window 中重复的协议类型改为导入或重新导出生成类型。适合留在本地的消息封装和错误解释辅助逻辑继续手写。

如果处理函数结果不符合发送协议,会返回带 effect_state: "unknown" 的 protocol_error,因为返回值不合法不能证明浏览器操作没有发生。会话启动结果校验失败时,也会清理刚分配的会话。握手审计字段和停止会话时的窗口保留信息改为具名 Rust 封装类型,避免临时向 JSON 中插字段。

4. 强制处理函数与本地策略完整覆盖

ToolMap 来自目录中由扩展执行的方法。处理函数表必须符合:

type ToolHandlerMap = {
  [M in ExtensionToolMethod]: (
    params: ParamsOf<M>,
    signal: AbortSignal,
    onInputSent?: (tabId: number) => void,
  ) => Promise<ResultOf<M> | RpcError>;
};

每个方法都是必填项,并绑定自己的参数和结果类型。Dispatcher 不再依赖另一份包含逐方法参数断言的大 switch。

关注点 归属与约束
方法名、参数/结果配对、执行归属、共享浏览器影响 Rust 目录定义,生成消费者使用的信息
扩展处理函数 ToolHandlerMap 要求覆盖全部扩展方法
后台目标选择、恢复控制、弹窗跟踪、远程支持 tools/policy.ts 满足 Record<ExtensionToolMethod, ToolPolicy>
Daemon 超时、响应宽限、取消、结果和结束确认 daemon/tool_policy.rs 穷尽匹配 Method,没有通配默认分支

这里没有把所有行为塞进一个通用策略表。例如可以通过某条后台标签页路径访问,不代表任意 evaluate 代码就是只读操作。共享事实来自目录,本地决策仍显式维护。编译器保证覆盖完整、类型匹配,不能证明每个策略取值在业务上一定正确。

5. 明确 evaluate 的结束确认

Daemon 策略区分“调用方等待超时”和“页面 JavaScript 执行结束”。tool.evaluate 到期时会发送取消,并等待清理宽限期,但取消确认不会被视为脚本已经停止的证据。

如果原响应仍未返回,调用方可以收到 execution_pending,同时 daemon 保留原响应接收器,并继续让该会话保持忙碌。活动状态显示为 settling,原操作最终响应到达后才释放队列,期间不会重放操作。断连会产生未确认结果,或者由已有连接生命周期使会话失效;两种情况都不会把旧会话重新当作可安全执行新命令的会话。其他会话保持独立。

这里追踪的是等待中的 evaluate 本身。脚本仍可能安排之后独立执行的工作,取消也不能回滚浏览器影响。希望维护者明确讨论:这部分行为变化应保留在本 PR,还是从协议生成改动中拆出。

6. 开发流程与 CI

# 修改 Rust 协议定义之后:
pnpm protocol:generate
pnpm protocol:check

# 可选:导出独立 Schema:
cargo run -p bsk-protocol --bin dump-schema --locked -- --out-dir /tmp/bsk-schema

Rust 修改与生成文件一起提交。protocol:check 在内存中重新计算预期内容,生成文件缺失、变化或出现额外文件都会失败。前端 CI 安装 Rust,并在类型检查前运行该检查。

新增扩展工具的流程变为:声明协议和共享影响、重新生成、实现处理函数、主动选择两端策略。遗漏会导致编译失败。生成需要显式运行命令,当前没有文件变化监听。正式改动只保留一条权威生成/检查路径,不包含或引用此前的本地比较脚本。

验证与证据

本地环境为 macOS、Rust 1.94.1、Node 26.10.0、仓库指定的 pnpm 10.17.0。前端检查使用从暂存区导出的干净源码副本,并复用已安装依赖;本地未跟踪的审计文件和原型文件没有进入被检查的源码。

检查 结果
pnpm protocol:check 通过:41 个扩展方法、193 个定义
cargo fmt --all -- --check 与暂存区空白检查 通过
cargo test --workspace --locked 830 通过,1 忽略
cargo clippy --workspace --lib --bins --locked -- -D warnings 通过
pnpm lint 通过,包含 DSH 类型检查和 446 个既有 DSH 测试
pnpm --filter @browser-skill/extension compile 干净源码副本中通过
pnpm ext:test 2,421 通过;122 个按浏览器条件/现有配置跳过
pnpm ext:build 通过

实现期间还在仓库外进行了以下验证:

  • 23 项协议/消息封装检查,覆盖非法值、可选 null 规范化、JSON/字典 null 保留,以及人为制造的生成元数据过期。
  • 在内存中的编译探针里移除 tool.evaluate 处理函数和策略,分别得到 ToolHandlerMap、Record<ExtensionToolMethod, ToolPolicy> 的缺项错误。
  • 为 39 个处理函数添加外部结果校验包装后,既有扩展测试仍通过。
  • 使用真实 daemon 和模拟 WebSocket 扩展,确认取消确认不会释放忙碌会话、原结果延迟返回后会释放、连接替换会使旧会话失效:
{"cancel_ack_did_not_unlock":true,"late_result":"idle","real_daemon_and_websocket":true}
{"cancel_ack_did_not_unlock":true,"late_result":"invalidated","real_daemon_and_websocket":true}

已知验证边界: 全目标 Clippy 仍被未修改的 crates/bsk-cli/src/cli/update.rs:1428 中已有的 redundant_iter_cloned 警告阻断;生产目标 Clippy 通过。依赖浏览器条件的测试仍跳过,daemon 结束确认探针使用模拟扩展,不是真实 Chrome 端到端验证。本地结果不能证明跨平台和所有已发布版本的兼容性。托管 CI 状态以 GitHub checks 单独展示。

提交中没有新增测试用例、测试脚本、fixture、截图或本地审计素材。既有测试仅做接口/Schema API、必填请求字段和异步响应时序所需的适配;额外探针的证据放在本说明中。

兼容性与结束 Draft 前需要决定的事项

  • 原型保留协议版本 1.3、最低兼容版本 1.0,这不能证明过去所有被容忍的消息都仍兼容。严格校验可能暴露既有不一致,需要讨论兼容矩阵及是否调整版本。
  • 空 console/network 结果统一发送 entries: [],Serde 读取时仍通过默认值接受字段省略。
  • 动态权限、会话归属、标签页就绪状态、URL/正则语义,以及实际浏览器影响,仍由处理函数负责。
  • 需要维护者决定生成工具、策略归属、生成代码评审方式、分阶段落地安排、evaluate 结束确认的范围,以及所需浏览器/平台证据。

…licies

Refs Tencent#388. Derive extension contracts and validators from the Rust catalog, enforce exhaustive handler and execution policies, and preserve evaluation settlement across caller deadlines.

This branch has not been deployed

No deployments
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.

RFC: unify the Rust-TypeScript protocol contract and require complete tool policies

1 participant