52 KiB
Connector 授权 UI 协议与可替换渲染方案
Status: Draft RFC
Owner: Connector Platform
Protocol:
tutti.connector.authorization.view.v1tutti.connector.authorization.event.v1
0. 决策摘要
Connector 授权采用一套 Tutti 所有、版本化、与 React 无关的声明式 UI 协议。授权交互适配器 只描述当前要展示的授权界面,Renderer 只展示界面并返回用户事件。常规单凭证、OAuth 等场景 优先由平台的声明式适配器根据 Connector 配置完成,不要求每个 Connector 发布 JS 处理器; 只有二维码、设备码、轮询或其他动态多步流程才实现自定义交互适配器。
协议只存在于 Connector 和 Renderer 的边界,不是 tsh-server API、Connector Market API、 持久化模型或通用低代码页面协议。tsh-server 不解析、不校验、也不生成该协议。
协议实现放在独立 workspace 包:
packages/connector/authorization-protocol
包名:
@tutti-os/connector-authorization-protocol
该包使用 Valibot 定义 V1 Schema,并从 Schema 推导 TypeScript 类型。首期不引入 JSON
Schema、AJV、JSON Forms、RJSF 或表单状态库。默认 Renderer 继续放在
@tutti-os/connector-market,使用 @tutti-os/ui-system;宿主可以替换整个 Renderer,
也可以替换某种 view 或 field 的 Renderer。
UI 协议与声明式授权配置是两个边界:前者是 Adapter/Renderer 之间的 View/Event;后者是 Connector package 提供给平台通用 Adapter 的可信配置。credential 最终注入哪个 header、 endpoint 或进程环境仍由现有受信 binding 决定,不进入 UI 协议。
1. 背景和第一处错误边界
当前授权 Dialog 根据 authorizationKind === "api_key" 决定是否展示 secret 输入框,
并直接理解 secret、authorizationUrl 等授权实现细节:
authorizationKind
-> ConnectorAuthorizationDialog 分支
-> secret 或 authorizationUrl
-> beginAuthorization
authorizationKind 描述凭证类型,不等于当前应该渲染的用户交互。一个 API key Connector
可能需要多个字段,一个 OAuth Connector 可能需要链接、device code 或二维码;Renderer
不应该根据凭证类型猜测界面。
目标数据流是:
Connector
-> AuthorizationViewEnvelopeV1 (unknown JSON)
-> Valibot parse + 跨字段校验
-> Authorization UI Controller
-> injected AuthorizationRenderer
-> AuthorizationEventEnvelopeV1
-> Controller 校验事件与当前 view 是否匹配
-> Connector
-> 下一份 AuthorizationViewEnvelopeV1
第一个需要修正的状态是 Connector 没有显式输出 UI view,而不是 Dialog 缺少更多
authorizationKind 分支。
1.1 工作流程图
flowchart TD
U["用户打开授权页面"] --> C0["Controller 创建本地 flow<br/>open(locale, signal)"]
C0 --> S["Connector 打开有序 View Stream"]
subgraph ADAPTER["授权交互适配器:声明式优先,可自定义"]
S --> B["推进授权业务<br/>通用 profile adapter / 自定义动态流程"]
B --> O["输出 unknown AuthorizationView"]
E2["接收已校验 AuthorizationEvent"] --> B
end
O --> P{"Valibot + 跨字段校验"}
P -- "失败" --> PE["Tutti 安全协议错误页<br/>不渲染原始 payload"]
P -- "成功" --> CV["Controller 提交当前 viewId<br/>生成 renderKey"]
CV --> R["可替换 Renderer<br/>只展示 View"]
R --> A["用户输入或操作"]
A --> D["Renderer 生成 Event<br/>submit / activate / refresh / retry / cancel"]
D --> V{"Controller 按当前 View<br/>校验 viewId、字段和值"}
V -- "失败" --> LE["本地安全错误<br/>不发送给 Connector"]
LE --> R
V -- "成功" --> K{"事件类型"}
K -- "activate" --> N["宿主校验并打开外部 URL"]
N -- "允许并成功" --> E2
N -- "拒绝或失败" --> LE
K -- "submit / refresh / retry" --> E2
K -- "cancel" --> X["best-effort dispatch cancel"]
O -. "下一帧,继续循环" .-> P
U2["用户关闭 / 协议失败 / 卸载"] --> X
PE --> X
X --> CL["abort View Stream<br/>撤销 file handle / object URL<br/>清空 secret draft / port.close"]
B -. "Adapter 内部调用;不属于 UI 协议" .-> BACKEND["Provider / 本地 Runtime / tsh-server"]
图中的实线 View 和 Event 是本方案定义的授权适配器/Renderer UI 协议;适配器到
Provider、本地 Runtime 或 tsh-server 的调用不共享这套 UI Schema。Renderer 永远不直接
访问这些 backend。
1.2 腾讯文档现状核对
腾讯文档 0.2.0 没有 Connector 专属授权脚本,业务链路确实是配置驱动的:
tutti.connector.json声明authorization.kind: "api_key"、backend: "native_secret";implementation/mcp.json通过bindingRef: "tencent-docs.primary"关联服务端 binding;server/tsh-binding.json声明远端 MCP endpoint、Authorizationheader 注入、allowlist 和initialize/tools/list验证;- tsh-server 负责校验、加密保存和运行时 header 注入,Connector 不处理 token。
但当前 UI 仍不是“显式表单配置”:桌面端根据 authorizationKind === "api_key" 硬编码生成
单个 secret 输入框。也就是说,腾讯文档的授权业务和 binding 已配置化,表单展示仍靠 UI
猜测。本方案要补齐的是最后这一段:由通用声明式适配器把显式 presentation 配置转换为
View/Event,Renderer 不再根据 authorization kind 猜字段,同时不破坏腾讯文档现有的
native_secret 和 tsh binding 链路。
2. 范围
2.1 目标
- 用有限、版本化的 union 描述当前授权页面;
- 覆盖邮箱加授权码、PAT、数值配置、OAuth 文件、外部链接、device code、二维码、等待和 结果页;
- 所有 Connector 输出在进入 Renderer 前经过运行时校验;
- Renderer 不理解具体 Connector、provider 或凭证类型;
- Connector 不提供 React、HTML、CSS、组件路径或脚本;
- 支持替换整体 Renderer、单个 view Renderer 和单个 field Renderer;
- 对未知版本、未知 view、未知 field 和额外属性 fail closed;
- secret、二维码内容和本地文件句柄不进入日志、持久化或 analytics;
- 保持协议轻量,首期只增加 Valibot。
2.2 非目标
- 不定义 tsh-server、HTTP、gRPC、protobuf 或 Connector Market backend 接口;
- 不定义 Connector 内部如何完成 OAuth、轮询或凭证交换;
- 不提供任意页面布局、嵌套对象、数组表单、条件表达式或远程组件;
- 不允许 Connector 下发 Markdown、HTML、CSS、className、图标 URL 或任意 handler;
- 不允许 Connector 选择 Renderer;
- 不让 Renderer 直接访问 Connector transport、Market backend 或 credential store;
- 不为非 TypeScript 语言生成 JSON Schema;
- 不在 V1 提供自定义 field/view type 扩展槽。
3. 所有权与依赖方向
3.1 包职责
packages/connector/authorization-protocol/
package.json
src/
index.ts
limits.ts
parseAuthorizationEvent.ts
parseAuthorizationView.ts
validateEventForView.ts
v1/
eventSchemas.ts
types.ts
viewSchemas.ts
__fixtures__/
valid/
invalid/
协议包只拥有:
- Valibot Schema;
- 从 Schema 推导的 V1 DTO 类型;
- 统一 parser 和稳定的 parse error;
- 事件与当前 view 的语义校验;
- 安全上限常量;
- package-private contract fixtures。
协议包禁止依赖:
- React 和 DOM;
@tutti-os/ui-system;@tutti-os/connector-market;- desktop bridge;
- tsh-server client;
- Connector runtime transport;
- Valtio 或其他业务状态库。
依赖方向:
Authorization adapter ────┐
├──> connector-authorization-protocol
Authorization controller ┤
Default Renderer ─────────┘
connector-authorization-protocol -X-> connector-market
connector-authorization-protocol -X-> tsh-server
这满足 packages/AGENTS.md 的共享边界要求:协议包由授权 Adapter 输出侧、Controller 和
Renderer 多方消费,职责可以精确命名,不是为了“看起来可复用”而抽取。
3.2 默认 Renderer 位置
首期默认实现放在:
packages/connector/market/src/ui/authorization/
DefaultAuthorizationRenderer.tsx
AuthorizationRenderer.types.ts
viewRenderers/
AuthorizationFormView.tsx
AuthorizationExternalLinkView.tsx
AuthorizationQrCodeView.tsx
AuthorizationDeviceCodeView.tsx
AuthorizationProgressView.tsx
AuthorizationResultView.tsx
fieldRenderers/
AuthorizationTextField.tsx
AuthorizationSecretField.tsx
AuthorizationNumberField.tsx
AuthorizationSelectField.tsx
AuthorizationBooleanField.tsx
AuthorizationLocalFileField.tsx
只有出现第二个真实 UI 消费方后,才将默认实现拆为
packages/connector/authorization-ui。协议包从一开始独立,避免 Connector 依赖 Market
或 React。
3.3 发布形态
本方案不发布需要单独执行或通过 CDN 加载的 JS 脚本。发布物是普通 ESM npm library:
@tutti-os/connector-authorization-protocol
dist/index.js
dist/index.d.ts
dist/v1/index.js
dist/v1/index.d.ts
README.md
package.json
推荐 exports:
{
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./v1": {
"types": "./dist/v1/index.d.ts",
"import": "./dist/v1/index.js"
}
}
}
- 声明式 Connector 直接在 manifest 中携带 JSON 配置,不需要 import 或执行 JS;
- 自定义 JS/TS Adapter 可以从
/v1import typed builder; - Tutti Controller 和默认 Renderer 依赖同一 package;
valibot是该 package 的普通 runtime dependency,不要求调用方配置 peer version;- package 输出编译后的 ESM JS 和
.d.ts,不公开src/*; - npm package version 与 UI protocol version 独立:package 可以修 bug,Connector 通过
/v1subpath 明确选择协议版本; - Python 或其他语言 Connector 不需要执行这份 JS,仍可按协议生成 JSON,由 Tutti 的 Valibot parser 做最终边界校验;
- 首期不发布 JSON Schema、CDN bundle、全局变量脚本、CLI executable 或远程可执行代码。
本次实现将协议作为 public workspace package,并已加入
docs/conventions/npm-package-release.md 的固定发布组。Connector manifest 只携带 JSON,
不要求加载该 package 或执行远程 JS;仓库外 TypeScript Adapter 后续可从 /v1 import 类型和
builder。
4. 协议设计原则
- 一帧一个 view:Connector 每次只描述当前界面,不下发完整流程图。
- Adapter 拥有状态转换:Renderer 不推断下一步、不轮询、不交换凭证。Adapter 可以是 Tutti 提供的通用声明式实现,也可以是 Connector 的自定义动态实现。
- 纯数据:协议是可结构化克隆的 JSON 数据,不包含函数或框架对象。
- 显式 union:view 和 field 均使用封闭的 discriminated union。
- 严格 V1:V1 Schema 使用
strictObject,未知字段拒绝;发布后冻结。 - 安全能力由宿主决定:打开 URL、读取文件和复制内容必须经过可信宿主能力。
- 文案与视觉分离:Connector 可以提供业务标题和说明;通用按钮、状态和错误文案由 Tutti i18n 提供。
- 协议与 Renderer API 分离:协议包只定义数据;React component props 留在 UI 包。
Adapter 输出的业务文案必须已经按 open 收到的 locale 解析;协议不承载 i18n message
key。声明式配置由通用 Adapter 选择 locale,自定义 Adapter 自行选择;缺少目标语言时使用
Connector 的默认语言,Tutti-owned 文案仍由宿主 i18n 生成。
5. Authorization View V1
5.1 Envelope
interface AuthorizationViewEnvelopeV1 {
protocol: "tutti.connector.authorization.view.v1";
viewId: string;
view: AuthorizationViewV1;
}
viewId 是 Connector 为当前 UI 帧生成的不透明标识:
- 在同一次授权流程内必须唯一;
- view 的交互语义发生变化时必须生成新值;
- Renderer 返回事件时原样回传;
- Controller 只接受与当前
viewId相同的事件; - 它不是服务端 session ID、Market revision 或幂等键;
- 不得包含账号、token、路径或其他敏感信息。
授权流程实例 ID 由 Controller 内部管理,不属于 Connector/Renderer 协议。
5.2 View union
type AuthorizationViewV1 =
| AuthorizationFormViewV1
| AuthorizationExternalLinkViewV1
| AuthorizationDeviceCodeViewV1
| AuthorizationQrCodeViewV1
| AuthorizationProgressViewV1
| AuthorizationResultViewV1;
interface AuthorizationViewBaseV1 {
type: string;
title?: string;
description?: string;
}
所有文字按纯文本渲染。description 不支持 Markdown、HTML 或内嵌链接;链接必须使用
结构化 helpLinks。
5.3 Form view
interface AuthorizationFormViewV1 extends AuthorizationViewBaseV1 {
type: "form";
helpLinks?: AuthorizationHelpLinkV1[];
fields: AuthorizationFieldV1[];
fieldErrors?: Record<string, string>;
submitLabel?: string;
}
interface AuthorizationHelpLinkV1 {
label: string;
url: string;
}
submitLabel 只允许覆盖业务动作,例如“保存并连接”。取消、返回、重试等通用文案由
Renderer 的本地 i18n 提供。fieldErrors 只能引用当前 form 已声明的字段,内容按纯文本
展示;它用于 Connector 完成业务校验后返回错误,不替代 Renderer 的本地基础校验。
5.4 External link view
interface AuthorizationExternalLinkViewV1 extends AuthorizationViewBaseV1 {
type: "external_link";
url: string;
actionLabel?: string;
expiresAt?: string;
}
Renderer 不直接使用 window.open。用户触发后发出 activate 事件,由可信宿主通过统一
external navigation policy 校验并打开 URL。协议不支持 autoOpen;是否在特定受信场景
自动打开由宿主策略决定,Connector 无权指定。
5.5 Device code view
interface AuthorizationDeviceCodeViewV1 extends AuthorizationViewBaseV1 {
type: "device_code";
verificationUrl: string;
userCode: string;
actionLabel?: string;
expiresAt?: string;
}
Renderer 展示和复制 userCode,并通过 activate 请求宿主打开 verificationUrl。复制是
Renderer 本地操作,不向 Connector 发事件。
5.6 QR code view
interface AuthorizationQrCodeViewV1 extends AuthorizationViewBaseV1 {
type: "qr_code";
source:
| {
type: "payload";
value: string;
}
| {
type: "png_base64";
value: string;
};
fallbackText?: string;
expiresAt?: string;
refreshable?: boolean;
}
优先使用 payload,默认 Renderer 在本地编码二维码。只有 Connector 无法获得原始 payload
时才允许 png_base64。V1 不允许远程图片 URL、data URL、SVG 或 HTML:
- payload 最大 4,096 UTF-8 bytes;
- PNG 解码后最大 512 KiB;
- 协议 parser 检查 base64、decoded length、PNG signature 和 IHDR 尺寸;
- 默认 Renderer 在展示前还必须使用受限 decoder 完成实际解码,解码失败安全终止;
- 二维码尺寸、纠错等级、颜色、留白和 Logo 由 Renderer 决定;
fallbackText可以复制,但不得记录;expiresAt仅用于显示倒计时和禁用过期操作,不让 Renderer 自动调用 Connector;refreshable: true时显示本地“刷新”按钮并发出refresh事件。
Connector 负责扫码结果观察。扫码成功后 Connector 主动输出新的 result view,Renderer 不轮询二维码状态。
5.7 Progress view
interface AuthorizationProgressViewV1 extends AuthorizationViewBaseV1 {
type: "progress";
message?: string;
}
progress 只描述展示状态。Connector 在后台工作完成后主动输出下一帧;Renderer 不依据
该 view 启动 timer、poll 或网络请求。Dialog 是否允许关闭由宿主策略控制。
5.8 Result view
interface AuthorizationResultViewV1 extends AuthorizationViewBaseV1 {
type: "result";
outcome: "success" | "failure";
message?: string;
retryable?: boolean;
}
retryable 只对 failure 有效。为 true 时 Renderer 显示本地化重试按钮并发出 retry
事件。Result 是 Connector 的业务结果,不用于表示协议解析失败或 Renderer 异常;后两者由
Tutti 自己的错误 UI 处理。
6. Form field V1
6.1 Field union
type AuthorizationFieldV1 =
| TextAuthorizationFieldV1
| SecretAuthorizationFieldV1
| NumberAuthorizationFieldV1
| SelectAuthorizationFieldV1
| BooleanAuthorizationFieldV1
| LocalFileAuthorizationFieldV1;
interface AuthorizationFieldBaseV1 {
name: string;
label: string;
description?: string;
helpLinks?: AuthorizationHelpLinkV1[];
required?: boolean;
}
字段名必须匹配 ^[a-z][a-z0-9_]{0,63}$,同一 form 内不能重复。Renderer 使用 name
关联值和错误,不将它直接用作 DOM id。
6.2 Text 和 secret
interface TextAuthorizationFieldV1 extends AuthorizationFieldBaseV1 {
type: "text";
format?: "plain" | "email";
placeholder?: string;
defaultValue?: string;
minLength?: number;
maxLength?: number;
}
interface SecretAuthorizationFieldV1 extends AuthorizationFieldBaseV1 {
type: "secret";
placeholder?: string;
minLength?: number;
maxLength?: number;
}
V1 不允许 secret defaultValue。text 默认去除首尾空白;secret 永不自动 trim,避免改变
真实凭证。format: "email" 提供基础格式反馈,但 Connector 仍负责业务校验。
6.3 Number
interface NumberAuthorizationFieldV1 extends AuthorizationFieldBaseV1 {
type: "number";
placeholder?: string;
defaultValue?: number;
minimum?: number;
maximum?: number;
step?: number;
unit?: string;
}
数值必须是有限安全数值,拒绝 NaN、Infinity 和超出 JavaScript safe integer 范围的
整数。step 必须大于 0;step 校验以 minimum ?? 0 为基准,并允许有限浮点误差。
6.4 Select 和 boolean
interface SelectAuthorizationFieldV1 extends AuthorizationFieldBaseV1 {
type: "select";
defaultValue?: string;
options: Array<{
value: string;
label: string;
description?: string;
}>;
}
interface BooleanAuthorizationFieldV1 extends AuthorizationFieldBaseV1 {
type: "boolean";
defaultValue?: boolean;
}
Select option value 在字段内必须唯一;提交值必须来自 options。未勾选 boolean 的规范值是
false,除非 required 语义明确要求用户确认 true。
6.5 Local file
interface LocalFileAuthorizationFieldV1 extends AuthorizationFieldBaseV1 {
type: "local_file";
extensions?: string[];
}
interface AuthorizationLocalFileValueV1 {
type: "local_file";
handleId: string;
}
V1 的 local_file 返回宿主创建的短期 opaque handle,不把原始路径或文件内容放进协议。
Renderer 可以显示经过脱敏的文件名或路径,但事件只携带 handleId。Connector 通过宿主
提供的受限文件能力读取 handle;该能力不属于本协议。
约束:
- handle 绑定当前授权流程,关闭 Dialog 或 view 切换时撤销;
- Connector 只能读取用户明确选择的文件;
- 不允许 Connector 自行解析任意宿主路径;
- 不支持
defaultValue,避免静默读取历史路径; - extensions 最多 20 个,使用规范化的
.json形式; - 宿主读取时重新检查 handle、文件类型、大小和权限,不能只信任选择时检查;
- 实现应避免符号链接切换和选择后替换造成的 TOCTOU;优先使用已打开的只读句柄或安全 快照。
如果某个受信本地 Connector 确实需要路径而不是文件内容,应通过宿主的 Connector
capability 显式解决,不把 path 加回通用 UI 协议。
6.6 字段与 Connector 参数绑定
field.name 是授权交互内部的提交键,Renderer 不维护 credential 映射表。绑定分两层:
声明式授权配置输出 field.name
-> Renderer 以 name 收集 draft
-> submit.values[name]
-> Controller 做结构和类型校验
-> 通用授权 Adapter 按 trusted submission 配置取值
-> 现有 authorization backend / credential binding
声明式配置不属于 Renderer 协议,也不会原样发送给 Renderer。建议在 Connector 的
authorization method 中增加 interaction,由平台通用 Adapter 消费:
interface DeclarativeAuthorizationInteractionV1 {
protocol: "tutti.connector.authorization.declarative.v1";
initialView: {
defaultLocale: string;
locales: Record<string, AuthorizationViewV1>;
};
submission: {
kind: "native_secret";
secretField: string;
};
}
initialView.defaultLocale 必须引用 initialView.locales 中存在的语言项。locale 精确命中时选择
对应 View,未命中时选择 defaultLocale;所有语言项必须保持相同字段 contract。
secretField 只能引用每个语言 View 中类型为 secret 的字段。通用 Adapter 收到 submit 后,
从 values 中取出该字段,并调用现有 native_secret complete 流程;它无权指定 header、host、
endpoint 或文件路径。这些安全敏感的运行时映射继续由 tsh binding 或其他受信 runtime
binding 管理。
以 Figma PAT 为例,Connector package 只需声明配置,不需要 handleFigmaEvent:
{
"protocol": "tutti.connector.authorization.declarative.v1",
"initialView": {
"defaultLocale": "en-US",
"locales": {
"en-US": {
"type": "form",
"title": "Configure Figma",
"submitLabel": "Save and enable",
"fields": [
{
"type": "secret",
"name": "personal_access_token",
"label": "Personal access token",
"required": true
}
]
},
"zh-CN": {
"type": "form",
"title": "配置 Figma",
"submitLabel": "保存并启用",
"fields": [
{
"type": "secret",
"name": "personal_access_token",
"label": "个人访问令牌",
"required": true
}
]
}
}
},
"submission": {
"kind": "native_secret",
"secretField": "personal_access_token"
}
}
运行时通用 Adapter 执行:
personal_access_token
-> submit.values.personal_access_token
-> submission.secretField
-> CompleteAuthorization({ secret })
-> encrypted native_secret
-> trusted runtime binding(例如 X-Figma-Token)
腾讯文档可以沿用同一路径:
{
"protocol": "tutti.connector.authorization.declarative.v1",
"initialView": {
"defaultLocale": "en-US",
"locales": {
"en-US": {
"type": "form",
"title": "Connect Tencent Docs",
"fields": [
{
"type": "secret",
"name": "personal_token",
"label": "Personal Token",
"required": true
}
]
},
"zh-CN": {
"type": "form",
"title": "连接腾讯文档",
"fields": [
{
"type": "secret",
"name": "personal_token",
"label": "Personal Token",
"required": true
}
]
}
}
},
"submission": {
"kind": "native_secret",
"secretField": "personal_token"
}
}
随后仍调用现有 native_secret backend;Authorization header 名称、空 prefix、
docs.qq.com allowlist 和 MCP 验证全部保持在 tsh-binding.json,不重复写入 UI 配置。
为了兼容现有 Tencent Docs manifest,迁移期通用 Adapter 可以把
kind: "api_key" + backend: "native_secret" + secret: {} 转换为一个默认单 secret 表单。
这是集中在 Adapter 内的 legacy 兼容,不允许 Renderer 继续包含
authorizationKind === "api_key" 分支。Connector 想自定义标题、字段名和帮助链接时,应显式
提供 interaction。
V1 的声明式 submission 先只支持 native_secret 单敏感字段。邮箱账号加授权码、多 credential
slot、非敏感 runtime settings 等场景,只有在对应 backend 已定义明确的存储与消费 contract
后才增加新的封闭 union 分支;不能通过通用表达式或任意 binding 对象绕过安全边界。动态
二维码、device code、轮询等流程使用自定义 InteractionPort,但仍输出相同 View/Event 协议。
Connector manifest tooling 可以提供轻量 builder 校验 presentation 和 submission 引用一致; 它不是 Renderer protocol package 的职责:
const figmaAuthorization = defineDeclarativeAuthorization({
protocol: "tutti.connector.authorization.declarative.v1",
initialView: {
defaultLocale: "en-US",
locales: {
"en-US": {
type: "form",
title: "Configure Figma",
fields: [
{
type: "secret",
name: "personal_access_token",
label: "Personal access token",
required: true
}
]
}
}
},
submission: {
kind: "native_secret",
secretField: "personal_access_token"
}
} as const);
这段 TypeScript 只是可选的构建期辅助;发布物可以直接携带等价 JSON。不存在需要宿主下载并 执行的 Connector JS 脚本。Renderer protocol package 仍只导出 View/Event Schema。
Renderer 实际收到的 submit event 仍然只有 UI 数据:
{
"protocol": "tutti.connector.authorization.event.v1",
"viewId": "figma-token-1",
"event": {
"type": "submit",
"values": {
"personal_access_token": "figd_..."
}
}
}
V1 不增加 envName、configPath、headerName 或通用 binding 属性,原因是:
- Renderer 不应该知道 credential 最终落在哪里;
- 远端 Connector、本地 CLI 和 managed broker 的落地方式不同;
- 允许 Connector 通过 UI Schema 指定环境变量、文件路径或 header 会扩大安全权限;
- credential binding 可以独立演进,不迫使 UI 协议升级。
field.name 不是用户可见文案,也不是最终运行时参数名。它只是一次授权交互内的稳定提交
key。声明式配置的 contract test 必须证明 submission 引用的每个 key 都由 form 声明且类型
匹配;自定义 Adapter 必须证明消费字段与 form 声明一致。未知 key 必须拒绝,不能静默丢弃。
7. Authorization Event V1
7.1 Envelope 和 event union
interface AuthorizationEventEnvelopeV1 {
protocol: "tutti.connector.authorization.event.v1";
viewId: string;
event: AuthorizationEventV1;
}
type AuthorizationEventV1 =
| {
type: "submit";
values: Record<string, AuthorizationValueV1>;
}
| { type: "activate" }
| { type: "refresh" }
| { type: "retry" }
| { type: "cancel" };
type AuthorizationValueV1 =
| string
| number
| boolean
| AuthorizationLocalFileValueV1;
7.2 Event 与 view 的合法组合
| 当前 view | 允许事件 |
|---|---|
form |
submit, cancel |
external_link |
activate, cancel |
device_code |
activate, cancel |
qr_code |
refresh(仅 refreshable), cancel |
progress |
cancel |
result/success |
cancel |
result/failure |
retry(仅 retryable), cancel |
validateEventForView(currentView, event) 必须执行以下语义校验:
viewId必须等于 Controller 当前 view;- submit 只能用于 form;
- values 不能包含未知字段;
- required 字段必须存在;
- optional 空值按第 7.3 节规范化;
- value 类型必须与 field type 匹配;
- select value 必须存在于 options;
- 字符串、数值和本地文件必须满足字段限制;
- refresh/retry 只有对应 flag 为 true 才能发送。
7.3 Form 值规范化
- optional text 为空时从 values 中省略;
- required text trim 后为空时报字段错误;
- secret 不 trim,空字符串只在 required 时拒绝;
- optional number 输入为空时省略,不能转换成 0;
- boolean 始终提交明确的 true/false;
- local_file 只提交当前 flow 创建且仍有效的 handle;
- defaultValue 只负责初始化 Renderer draft,用户提交前仍按同一规则校验;
- Renderer 本地字段错误使用稳定内部 code,不使用 Connector 提供的 HTML 或富文本错误。
8. Valibot 组织
8.1 唯一协议实现
Valibot Schema 是 V1 TypeScript 协议定义和运行时校验的单一来源:
完整、可类型检查的参考 Schema 位于
packages/connector/authorization-protocol/src/v1/schema.ts。
它包括全部 View、Field、Event、限制、跨字段检查、推导类型、typed form builder,以及依赖
当前 View 的 validateAuthorizationEventForViewV1。以下代码只展示两个顶层 envelope:
import * as v from "valibot";
export const authorizationViewEnvelopeV1Schema = v.strictObject({
protocol: v.literal("tutti.connector.authorization.view.v1"),
viewId: viewIdSchema,
view: authorizationViewV1Schema
});
export const authorizationEventEnvelopeV1Schema = v.strictObject({
protocol: v.literal("tutti.connector.authorization.event.v1"),
viewId: viewIdSchema,
event: authorizationEventV1Schema
});
export type AuthorizationViewEnvelopeV1 = v.InferOutput<
typeof authorizationViewEnvelopeV1Schema
>;
禁止另外手写一份结构相同的 TypeScript interface 作为协议源。本文代码块用于表达规范, 实现时以 Schema 和推导类型为准。
首期不生成 JSON Schema,原因是该协议没有 tsh-server 或其他语言运行时消费者。未来只有 出现明确的跨语言 Connector SDK 消费者时,才评估从同一来源生成发布 artifact;不能手写 第二份 Schema。
8.2 Parser
type AuthorizationProtocolParseResult<T> =
| { ok: true; value: T }
| { ok: false; error: AuthorizationProtocolError };
function parseAuthorizationView(
input: unknown
): AuthorizationProtocolParseResult<AuthorizationViewEnvelopeV1>;
function parseAuthorizationEvent(
input: unknown
): AuthorizationProtocolParseResult<AuthorizationEventEnvelopeV1>;
Parser 负责:
- 按完整 protocol literal 路由版本;
- 执行 strict structural validation;
- 执行数组长度、字符串长度、URL、时间、base64、PNG header 和尺寸上限;
- 执行字段名、select option 和跨字段约束;
- 将 Valibot issue 转为稳定错误 code;
- diagnostics 只保留字段路径、code 和协议版本,不保留原始值。
建议上限:
| 项目 | V1 限制 |
|---|---|
| 单个 form 字段 | 16 |
| select options | 每字段 100 |
| title/label/actionLabel | 128 Unicode 字符 |
| description/message | 1,024 Unicode 字符 |
| placeholder/unit | 128 Unicode 字符 |
| 用户文本输入 | 每字段最大 8,192 字符 |
| helpLinks | view 最多 8 个、field 最多 4 个 |
| URL | 最大 2,048 字符,https:;开发策略可允许 loopback http: |
| QR payload | 4,096 UTF-8 bytes |
| QR PNG | 解码后 512 KiB,最大 2,048 × 2,048 |
| local_file extensions | 20 |
9. Connector 集成与 Headless Controller
协议不规定 Adapter 使用 stdio、进程 RPC、worker 还是内存调用。Market feature 定义一个 transport-neutral port。这个 port 通常由 Tutti 的声明式 profile adapter 实现;只有动态流程 才由 Connector 自定义实现。为避免 request/response 返回值和异步订阅产生新旧 view 竞态, 输出统一为单一、有序的 AsyncIterable:
interface ConnectorAuthorizationInteractionPort {
open(options: {
connectorKey: string;
locale: string;
signal: AbortSignal;
}): AsyncIterable<unknown>;
dispatch(
event: AuthorizationEventEnvelopeV1,
options: { signal: AbortSignal }
): Promise<void>;
close?(): Promise<void>;
}
open返回当前授权 flow 唯一的 view stream,第一项必须是首帧;- stream 内所有 view 按 Adapter 状态提交顺序输出,不能在较新 view 后输出旧 view;
dispatch只确认事件已被 Adapter 接收,不返回 view;- 简单声明式 adapter 也必须把响应放入同一 stream;
- OAuth、扫码和等待等异步结果同样进入该 stream;
- 返回值保持 unknown,必须经过 parser 后才能进入状态;
- transport 的认证、超时和进程生命周期不属于 UI 协议。
Controller 状态仅描述本地请求生命周期:
interface AuthorizationControllerState {
view: AuthorizationViewEnvelopeV1 | null;
status: "idle" | "starting" | "ready" | "dispatching" | "failed";
error?: AuthorizationUiError;
}
progress 和 result 是 Connector 输出的业务 view,不重复编码成 controller status。
Controller 独占:
- parse Connector 输出;
- 保存当前 viewId;
- 串行 dispatch,拒绝重复点击和旧 view 事件;
- 单线程消费 Connector view stream,保证 UI 不发生响应/订阅竞态;
- 对 form event 做 view-aware validation;
- 管理 Renderer draft 的 reset key;
- 管理 AbortController 和 Connector view stream;
- 通过宿主能力打开 URL、创建/撤销 local file handle;
- close、view 变化、失败和卸载时清理 secret、object URL 和 file handle;
- 将 transport/protocol 错误映射为 Tutti-owned 安全错误。
activate 的处理顺序是:Controller 校验当前 view 和 URL,通过宿主 external navigation
policy 打开成功后,再向 Connector dispatch activate;用户拒绝或宿主拦截时不通知
Connector 已激活。cancel 在 Dialog 关闭时 best-effort dispatch,随后总是 abort stream、
撤销本地资源并调用 port close,不能因为 Connector 无响应阻止窗口关闭。
Authorization Adapter 独占:
- 授权状态机;
- 网络请求和凭证交换;
- OAuth/device code/扫码结果观察;
- 业务字段校验;
- 决定下一份 view;
- 生成用户安全、可展示的业务失败信息。
默认采用平台通用声明式 Adapter:它读取 Connector 的 interaction 和既有 authorization
profile,生成首帧、验证提交字段,并调用现有 backend。自定义 Adapter 只用于声明式能力无法
表达的动态流程;两者对 Controller 暴露同一个 port,因此 Renderer 无感知。
Renderer 独占:
- 布局和视觉;
- 临时表单 draft;
- 本地基础校验和字段错误关联;
- 倒计时、复制、密码显示/隐藏等展示行为;
- 发出结构化 UI event。
10. 可替换 Renderer
React 接口属于 connector-market/ui,不导出自协议包:
interface AuthorizationRendererProps {
connector: Pick<Connector, "key" | "release">;
view: AuthorizationViewEnvelopeV1;
renderKey: string;
busy: boolean;
error?: AuthorizationUiError;
capabilities: AuthorizationRendererCapabilities;
onEvent(event: AuthorizationEventEnvelopeV1): void;
}
type AuthorizationRenderer = React.ComponentType<AuthorizationRendererProps>;
interface AuthorizationRendererCapabilities {
selectLocalFile(options: { extensions?: string[] }): Promise<{
handleId: string;
displayName: string;
} | null>;
}
renderKey 是 Controller 创建的 flowInstanceId + viewId 不透明组合,只用于强制清理
Renderer subtree,不进入协议。selectLocalFile 是唯一需要暴露给 Renderer 的宿主能力:
宿主负责弹出系统选择器、创建 flow-scoped handle,并只返回用于展示的脱敏文件名。
Renderer 不能自行构造 handle。打开外部 URL 仍由 Controller 处理,不作为 Renderer
capability 暴露。
整体替换只能在可信宿主组合根注入:
<ConnectorMarketDialogHost
authorizationRenderer={CustomAuthorizationRenderer}
/>
Connector manifest 和 view 均不能指定 Renderer。
默认 Renderer 内提供两级 registry:
interface AuthorizationViewRendererMap {
form: AuthorizationViewRenderer<AuthorizationFormViewV1>;
external_link: AuthorizationViewRenderer<AuthorizationExternalLinkViewV1>;
device_code: AuthorizationViewRenderer<AuthorizationDeviceCodeViewV1>;
qr_code: AuthorizationViewRenderer<AuthorizationQrCodeViewV1>;
progress: AuthorizationViewRenderer<AuthorizationProgressViewV1>;
result: AuthorizationViewRenderer<AuthorizationResultViewV1>;
}
interface AuthorizationFieldRendererMap {
text: AuthorizationFieldRenderer<TextAuthorizationFieldV1>;
secret: AuthorizationFieldRenderer<SecretAuthorizationFieldV1>;
number: AuthorizationFieldRenderer<NumberAuthorizationFieldV1>;
select: AuthorizationFieldRenderer<SelectAuthorizationFieldV1>;
boolean: AuthorizationFieldRenderer<BooleanAuthorizationFieldV1>;
local_file: AuthorizationFieldRenderer<LocalFileAuthorizationFieldV1>;
}
宿主可以传入 Partial<AuthorizationViewRendererMap> 或
Partial<AuthorizationFieldRendererMap> 覆盖局部实现。未知类型在 parser 阶段已经拒绝,
不能静默跳过 required 字段。
默认实现使用 @tutti-os/ui-system 的公开组件、图标和 token。字段 Renderer 只接收字段
定义、当前值、字段错误和 onChange;不接收 Controller、Connector port 或 backend。
Dialog 必须以 renderKey 作为 Renderer 的 React key。view 改变时强制销毁旧 subtree,
确保 secret draft、倒计时、临时 object URL 和 field-local state 被清理。
11. 典型场景
11.1 邮箱和 IMAP/SMTP 授权码
{
"protocol": "tutti.connector.authorization.view.v1",
"viewId": "mail-credentials-1",
"view": {
"type": "form",
"title": "邮箱授权",
"description": "输入邮箱地址和 IMAP/SMTP 授权码(非登录密码)",
"helpLinks": [
{
"label": "如何开启 IMAP/SMTP 并获取授权码",
"url": "https://example.com/mail-help"
}
],
"submitLabel": "保存并连接",
"fields": [
{
"type": "text",
"format": "email",
"name": "email",
"label": "邮箱地址",
"required": true
},
{
"type": "secret",
"name": "authorization_code",
"label": "IMAP/SMTP 授权码",
"required": true
}
]
}
}
11.2 Figma PAT 完整流程
Figma PAT 与腾讯文档 Personal Token 同属单 secret 场景,应完全走声明式配置,不发布 Connector 专属授权 JS。Connector manifest 的 authorization method 在现有 profile 上增加 presentation 和 submission 配置:
{
"kind": "api_key",
"methods": [
{
"profileId": "figma-personal-token",
"profileVersion": "1",
"method": "api_key",
"displayName": "Connect Figma",
"backend": "native_secret",
"secret": {},
"interaction": {
"protocol": "tutti.connector.authorization.declarative.v1",
"initialView": {
"defaultLocale": "en-US",
"locales": {
"en-US": {
"type": "form",
"title": "Configure Figma",
"description": "Enter a Figma Personal Access Token",
"submitLabel": "Save and enable",
"fields": [
{
"type": "secret",
"name": "personal_access_token",
"label": "Personal access token",
"required": true
}
]
},
"zh-CN": {
"type": "form",
"title": "配置 Figma",
"description": "输入 Figma Personal Access Token",
"submitLabel": "保存并启用",
"fields": [
{
"type": "secret",
"name": "personal_access_token",
"label": "个人访问令牌",
"required": true
}
]
}
}
},
"submission": {
"kind": "native_secret",
"secretField": "personal_access_token"
}
}
}
]
}
配置加载后,通用声明式 Adapter 为本次 flow 生成 viewId,输出 Renderer 协议帧:
{
"protocol": "tutti.connector.authorization.view.v1",
"viewId": "figma-token-1",
"view": {
"type": "form",
"title": "配置 Figma",
"description": "输入 Figma Personal Access Token",
"submitLabel": "保存并启用",
"fields": [
{
"type": "secret",
"name": "personal_access_token",
"label": "个人访问令牌",
"required": true
}
]
}
}
Renderer 不理解 Figma,只按 field.name 生成 submit event:
{
"protocol": "tutti.connector.authorization.event.v1",
"viewId": "figma-token-1",
"event": {
"type": "submit",
"values": {
"personal_access_token": "figd_..."
}
}
}
通用 Adapter 校验事件后读取 submission.secretField,调用现有 native_secret 授权完成接口。
验证、加密保存与运行时注入继续沿用已有 backend/binding:
personal_access_token
-> submit.values.personal_access_token
-> declarative submission.native_secret
-> existing CompleteAuthorization secret proof
-> encrypted credential storage
-> trusted Figma binding
-> provider request header
这里不存在 Renderer 到具体 header 的直接映射,也没有 Figma 专属
handleFigmaAuthorization。通用 Adapter 只把指定 secret 交给 backend,受信 binding 决定
最终注入位置。验证失败时 Adapter 根据 backend 的结构化错误生成新的 form view 和
fieldErrors;PAT 不能回传为 defaultValue。
timeout_seconds 是 Connector 运行配置,不是授权凭证。首期不把它混入 native_secret
submission;应由独立 settings 配置处理。如果未来要求在同一个 Dialog 提交,必须先定义
一个明确的 credential_and_settings backend contract,再给声明式 submission union 增加对应
分支,不能让 Renderer 直接写 runtime config。
“让 Agent 帮我配置”是 Tutti 宿主能力,不由 Connector 协议下发按钮。宿主需要时在整体 Renderer 外围根据可信 feature flag 添加。
11.3 OAuth JSON 文件
{
"protocol": "tutti.connector.authorization.view.v1",
"viewId": "google-oauth-file-1",
"view": {
"type": "form",
"title": "配置 Google 日历",
"fields": [
{
"type": "local_file",
"name": "oauth_credentials",
"label": "OAuth 凭据文件",
"required": true,
"extensions": [".json"]
}
]
}
}
Renderer 展示文件选择器,提交 opaque handle;协议不传 ~/... 原始路径。
11.4 二维码
{
"protocol": "tutti.connector.authorization.view.v1",
"viewId": "scan-login-1",
"view": {
"type": "qr_code",
"title": "扫码授权",
"description": "请使用目标应用扫描二维码",
"source": {
"type": "payload",
"value": "https://example.com/device/authorize?challenge=opaque"
},
"fallbackText": "https://example.com/device/authorize?challenge=opaque",
"expiresAt": "2026-08-12T12:00:00Z",
"refreshable": true
}
}
默认 Renderer 使用 Tutti 已有的 qrcode-generator 依赖本地编码。共享编码 helper 应放在
适当的 UI 基础层或授权 UI 内部,不能让 Connector Market 反向依赖 Desktop feature。
12. 错误模型
协议边界错误由 Tutti 定义,不允许 Connector 模拟。stale_view 和
invalid_event_for_view 由 Controller 的 view-aware validation 产生,其余错误来自 parser:
interface AuthorizationProtocolError {
code:
| "unsupported_protocol"
| "invalid_envelope"
| "invalid_view"
| "invalid_event"
| "invalid_event_for_view"
| "limit_exceeded"
| "stale_view";
path?: string;
}
业务字段错误由 Connector 在收到 submit 后通过 form view 的 fieldErrors 返回。
fieldErrors 只能引用当前 form 已声明字段,值是安全纯文本。返回错误时 Connector 必须生成
新的 viewId,Controller 销毁旧 draft;如需保留非敏感输入,应由 Connector 在新 view 的
对应字段 defaultValue 中显式返回。Connector 禁止回传 secret defaultValue,因此 secret
失败后必须重新输入。
Transport 错误、Connector 崩溃和 Renderer error boundary 使用 Tutti-owned
AuthorizationUiError,不属于协议。
13. 安全和隐私
- Connector 字符串全部按纯文本展示;
- URL 仅允许 HTTPS,开发策略可以显式允许 loopback HTTP;
- 打开 URL 必须经过宿主 external navigation policy;
- Renderer 不加载 Connector 提供的远程图片、SVG、HTML 或 data URL;
- QR payload、fallbackText、PNG、secret、field values、file handle 和显示路径均不写入 日志、analytics、Valtio snapshot 或持久化;
- secret 只存在于当前 Renderer subtree 的临时 draft;
- view 切换、提交完成、失败、关闭和卸载都清空 secret;
- protocol parse error 不记录原始 payload;
- local file handle 绑定 flow、短期有效、只读、可撤销;
- 自定义 Renderer 只能由可信宿主注入,Connector 无权选择;
- Renderer event 在发送给 Connector 前必须再次进行 view-aware validation;
- Connector 必须把 Renderer 输入视为不可信,并执行最终业务校验;
viewId只防止本地旧 UI 事件错投,不替代 transport 层认证或 Connector 自身幂等性。
14. 版本策略
- V1 使用
strictObject,发布后属性集合和语义冻结; - 修正文档、收紧实现 bug 或增加 fixture 不改变版本;
- 新增可见属性、view type、field type、value type 或事件语义必须发布 V2;
- parser 通过完整 protocol literal 路由,未知版本 fail closed;
- 同一个 UI Controller 可以注册多版本 parser,并归一化到内部 Renderer model;
- V2 只有在需要兼容已发布 V1 Connector 时才引入 normalizer,不提前维护重复 domain type;
- 首期不提供开放
metadata或extensionsbag,避免形成无法校验的隐式协议; - Connector 和 Tutti 的版本协商属于 transport adapter,不属于 UI message 本身。
15. 兼容迁移
阶段 A:协议包
- 新增
@tutti-os/connector-authorization-protocol; - 增加 Valibot dependency、V1 view/event Schema、parser、limits 和 fixtures;
- 不修改 tsh-server;
- 不生成 JSON Schema;
- 暂不发布独立 npm SDK,直到存在明确的外部 Connector 消费需求。
阶段 B:Controller 和默认 Renderer
- 在 Connector Market 增加 interaction port 和 headless controller;
- 增加默认 view/field renderer registry;
ConnectorAuthorizationDialog只组合 Controller 和 Renderer;- 默认 UI 使用
@tutti-os/ui-system; - 整体 Renderer 注入点放在可信宿主 composition root。
阶段 C:声明式 Adapter 与 Connector adoption
- 在 Connector manifest contract 中增加可选
interaction配置;该配置不属于 Renderer protocol package,initialView.locales的每个值复用同一份 View Schema,并由defaultLocale声明未命中时的回退项; - 实现平台通用 declarative Adapter,负责生成
viewId、输出 view、消费 event,并调用现有 authorization backend; - Tencent Docs 和 Figma 单 token 场景只增加配置,不增加 Connector 专属 JS;
- OAuth URL 只有在 provider 实际返回 URL 时映射为
external_link; - API key 只有在 Connector 明确声明表单字段时渲染 form;
- 禁止继续用
authorizationKind === "api_key"猜测 secret 表单; - 只在二维码、device code、轮询等声明式配置不能表达的流程实现自定义 Adapter;
- legacy 与新协议使用独立 Controller,不能伪造 viewId 或假装旧路径支持异步 view。
阶段 D:清理旧 UI 分支
- 新 Connector 全部使用协议后,删除 Dialog 内的凭证类型分支;
- 删除只为旧 Dialog 存在的
secret?和authorizationUrl?UI contract; - 兼容 adapter 的删除以实际 Connector 覆盖率为准,不与 tsh-server 升级绑定。
16. 测试与验收
16.1 Protocol tests
- 每种 view 和 field 的最小/完整有效 fixture;
- 未知协议、view、field、event 和额外属性失败;
- 字符串、数组、URL、base64、PNG 和时间边界;
- 重复 field name、重复 select value 和非法 defaultValue 失败;
- submit 缺少 required、出现未知字段或类型不匹配失败;
- stale viewId、非法 activate/refresh/retry 组合失败;
- parser error 不包含输入值。
- declarative contract fixture 证明 form 字段与 submission 引用完全一致;自定义 Adapter fixture 证明声明字段与消费字段完全一致。
16.2 Controller tests
- open stream 的首帧必须 parse 成功;
- dispatch 串行且 busy 时重复点击不重复发送;
- stream 中每一份 unknown view 都重新 parse,且消费顺序不倒退;
- 旧 view 事件被本地拒绝;
- view 切换和 close 撤销 stream、file handle 和 draft;
- dispatch 不返回 view,Controller 保持当前 view,直到 stream 推送下一帧;
- transport/protocol/Connector failure 映射为安全 UI error;
- legacy 与 interaction controller 不共享假状态。
16.3 Renderer tests
- V1 所有 view 和 field 都有默认 Renderer;
- 邮箱、PAT、OAuth JSON 文件和二维码 case 可完整渲染/提交;
- QR payload 本地生成,PNG 校验失败时安全失败;
- QR 过期后禁用并只在 refreshable 时显示刷新;
- secret 不 trim、不持久化,view 改变时清空;
- local_file 只提交 opaque handle;
- whole/view/field 三层替换均有效;
- 自定义 Renderer 不获得 Connector port 或 backend;
- label、焦点、键盘提交、错误关联和 loading 状态满足可访问性要求。
16.4 工程验证
实施后至少运行:
pnpm --filter @tutti-os/connector-authorization-protocol test
pnpm --filter @tutti-os/connector-authorization-protocol typecheck
pnpm --filter @tutti-os/connector-authorization-protocol build
pnpm --filter @tutti-os/connector-market test
pnpm --filter @tutti-os/connector-market typecheck
pnpm --filter @tutti-os/connector-market build
pnpm check:changed
记录引入 Valibot 和默认 Renderer 前后的实际 bundle diff。Valibot 只能出现在协议解析边界, 不得被表单状态或 UI component 直接依赖。
17. PR 拆分和完成标准
推荐三个可独立回滚的 PR:
- protocol:workspace package、Valibot Schema、parser、limits、fixtures;
- renderer:interaction port、Controller、默认 Renderer、注入点和 legacy Controller;
- declarative adoption:通用 Adapter、manifest interaction schema、Tencent Docs/Figma 配置迁移;动态流程再按需接入自定义 Adapter。
方案完成标准:
- tsh-server 没有新增协议代码或检测机制;
- 协议包没有 React、UI System、Market 或 transport dependency;
- ConnectorAuthorizationDialog 不再根据 Connector 类型决定字段;
- Renderer 不调用 Connector API,只发结构化 event;
- 声明式和自定义 Adapter 可以通过同一协议覆盖 form、URL、device code、QR、progress 和 result;
- 更换整体 Renderer 不需要改 Connector;
- 增加一个已有 V1 field/view 的 Connector 不需要修改 Dialog;
- 所有 unknown Adapter 输出在渲染前 fail closed。