From bda6fea4c4764b3e512cffd8e4e3a3e2e3959a4d Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Thu, 27 Aug 2026 19:11:30 +0800 Subject: [PATCH 1/8] feat(mcp): add stdio MCP Apps relay Gate MCP Apps with PERI_MCP_APPS, propagate UI capabilities to MCP connections, and add connection-owned resource and tool-call relay with binding leases and canonical HITL dispatch. Co-Authored-By: gpt-5.6-sol --- docs/design/mcp-connector-guide-v2.md | 178 ++++---- docs/design/mcp-multiplexing.md | 299 +++++++++---- peri-acp-types/src/lib.rs | 1 + peri-acp-types/src/mcp_apps.rs | 256 +++++++++++ peri-acp-types/src/mcp_apps_test.rs | 77 ++++ peri-acp-types/src/tools.rs | 22 + peri-acp/src/host/assemble.rs | 46 +- peri-acp/src/host/connection.rs | 95 ++++ peri-acp/src/host/connection_test.rs | 32 ++ peri-acp/src/host/mcp_apps.rs | 208 +++++++++ peri-acp/src/host/mcp_apps_test.rs | 22 + peri-acp/src/host/mod.rs | 98 +++- peri-acp/src/host/requests_test.rs | 1 + peri-acp/src/host/stdio/mod.rs | 11 +- .../host/stdio/run_server_integration_test.rs | 1 + peri-acp/src/host/task_scope.rs | 2 + peri-agent/src/agent/stages/reason.rs | 2 +- peri-agent/src/agent/stages/tool_dispatch.rs | 12 +- peri-agent/src/session/tool_catalog.rs | 2 +- peri-middlewares/src/mcp/apps.rs | 419 ++++++++++++++++++ peri-middlewares/src/mcp/apps_relay.rs | 284 ++++++++++++ peri-middlewares/src/mcp/apps_test.rs | 264 +++++++++++ peri-middlewares/src/mcp/client.rs | 57 ++- peri-middlewares/src/mcp/client/transport.rs | 9 +- peri-middlewares/src/mcp/client_oauth.rs | 2 +- peri-middlewares/src/mcp/client_test.rs | 28 ++ .../src/mcp/dynamic/staged_connection.rs | 10 +- peri-middlewares/src/mcp/initialize.rs | 13 +- peri-middlewares/src/mcp/mod.rs | 8 + peri-middlewares/src/mcp/reconnect.rs | 15 +- peri-middlewares/src/mcp/tool_bridge.rs | 121 ++++- peri-middlewares/src/mcp/tool_bridge_test.rs | 84 ++++ .../issues/2026-08-27-mcp-apps-stdio-relay.md | 144 ++++++ 33 files changed, 2618 insertions(+), 205 deletions(-) create mode 100644 peri-acp-types/src/mcp_apps.rs create mode 100644 peri-acp-types/src/mcp_apps_test.rs create mode 100644 peri-acp/src/host/connection.rs create mode 100644 peri-acp/src/host/connection_test.rs create mode 100644 peri-acp/src/host/mcp_apps.rs create mode 100644 peri-acp/src/host/mcp_apps_test.rs create mode 100644 peri-middlewares/src/mcp/apps.rs create mode 100644 peri-middlewares/src/mcp/apps_relay.rs create mode 100644 peri-middlewares/src/mcp/apps_test.rs create mode 100644 spec/issues/2026-08-27-mcp-apps-stdio-relay.md diff --git a/docs/design/mcp-connector-guide-v2.md b/docs/design/mcp-connector-guide-v2.md index 67760970..57969a23 100644 --- a/docs/design/mcp-connector-guide-v2.md +++ b/docs/design/mcp-connector-guide-v2.md @@ -63,29 +63,30 @@ flowchart LR A["MCP App(ui:// 资源)"] subgraph AGENT["peri-agent —— MCP 连接持有者(client)"] MC["MCP Client
tools/list · tools/call · resources/read · subscriptions/listen"] - HOST["App Host 逻辑
握手校验 · 工具路由 · pending calls"] + RELAY["Apps relay ports
raw metadata/result · capability profile"] end subgraph ACP["peri-acp —— ACP 服务层(数据出口)"] SESS["Session / 事件映射"] ST["StdioTransport · MpscTransport"] end - subgraph VIEW["view 层(UI 界面)"] - TUI["peri-tui
TUI 版 view"] - WEB["web UI 界面
webview + 桥 JS"] + subgraph VIEW["ACP client"] + DOWNSTREAM["下游 UI / Web Host
通过 stdio ACP 消费 Apps 数据"] + TUI["peri-tui
不声明、不消费 Apps capability"] end T <-->|"MCP 原生协议(JSON-RPC)"| MC N <--> MC S <--> MC - A <-->|"resources/read 拉取 HTML"| MC - MC <--> HOST - HOST <-->|"ACP JSON-RPC
peri/app/* 透传帧"| SESS + A <-->|"resources/read"| MC + MC <--> RELAY + RELAY <-->|"Apps ports / DTO"| SESS SESS <--> ST - ST <-->|"ACP stdio 出数据"| WEB - ST <-->|"MpscTransport(内嵌)"| TUI - WEB <-->|"postMessage JSON-RPC(经宿主桥转发)"| A + ST <-->|"ACP stdio envelope"| DOWNSTREAM + ST <-->|"MpscTransport(普通 ACP)"| TUI ``` +下游 Web Host 与 MCP Apps FE 的 iframe/`postMessage` 数据流不属于 Peri,实现细节不在本拓扑展开。 + ### 2.3 对外契约与能力索引 **对外人员的唯一契约**:实现标准 MCP server(stdio 或 Streamable HTTP 传输),按需暴露下列能力;互通由 peri 侧(MCP client + ACP 通道)完成,外部无需感知 peri 内部结构。 @@ -409,42 +410,49 @@ MCP Apps 是 MCP `2026-07-28` 规范中 **Extensions 框架的官方扩展**(S ### 6.1 核心机制 1. **资源形态**:server 以 `ui://` scheme 的 resource 发布 HTML(MIME `text/html;profile=mcp-app`)。 -2. **工具绑定**:`tools/list` 返回的工具携带 `_meta.ui.resourceUri`(如 `ui://get-time/mcp-app.html`),host 据此把 App 关联到工具调用结果上。 -3. **渲染与通信**:host 拉取 HTML 后在隔离 iframe 渲染;App 内脚本通过 postMessage 与 host 走 JSON-RPC(`ui/initialize` 握手后,可发 `tools/call` 回调 server、收 `toolresult` 事件)。 -4. **安全模型**:iframe 沙箱 + CSP(默认同源,`connectDomains` / `resourceDomains` 白名单),opaque origin、无 cookie 继承,权限(camera 等)默认全禁。 +2. **工具绑定**:`tools/list` 返回的工具携带 `_meta.ui.resourceUri`;同时处理 `_meta.ui.visibility`,默认可同时面向 model 与 app。 +3. **能力方向**:当前进程在 MCP pool prewarm 前读取一次 `PERI_MCP_APPS`;变量只看是否存在,值不解析(包括空串和 `0`)。存在时,所有初始连接和重连都在 MCP `initialize` 中传播 `io.modelcontextprotocol/ui`;不存在时完全关闭。该 deployment profile 与 Apps `appCapabilities`/`hostCapabilities` 是不同层次。 +4. **渲染与通信**:下游 Web Host 拉取 HTML 并自行决定如何渲染与通信;Peri 不实现 iframe、sandbox、CSP、Permissions Policy 或 `postMessage`。 +5. **工具结果**:Host/model 发起的调用由下游按 Apps 规范投影为 `ui/notifications/tool-input*` 与 `ui/notifications/tool-result`;App 发起的 `tools/call` 使用匹配原始 id 的标准 JSON-RPC response。Peri 保留 `content`、`structuredContent`、`_meta`、`isError`。 +6. **安全模型**:下游负责 iframe/CSP/权限执行;Peri 负责 server/session/tool 归属、visibility、connection capability 和既有权限/HITL 边界。 -### 6.2 架构:iframe · 宿主页面 · WebServer · ACP Agent 的关系与透传 +### 6.2 架构:下游 Web Host · MCP server · Peri deployment capability 的关系 -四者的关系图: +Web Host/iframe 仅为下游消费方,本仓库不实现。MCP Apps/UI capability 来自进程启动环境中的 `PERI_MCP_APPS`,不是 ACP capability: ```mermaid flowchart LR - subgraph VIEW["view 层 · Host WebSite / webview"] - IFRAME["MCP App iframe
(沙箱 · opaque origin
CSP 默认同源 · 无 cookie 继承
权限默认全禁)"] - BRIDGE["桥 JS(宿主侧镜像)
postMessage ⇄ JSON-RPC"] - end - subgraph AGENT["ACP Agent(peri-agent + peri-acp)"] - HOST["App Host 逻辑
握手校验 · 工具路由 · pending calls"] - CLIENT["MCP Client
tools/call · resources/read"] - ACP["ACP 通道(数据出口)"] - end - WS["MCP WebServer
(ui:// HTML · tools)"] - - IFRAME <-->|"postMessage JSON-RPC
ui/initialize · tools/call · toolresult"| BRIDGE - BRIDGE <-->|"ACP 透传帧
(见下方透传理念)"| HOST - HOST <--> CLIENT - CLIENT <-->|"MCP 原生协议
Streamable HTTP / stdio"| WS - HOST <--> ACP + ENV["PERI_MCP_APPS\n存在即启用"] + P["Peri\nACP stdio + MCP client"] + S["MCP server\nui:// resource · tools"] + H["下游 Web Host\n消费 Peri ACP 数据"] + ENV -->|"进程启动时冻结 deployment profile"| P + P -->|"初始连接与重连\n传播 UI capability"| S + P <-->|"ACP envelope\nresource/result/app data"| H + P <-->|"MCP JSON-RPC\ntools/list · resources/read · tools/call"| S ``` -**peri ACP 如何透传**(理念定稿,信封结构与 id 映射见 `docs/design/mcp-multiplexing.md`): +Peri 不创建 iframe、不实现 sandbox/CSP/Permissions Policy、不处理浏览器 `postMessage`,也不实现 MCP Apps FE。下游 Web Host 负责把 Peri 的 ACP 数据接入自己的 Apps host;`peri-tui` 不参与。 + +Peri 侧数据流: + +1. Peri 在 MCP pool prewarm 前读取一次 `PERI_MCP_APPS` 是否存在并冻结 deployment profile;值不解析。 +2. 变量存在时,初始连接和重连均传播 `io.modelcontextprotocol/ui` 及 `text/html;profile=mcp-app`;不存在时使用普通 MCP capabilities。 +3. Peri 从 `tools/list` 发现 `Tool._meta.ui.resourceUri`,通过 `peri/mcp/open` 建立 connection-owned resource binding,再以 `peri/mcp/resource` 获取 `ui://` resource。 +4. Peri 保留 resource 的 `text|blob`/`_meta` 与 `CallToolResult` 的 `content`、`structuredContent`、`_meta`、`isError`。 +5. 下游是否创建 iframe、如何执行 `postMessage`、如何消费 Apps tool notifications,完全由下游 Web Host 决定。 + +### 6.3 Peri ACP 如何透传 + +- **payload 保留 MCP Apps 原始消息**:Peri 解析外层 envelope 进行 server/session 路由校验,但不实现下游 Web Host 的 `postMessage` bridge。 +- **外层方法名统一包装**:`peri/mcp/app` 与 `peri/mcp/resource`;信封分离 `envelopeVersion`、`mcpProtocolVersion`、`appsProtocolVersion`,并携带 `serverId` / `appSessionId` / `resourceUri` 与 Apps payload。 +- **能力开关由环境驱动**:`PERI_MCP_APPS` 存在即启用整个进程的 immutable deployment profile;不再读取或回显 ACP MCP Apps capability。 +- **Web Host 属于下游**:iframe、sandbox、CSP、Permissions Policy、`postMessage` 和 MCP Apps FE 均不在 Peri 实现范围。 +- **工具调用不绕过权限**:App 发起的调用与 agent 发起的 MCP 工具调用共用既有执行路径与 HITL 权限。 -- **payload 保留 MCP 原始消息**:view 侧剥掉信封即得协议原文,与 App 的 postMessage 层直接对接,两端都不需要二次序列化。 -- **外层方法名统一包装**:`peri/mcp/app`(App 交互双向)与 `peri/mcp/resource`(`ui://` 内容读取);信封携带路由元数据(`serverId` / `appSessionId` / `protocolVersion`)与 MCP 原文 payload。原「裸传 MCP 原文」倾向已在定稿中否决(理由见 mcp-multiplexing.md §3.1)。 -- **App 发起的 `tools/call` 与 agent 发起的工具调用共用同一执行路径与 HITL 权限**——透传不绕过安全模型。 -- 已知冲突点(实施前处理):`mcp/oauth_*` 占用 `mcp/` 前缀、`elicitation/create` 与 MCP elicitation 撞名(SEP-1036);参照系 grok-build 选择 `x.ai/mcp/*` 包装(非裸传)。 +### 6.4 关键 JSON-RPC 方法(当前 SDK/实验观察;不是封闭方法全集) -### 6.3 关键 JSON-RPC 方法(实验验证) +MCP Apps 协议会随 capability 扩展增加 `ui/*`、context、link、message、teardown 等交互;下表只列当前研究涉及的代表性消息,不应作为实现层的永久白名单。 | 方向 | 方法 | 说明 | | --- | --- | --- | @@ -452,30 +460,38 @@ flowchart LR | App → Host | `ui/notifications/initialized` | 初始化完成通知 | | App → Host | `tools/call` | App 主动回调 server 工具(参数 `{name, arguments}`) | | App → Host | `ui/notifications/size-changed` | 自适应尺寸通知 | -| Host → App | `toolresult` | 工具结果事件(含 `content`) | -| Host → App | `ui/notifications/host-context-changed` | 主题 / 时区等宿主上下文(可传递暗色主题) | -| Host → App | `ui/notifications/teardown` | 销毁通知 | +| Host → App | `ui/notifications/tool-input-partial` | 初始工具调用的增量输入(可选) | +| Host → App | `ui/notifications/tool-input` | 初始工具调用的完整输入 | +| Host → App | `ui/notifications/tool-result` | 初始工具调用结果;完整保留 `content`、`structuredContent`、`_meta`、`isError` | +| Host → App | `ui/notifications/tool-cancelled` | 初始工具调用被取消 | +| Host → App | `ui/resource-teardown` | Host → App request;需要按原 request id 返回 response | +| Host → App | `ui/notifications/host-context-changed` | 宿主上下文变化 | +| App → Host | `ui/notifications/size-changed` | 尺寸通知 | ```mermaid sequenceDiagram - participant A as MCP App(iframe) - participant H as Host - A->>H: ui/initialize
{appInfo{name,version}, appCapabilities, protocolVersion} - H-->>A: ui/notifications/initialized - H-->>A: ui/notifications/host-context-changed(暗色主题) - A->>H: tools/call {name, arguments} - H-->>A: toolresult {content} - A->>H: ui/notifications/size-changed - H-->>A: ui/notifications/teardown + participant A as MCP App FE(下游) + participant H as Web Host(下游) + A->>H: ui/initialize request + H-->>A: ui/initialize response(hostCapabilities/hostContext) + A->>H: ui/notifications/initialized + H-->>A: ui/notifications/tool-input-partial(可选) + H-->>A: ui/notifications/tool-input + H-->>A: ui/notifications/tool-result 或 tool-cancelled + A->>H: tools/call request + H-->>A: JSON-RPC response(同一 App request id) + H->>A: ui/resource-teardown request + A-->>H: JSON-RPC response ``` -### 6.4 生态与 SDK 现状 +Peri 不实现上述 Web Host ↔ App 的 handshake;Peri 只承载下游选择透传的 payload,并负责自己的 ACP connection capability、MCP server capability propagation、server/session/tool 路由和结果完整性。 +### 6.5 生态与 SDK 现状 - **官方 SDK**:`@modelcontextprotocol/ext-apps`(前端 `App` 类 + server 端 `registerAppTool` / `registerAppResource` helpers),TypeScript。 - **已支持客户端**:ChatGPT、Claude(web / Desktop,本地需隧道)、VS Code(1.100+)、Goose(experimental / draft 实现)、Postman、MCPJam、mcp-use、官方参考实现 `basic-host`。 - **协议版本注意**:`@modelcontextprotocol/sdk` npm latest 仍以 `2025-11-25` 为 `LATEST_PROTOCOL_VERSION`,`2026-07-28` 尚未发布到 npm;ext-apps 的代码路径已是新协议风格(无 session 模式,见 5.5)。 -### 6.5 实验踩坑(`side-projects/mcp-apps`) +### 6.6 实验踩坑(`side-projects/mcp-apps`) 1. **`appInfo.version` 必填**:`new App({ name })` 缺 version,host 侧 zod 校验失败返回 `-32603 InternalError`(错误消息含 `path: ["params","appInfo","version"]`)。构造时必须传 `{ name, version }`。 2. **host 资源缓存**:Goose 把拉到的 HTML 缓存到 `~/.config/goose/mcp-apps-cache/`,改 UI 后必须清缓存(或等缓存过期)才能看到新版。 @@ -544,9 +560,11 @@ sequenceDiagram 4. **技能(可选,第 7 章)**:以 `skill://` 资源暴露 `SKILL.md`(frontmatter + 正文),声明 `capabilities.resources` 即可被 peri 异步发现并注入技能缓存与命令列表(`mcp____`);另提供 **skill 搜索工具**(检索入口,命名含 `skill`,如 `search_skills`)可提升检索直达性——peri 自有 DiscoverMCP 已提供全域查询(server / tool / resource / skill),server 侧搜索工具会作为普通 deferred 工具进索引,不强制。 5. **安全基线**:HITL 确认、工具注解视为不可信、敏感操作提示确认;App 场景遵守 iframe 沙箱 + CSP 默认同源 + 权限默认全禁。 -## 9. peri 内部落地现状与路径 +## 9. peri 内部落地现状与目标路径 -### 9.1 现状 +> 本节严格区分代码事实与目标设计。目标 contract 由 `spec/issues/2026-08-27-mcp-apps-stdio-relay.md` 冻结;在对应契约测试通过前,不得据此声称 relay 已实现。 + +### 9.1 当前代码事实 - MCP client 已进入 peri 主代码(`peri-middlewares/src/mcp/`,基于 rmcp):tools 桥接、资源读取(`mcp_read_resource`)、OAuth 授权、断线重连均已落地;MCP Apps(`ui://` 渲染)仍在 `side-projects/mcp-apps/` 实验。 - **MCP 域查询与技能分发已落地(2026-08-13)**:DiscoverMCP 只读工具(deferred / `meta`,search / list / detail)+ MCP `skill://` 异步发现与命令注入(McpSkillRegistry,session 级,分源合并),形态见 §7.4。 @@ -557,49 +575,31 @@ sequenceDiagram ### 9.2 信道划分(已定稿) -传输层为纯 JSON-RPC 2.0(Request / Notification / Response,无 frame 概念)。现有 ACP 方法名空间:`session/*`、`plugin/*`、`peri/agent_event*`、`mcp/oauth_*`、`marketplace/*`、`elicitation/create` 等。MCP 数据到达 view 的透传通道**设计已定稿**:`docs/design/mcp-multiplexing.md`(单 ACP 连接上的多路数据分离——信封结构、三层 id 映射、App 会话鉴权、分流规则、错误语义、可靠性)。该设计为 **MCP Apps 专属**,MCP Apps 当前搁置(§9.6)——设计定稿但**无实施计划**,实施与否随其评估结果。定稿要点: - -1. 透传的 `payload` **必须保留 MCP 原始消息**(view 侧剥信封即得原文,与 App postMessage 层直接对接)。 -2. **外层方法名统一包装**:`peri/mcp/app`(App 交互,双向)+ `peri/mcp/resource`(`ui://` 内容读取)。信封携带路由元数据(`serverId` / `appSessionId` / `protocolVersion`)。原「倾向裸传 MCP 原文」已否决,理由:方法名空间平铺共享需白名单、`mcp/` 前缀已被 `mcp/oauth_*` 占用、参照系 grok-build 亦为包装(详见 mcp-multiplexing.md §3.1)。 -3. 已知冲突点,实施前需处理:`mcp/oauth_*` 占用 `mcp/` 前缀(透传用 `peri/mcp/` 避让)、`elicitation/create` 与 MCP elicitation 撞名(SEP-1036)。 -4. 参照系:grok-build 选择 `x.ai/mcp/*` 包装(`extensions/mcp.rs`),非裸传。 - -### 9.3 协议层最小增量(peri-acp / MCP client 层) - -- 支持 `resources/read`(拉取 `ui://` HTML)。 -- `tools/list` 透传 `_meta.ui.resourceUri` 到事件链。 -- 订阅场景:2026-07-28 `subscriptions/listen` 已落地(全链路见 9.1);2025-11-25 旧路径(`resources/subscribe` + 直推 list_changed)未实现。 - -### 9.4 agent / 工具系统 +传输层为纯 JSON-RPC 2.0(Request / Notification / Response)。MCP Apps 数据到达下游的 contract 见 `docs/design/mcp-multiplexing.md`:外层 ACP envelope、Apps payload id 与 ACP id 分离、connection-owned App session、错误分层和 lifecycle。 -- 工具执行结果携带 app 标记(`ui.resourceUri`),事件链(tool_call → tool_result)透传到 view。 -- App 发起的 `tools/call` 与 agent 发起的工具调用共用同一执行路径与 HITL 权限(架构图与透传理念见 6.2)。 +1. Apps `payload` 保留 JSON-RPC 语义和下游 request id;Peri 将允许的方法映射到 MCP client API,不做字节级 server 透传。 +2. 外层使用 `peri/mcp/app` 与 `peri/mcp/resource`;信封携带 `envelopeVersion`、`serverId`、`appSessionId`、`resourceUri`,并分离 `mcpProtocolVersion` 与 `appsProtocolVersion`。 +3. `appSessionId` 必须由 ACP connection owner、server generation、resource/tool binding 共同约束。 +4. 已知方法名冲突通过 `peri/mcp/` 命名空间隔离;下游未知 Apps 方法与字段按版本化 contract 保留。 -### 9.5 view 层落点:TUI 集成建议 +### 9.3 协议层目标最小增量(尚未实现) -**总体判断**:协议层值得做,渲染层分档做。MCP Apps 协议本身很薄(7 个 JSON-RPC 方法 + 1 个资源 MIME),成本集中在「宿主渲染与桥接」。 +以下为 active spec 的目标,不是当前代码事实: -TUI 没有 DOM / iframe 渲染能力,纯文本终端无法直接渲染 HTML App,因此有三条路线: +- 支持受 capability/profile 约束的 `resources/read`(拉取 `ui://` HTML)。 +- raw tool metadata、resource item 与 `CallToolResult` 经 relay 保留标准字段和未知 `_meta`;不得提前压成文本。 +- 订阅场景:2026-07-28 `subscriptions/listen` 已落地(全链路见 9.1);2025-11-25 旧路径(`resources/subscribe` + 直推 list_changed)未实现。 -| 方案 | 做法 | 成本 | 收益 | -| --- | --- | --- | --- | -| A. webview 弹出面板(推荐) | 工具结果带 `_meta.ui.resourceUri` 时,TUI 弹出 webview 窗口渲染;注入 JS 桥实现 App ↔ TUI JSON-RPC | 中(协议薄、webview 集成为主) | 完整兼容官方 App 生态 | -| B. MCP-UI 元素子集渲染 | 渲染 `chart` / `button` / `link` 等结构化 JSON 元素(ratatui 原生绘图) | 低-中 | 无 webview 依赖,但只覆盖简单 widget | -| C. ASCII / 文本降级渲染 | 解析 HTML 摘录文本 | 中高且效果差 | 不推荐 | +### 9.4 Agent / 工具系统目标(尚未实现) -**webview 方案要点**: +- 经验证的 agent 初始 tool invocation 才能创建 App session;完整 tool input 恰好一次,随后 `tool-result XOR tool-cancelled` 恰好一个 terminal。 +- App 发起的 `tools/call` 目标为复用 session-local effective view、canonical dispatch 与既有 Permission/HITL seam;在对应测试通过前不得视为已接通。 -- `webview` crate(macOS WKWebView / Linux webkit2gtk / Windows WebView2)。 -- 加载前注入桥 JS(postMessage ↔ TUI 进程 JSON-RPC 转发,即 ext-apps `PostMessageTransport` 的宿主侧镜像)。 -- App 生命周期状态机:discover → open → handshake → active → teardown。 -- 安全基线对齐官方模型:opaque origin、CSP 默认同源、权限默认全禁、禁止 cookie 继承。 -- `ui/initialize` 成功后发送 `host-context-changed` 传递暗色主题。 +### 9.5 下游 Web Host 边界 -**成本评估**: +Web Host、iframe、sandbox、CSP、Permissions Policy、`postMessage` bridge 与 MCP Apps FE 均由下游实现,Peri 和 `peri-tui` 不实现这些能力。Peri 只提供 connection-scoped capability gate、MCP capability profile、resource/result DTO 和双向 ACP envelope。 -- 协议层约 1–2 周(纯 JSON-RPC + 事件路由,可完全单测)。 -- webview 集成约 2–4 周(主要成本在平台差异与焦点 / 布局)。 -- 收益:与 ChatGPT / Claude / VS Code / Goose 生态协议对齐,一次实现,全端通用。 +下游实现可自行选择 Web、IDE webview 或其他渲染容器;这些选择不能反向改变 Peri 的安全与传输 contract。 ### 9.6 MCP 能力支持度矩阵(2026-08-14 核查) @@ -617,13 +617,13 @@ peri 作为 MCP client,对照 2026-07-28 协议能力面的支持度与路线 | Roots | ❌ 未实现 | **不做(永远)** | 不向 server 暴露工作目录 | | Tasks(正式扩展) | ❌ 未实现 | **不做(永远)** | rmcp 模型齐全,不接 | | Elicitation(SEP-1036 正式扩展) | ❌ 未实现(默认 Decline) | **搁置(可能做)** | 与 ACP `elicitation/create` 撞名(§9.2) | -| MCP Apps(正式扩展) | ❌ 未实现 | **搁置(可能做)** | 设计定稿 `mcp-multiplexing.md`(§9.2),**无实施计划**;前置:能力声明、`ui.resourceUri` 透传 | +| MCP Apps(正式扩展) | ❌ 未实现 | **本次设计范围:Peri stdio relay;Web Host 不做** | 前置:ACP capability gate、条件 MCP capability、raw resource/result DTO、connection-owned session、HITL seam | | WebSocket 传输 | ❌ 未接 | **隔离**(不接入主链路) | rmcp 支持(`ws.rs`);peri 仅 stdio + HTTP,需要时走独立路径 | | Progress / Cancelled 通知 | ❌ 未处理 | 不做 | 长任务进度、优雅取消不可见 | | Logging(`logging/message` / `set_level`) | ❌ 未处理 | 不做 | | | Resource templates / complete | ❌ 未接 | 不做 | | -**决策记录**:Prompts / Sampling / Roots / Tasks 明确**永远不会做**(2026-08-14);Elicitation 与 MCP Apps 搁置待评估;WebSocket 传输隔离;其余未支持项不做。 +**决策记录**:Prompts / Sampling / Roots / Tasks 明确不做(2026-08-14);MCP Apps 当前仅设计 Peri stdio relay,Web Host/iframe 永久留给下游;WebSocket transport 维持隔离。 ## 10. 参考 diff --git a/docs/design/mcp-multiplexing.md b/docs/design/mcp-multiplexing.md index 5e4aa123..0b0f8f95 100644 --- a/docs/design/mcp-multiplexing.md +++ b/docs/design/mcp-multiplexing.md @@ -2,9 +2,9 @@ > 本文件是「外部 MCP server ↔ view 层」透传信道的设计定稿,回答一个问题:**ACP 只有一条连接,多个 MCP server 的数据(App 交互、工具结果、通知)如何在这条信道上分离路由,保证数据正确送达正确的接收方。** > -> 最后核对:2026-08-14 -> 状态:**设计定稿但无实施计划**——本文为 MCP Apps 专属设计(guide §6、§9.2);MCP Apps 当前搁置(guide §9.6 决策),实施与否随其评估结果 -> 关联文档:`docs/design/mcp-connector-guide-v2.md`(MCP 生态定位,§6 MCP Apps、§9 内部落地、§9.6 支持度矩阵);`docs/design/peri-acp-protocol.md`(ACP 协议) +> 最后核对:2026-08-27 +> 状态:**目标设计,实施中但尚未成为代码事实**——最小 ACP stdio relay 的 active spec 为 `spec/issues/2026-08-27-mcp-apps-stdio-relay.md`;在对应契约测试通过前,本文描述的 Apps capability、envelope、session 与 relay 均不得视为已实现 +> 关联文档:`docs/design/mcp-connector-guide-v2.md`(MCP 生态定位,§6 MCP Apps、§9 内部落地);`docs/design/peri-acp-protocol.md`(ACP 协议) > 本文是设计说明,不是规范;不搬运规范原文。 ## 目录 @@ -25,20 +25,22 @@ ### 1.1 要解决的问题 -- ACP 连接**只有一条**(TUI ↔ ACP Server 为 `MpscTransport`,外部 IDE 为 `StdioTransport`),传输层是纯 JSON-RPC 2.0(Request / Notification / Response)。 -- 需要在这条信道上承载**多个 MCP server** 的数据:MCP App(SEP-1865)的 `ui/*` 握手与 `tools/call` 回调、`ui://` 资源读取、Host → App 的推送(`host-context-changed` / `teardown`)。 -- 目标:**任意时刻、任意并发 App 之间的数据互不串扰**——一个 App 发起的 `tools/call` 的结果只回到该 App,且只能调用**它所属 server** 的工具。 +- ACP 连接只有一条(下游 UI ↔ ACP Server 为 `StdioTransport`),传输层是纯 JSON-RPC 2.0(Request / Notification / Response)。`peri-tui` 不属于本设计范围。 +- 需要在 stdio ACP 信道上承载多个 MCP server 的 Apps 数据:`PERI_MCP_APPS` 在进程启动时存在则启用 deployment capability,并向 MCP server 传播 UI extension。 +- 目标:**任意时刻、任意并发 App 之间的数据互不串扰**——一个下游 UI session 发起的 `tools/call` 的结果只回到该 session,且只能调用它所属 server 的工具。 ### 1.2 范围 -- **在**:透传帧的信封结构、id 映射、会话注册与鉴权、分流规则、错误语义、可靠性。 -- **不在**:MCP Apps 协议本身(`ui/initialize` 等 7 个方法,见 guide §6.3);iframe 渲染与桥 JS 内部实现细节;HITL 权限机制(共用现有执行路径,不重新设计)。 +- **在**:环境 deployment capability、MCP server capability 传播、透传帧、id 映射、会话绑定、错误语义与可靠性。 +- **不在**:MCP Apps 协议本身(`ui/initialize` 等代表性方法,见 guide §6.3);Web Host/iframe 渲染、sandbox、CSP、Permissions Policy、`postMessage` bridge 及 MCP Apps FE 内部实现;HITL 权限机制(共用现有执行路径,不重新设计)。 ### 1.3 设计原则(沿用 guide §6.2/§9.2 既有结论) -1. **payload 保留 MCP 原始消息**:view 侧剥掉信封即得协议原文,与 App 的 postMessage 层直接对接,两端都不二次序列化。 +1. **payload 保留 MCP Apps 原始消息**:下游 Web Host 可将 payload 交给其自己的 Apps bridge;Peri 不解析或实现 `postMessage`,但必须解析外层 envelope 并执行 server/session 路由校验。 2. **透传不绕过安全模型**:App 发起的 `tools/call` 与 agent 发起的工具调用共用同一执行路径与 HITL 权限。 -3. **view 不接触 MCP**:view 不持有 MCP 连接、不猜测协议版本;所有 MCP 侧信息由 agent 侧填充。 +3. **能力由 deployment 环境驱动**:`PERI_MCP_APPS` 只看是否存在;存在时整个进程的初始 MCP 连接和重连声明 UI capability,不存在时完全关闭。 +4. **下游 Web Host 不属于 Peri**:iframe、sandbox、CSP、Permissions Policy、`postMessage`、App FE 生命周期由下游实现;Peri 只提供 ACP 数据与协议 contract。 +5. **view 不接触 MCP connection**:下游只通过 ACP 获取 Peri 投影的数据,不持有 Peri 的 MCP peer、stdio handle 或 server credentials。 ## 2. 信道现状(代码事实) @@ -60,18 +62,18 @@ | # | 决策点 | 选择 | 理由 | | --- | --- | --- | --- | | D1 | 外层方法名 | **包装**:`peri/mcp/app`、`peri/mcp/resource`(否决定稿前 guide §9.2 的「倾向裸传」) | 见 3.1 | -| D2 | 信封字段 | `serverId` + `appSessionId` + `protocolVersion` + `payload` | 见第 4 章 | -| D3 | id 空间 | 三层(App 原始 id / ACP 传输层 id / MCP server id),**payload.id 恒为 App 原始 id** | 见第 5 章 | -| D4 | 请求-响应关联 | 外层 request 用传输层 id(`RequestRouter` 机制),agent 侧维护 `server 请求 → (appSessionId, 原始 id)` 映射 | 见 5.2 | -| D5 | App 会话 | agent 侧进程级注册表 `appSessionId → {serverId, resourceUri, state}`,握手时创建 | 见第 6 章 | -| D6 | 防双写 | agent 发起的调用走事件链;App 发起的调用只走透传 | 见第 7 章 | -| D7 | 错误分界 | envelope 校验错用 ACP 层错误码(`-32000` 系列);MCP 协议错误原样透传 | 见第 8 章 | -| D8 | 背压 | App 事件天然低频 + 同类通知 coalesce;不做硬隔离(列为未决) | 见 9.2 | -| D9 | 协议版本 | `protocolVersion` 由 agent 侧从 server 协商结果填充,view 不猜 | 见 9.3 | +| D2 | 信封字段 | `envelopeVersion` + `serverId` + `appSessionId` + `resourceUri` + `mcpProtocolVersion`(可选)+ `appsProtocolVersion`(由 payload/下游协商)+ `payload` | 见第 4 章 | +| D3 | id 空间 | ACP 外层 transport id 与 payload 内 Apps id 分离;MCP server id 由 MCP client 管理,不假设 Peri 可见 | 见第 5 章 | +| D4 | 请求-响应关联 | ACP request 用 transport id;payload 保留 Apps request id;Peri 不复制 MCP client 的内部 pending map | 见 5.2 | +| D5 | App 会话 | connection-owned 注册表 `appSessionId → {connectionId, serverId, resourceUri, bindings, state}` | 见第 6 章 | +| D6 | 防双写 | agent 发起的调用走下游结果通知;App 发起的调用只走对应 JSON-RPC response | 见第 7 章 | +| D7 | 错误分界 | envelope/routing error 与 payload 内 JSON-RPC error 分层表达,不根据 `-320xx` 范围猜测来源 | 见第 8 章 | +| D8 | 背压 | App 事件天然低频 + 同类通知 coalesce;具体队列策略待实现时验证 | 见 9.2 | +| D9 | 协议版本 | 分离 ACP、envelope、MCP core、MCP Apps 四类版本;不以 server core version 替代 Apps version | 见 9.3 | ### 3.1 D1:为什么包装,而不是裸传(对 §9.2 的修订) -guide §9.2 原方案倾向「外层方法名直接用 MCP 原文(`toolresult`、`ui/initialize`)」。定稿改为**包装一层**,理由基于代码事实: +guide §9.2 原方案倾向外层直接使用 Apps 方法名。定稿改为**包装一层**,理由基于代码事实: 1. **方法名空间平铺共享**:`session/*`、`plugin/*`、`marketplace/*`、`mcp/oauth_*` 都在同一信道。裸传 `tools/call`、`ui/initialize` 与 ACP 原生方法名无结构性区分——view 侧桥 JS 必须维护一份「MCP 方法名白名单」才能分辨「剥信封」与「正常处理」,而白名单会随 MCP 规范演进失效。 2. **`mcp/` 前缀已被占用**:`mcp/oauth_*` 占用了 `mcp/` 前缀(guide §6.2 已知冲突点)。裸传方案必须与 `mcp/oauth_*` 共存或改名,包装用 `peri/mcp/` 天然避让。 @@ -86,21 +88,26 @@ guide §9.2 原方案倾向「外层方法名直接用 MCP 原文(`toolresult` ```jsonc { - "method": "peri/mcp/app", // 透传方法(App 交互) - "id": 100, // 仅 request 携带;notification 省略(JSON-RPC 2.0) + "method": "peri/mcp/app", + "id": 100, "params": { - "serverId": "github", // 路由键:McpClientPool 的 key - "appSessionId": "app_01H4X...",// 路由键:App 会话(握手后存在,见第 6 章) - "protocolVersion": "2026-07-28", // 该 server 协商的 MCP 协议版本(agent 填充) - "payload": { // MCP 原始 JSON-RPC 消息,不做任何改写 + "envelopeVersion": "1", + "serverId": "github", + "appSessionId": "app_01H4X...", + "resourceUri": "ui://get-time/mcp-app.html", + "mcpProtocolVersion": "2026-07-28", + "appsProtocolVersion": "2026-01-26", + "payload": { + "jsonrpc": "2.0", + "id": "req-1", "method": "tools/call", - "params": { "name": "list_issues", "arguments": {} }, - "id": "req-1" // App 侧原始 id(L1),见第 5 章 + "params": { "name": "list_issues", "arguments": {} } } } } ``` +约束:`payload` 保留下游 Apps JSON-RPC 语义,但不会字节级转发到 MCP server;Peri 将其映射到 MCP client API。`mcpProtocolVersion` 表示 Peri 与 server 的 core MCP 协商结果;`appsProtocolVersion` 表示下游 Apps payload 使用的版本,两者不可互换。 资源读取(view 拉 `ui://` HTML 渲染)不依赖 App 会话,单独方法: ```jsonc @@ -133,66 +140,72 @@ guide §9.2 原方案倾向「外层方法名直接用 MCP 原文(`toolresult` ## 5. id 映射:数据正确性的核心 -### 5.1 三层 id 空间 +### 5.1 两层可见 request id | 层 | id 由谁分配 | 形态 | 用途 | | --- | --- | --- | --- | -| L1 | App(iframe 内) | string 或 number,App 自选 | postMessage 层的请求 id | -| L2 | ACP `RequestRouter` | 全局递增 i64 | 单信道上的请求-响应关联 | -| L3 | rmcp client(McpClientPool 内部) | client 层管理 | agent ↔ server 的请求-响应关联 | +| L1 | 下游 Apps payload | string 或 number | 下游 Apps request/response 关联 | +| L2 | ACP `RequestRouter` | 全局递增 i64 | ACP 单信道 request/response 关联 | + +MCP server 侧 request id 由 `rmcp`/MCP client 内部管理,不属于 ACP payload,也不由 Peri 复制维护。 -**不变量**:`payload.id` 恒为 **L1(App 原始 id)**。L2、L3 只存在于外层信封与 agent 内部映射表中,永不出现在 payload 里。这是「payload 保留 MCP 原始消息」原则的直接推论——view 与 agent 都不改写 payload,两端各自维护自己的外层映射,避免双重映射错误。 +**不变量**:Apps payload 中的 `id` 保持 L1;ACP 外层使用 L2;Peri 将 L1 request 映射为 MCP client API 调用,并用 L2 response 恢复对应的 Apps response。Apps notification 不产生 response;Host → App 的 request 也必须在下游完成自己的 response 关联。 ### 5.2 一次 `tools/call` 的完整旅程 ```mermaid sequenceDiagram - participant A as MCP App(iframe) - participant V as 桥 JS(view 侧) - participant H as App Host(agent 侧) + participant A as MCP App(下游) + participant V as Web Host(下游) + participant H as Peri relay/router participant S as MCP server A->>V: postMessage {id:"req-1", method:"tools/call", params} Note over V: 映射表1: L2=200 → {appSessionId, L1="req-1"} V->>H: send_request("peri/mcp/app", envelope{serverId, appSessionId, payload{id:"req-1"}}) [L2=200] Note over H: 校验 appSessionId ∈ 注册表 且 serverId 匹配(§6.3) - Note over H: 调 McpClientPool.call(serverId, ...) [L3 由 client 层分配] - H->>S: tools/call(L3) - S-->>H: 工具结果(L3 关联) - Note over H: 映射表2: L3 → {appSessionId, L1="req-1", L2=200} - H-->>V: send_response(200, result) 信封 payload{method:"toolresult", id:"req-1"} - Note over V: 查映射表1: 200 → L1="req-1" + Note over H: 调用 MCP client API(server-side request id 由 rmcp 内部管理) + H->>S: tools/call + S-->>H: CallToolResult + H-->>V: send_response(200, result)(标准 JSON-RPC response;无 method) + Note over V: 还原并返回下游 Apps 的 L1 id V-->>A: postMessage {id:"req-1", result} ``` ### 5.3 映射表规则 -- **view 侧(桥 JS)**:`L2 → {appSessionId, L1}`。request 发出时写入,response 到达时取出并还原 L1,随后删除。表项带 TTL(与 agent 侧一致,见 §9.1),超时视为信道错误。 -- **agent 侧(App Host)**:`L3 → {appSessionId, L1, L2}`。向 server 发请求时写入,server 响应到达时取出,构建响应信封(`payload.id` 还原为 L1),经 `transport.send_response(L2)` 返回。 -- 两个方向**互不共享映射表**:view 侧映射与 agent 侧映射是各自独立的(中间隔着 L2 关联),这是单信道多路复用不串扰的结构保证。 +- **下游侧**:负责 L2 与其 Apps L1 id 的关联;这是 Web Host 的实现细节,不属于 Peri。 +- **Peri 侧**:ACP host 已以 L2 关联入站 request;调用 MCP client async API 后,用同一个 L2 返回结果。除非实现证据表明 rmcp 无法关联请求,否则不建立第二份 server request-id pending map。 +- 多个 App 即使复用相同 L1 id,也因 `connectionOwner + appSessionId + L2` 隔离而不冲突。 ### 5.4 并发正确性 -- 多个 App 并发时:L2 全局唯一(RequestRouter 递增),L1 可能重复(两个 App 都用 `"req-1"`)——**L1 重复不冲突**,因为每个 App 的请求绑定唯一 L2,agent 侧映射表 key 是 L3(唯一),view 侧映射表 key 是 L2(唯一)。 -- 同一 App 内并发多个请求:L2 不同,互不干扰。 -- `payload.id` 为 string 时同样成立(L2 是数字,映射表 key 与 payload 内容无关)。 +- 多个 App 或 ACP connections 并发时,L2 由 ACP transport 唯一关联;L1 仅在各自下游 Apps session 内有意义。 +- 同一 App 内并发多个 request 时,下游和 ACP transport 分别维护各自 pending state。 +- `payload.id` 支持 string 或 number;notification 无 id 且不能生成 response。 ## 6. App 会话生命周期与鉴权 -### 6.1 会话注册表(agent 侧,进程级) +### 6.1 会话注册表(按 ACP connection 所有权隔离) ```rust struct AppSession { - app_session_id: String, // 握手时由 agent 生成(uuid) - server_id: String, // McpClientPool key - resource_uri: String, // ui:// 资源 URI(创建依据) - state: AppSessionState, // handshake / active / tearing_down + app_session_id: String, + connection_owner: String, + server_id: String, + server_generation: u64, + resource_uri: String, + allowed_tools: Vec, + apps_protocol_version: Option, + state: AppSessionState, created_at: Instant, } ``` -- 存储:进程级 `RwLock>`(与 `McpClientPool` 同生命周期)。 -- 与 `McpSubscriptionPort` 的 inbox 注册**不同**:透传不按 session 注册,App 会话是进程级(server 是进程级共享的)。 +- 每个 session 必须绑定 ACP connection owner;connection EOF 只清理该 owner 的 session。 +- `server_generation` 防止 MCP server 重连后旧 App session 调用新 peer。 +- `resource_uri` 与 tool binding 来自 `Tool._meta.ui.resourceUri`,不能从 App 自报的 `appInfo.name` 推导,也不能依赖 resource 必须出现在 `resources/list`。 +- session registry 可以由进程级 owner 持有,但 key/值必须包含 connection ownership,不能成为跨连接共享授权状态。 ### 6.2 状态机 @@ -209,24 +222,25 @@ stateDiagram-v2 agent 侧收到 `peri/mcp/app` 时按序校验,任一失败按 §8 返回: -1. **会话存在**:`appSessionId` 在注册表中(`ui/initialize` 之前没有会话 → 仅 `ui/initialize` 允许「无会话」状态)。 -2. **归属一致**:信封 `serverId` == 会话注册的 `server_id`。**这一步防止恶意 App(或被劫持的桥 JS)伪造 `serverId` 调用别的 server 的工具。** -3. **状态合法**:`tools/call` 要求 `state == active`;`ui/initialize` 要求无既有会话。 -4. **server 存在**:`serverId` 在 `McpClientPool` 中(`peri/mcp/resource` 也校验此项)。 +1. **会话存在且 owner 一致**:`appSessionId` 属于当前 ACP connection;不能访问其他 connection 的 session。 +2. **归属一致**:信封 `serverId`、绑定 `resourceUri` 与 session 一致。 +3. **server generation 一致**:MCP server 重连后旧 session 必须失效。 +4. **状态合法**:业务 request 仅在 active 状态允许;notification 不产生 response。 +5. **tool 可调用**:tool 属于绑定 server,且 `_meta.ui.visibility` 包含 `"app"`;未声明 visibility 时按规范默认值处理。 +6. **capability 已启用**:当前 ACP connection 声明 Apps/UI capability,且对应 MCP connection profile 已向 server 传播并完成协商。 +7. **权限路径有效**:App 工具调用进入 canonical invocation/HITL seam;不得直接调用低层 MCP peer 绕过审批。 -`ui/initialize` 握手校验(对齐 guide §6.3 实验事实): - -- `payload.params.appInfo` 必含 `name` + `version`(缺 version 报 `-32603`)。 -- 会话创建依据:`appInfo.name` 应能在该 server 的 `ui://` 资源列表中匹配(宽松校验,列表缓存于握手前一次 `resources/list`);匹配失败仅警告不拒绝(资源列表可能未刷新),但 `serverId` 必须属于已知 server。 -- 成功后 response 携带 `appSessionId` 与 `protocolVersion`,view 侧桥 JS 保存用于后续透传。 +`ui/initialize` 属于下游 Web Host ↔ App 的协议握手,Peri 不以 `appInfo.name` 推导授权。Peri session 应由下游针对已发现的 `{serverId, resourceUri, tool binding}` 显式创建/绑定,并将随后 payload 限制在该绑定内。 ### 6.4 teardown 触发 | 触发源 | agent 侧动作 | view 侧动作 | | --- | --- | --- | -| App 主动 teardown(notification) | 销毁会话 | 关闭 iframe | -| server 断开 / 重连失败(`McpClientPool` 状态变化) | 销毁该 server 全部会话,发 `teardown` 推送 | 关闭 iframe 并提示 | -| view 关闭(连接断开) | 按连接清理会话 | — | +| 下游主动关闭 App session | 清理该 connection-owned session 和 pending requests | 下游自行 teardown Web Host/App | +| server 断开或 generation 变化 | 失效该 server 旧 generation 的全部 session;通过 ACP 通知下游 | 下游决定 UI 处置 | +| ACP connection 关闭 | 只清理该 connection owner 的 session | — | + +`ui/resource-teardown` 是下游 Web Host → App 的 request/response,不由 Peri 生成或执行;若下游选择经 ACP 传递相关 lifecycle payload,Peri 仅做 session 定向与至多一次 terminal delivery。 ## 7. 分流规则(防双写) @@ -244,12 +258,15 @@ agent 侧收到 `peri/mcp/app` 时按序校验,任一失败按 §8 返回: | 类别 | 错误码 | 说明 | | --- | --- | --- | -| envelope 校验失败(§6.3 任一) | `-32001` invalid_app_session / `-32002` forbidden / `-32003` unknown_server | ACP 层错误,`data` 携带原因;payload 不执行 | -| 透传请求超时(§9.1) | `-32000` timeout | agent 侧(server 无响应)与 view 侧(agent 无响应)各自计时 | -| MCP 协议错误(`-32602` 参数错等) | **原样透传** | 封装进 response 的 error 字段,view 不拦截 | -| 工具执行错误(`isError: true` 结果) | **原样透传** | 属正常业务结果,模型/App 自纠 | +| envelope/session/routing 校验失败 | ACP 外层 JSON-RPC error | 不执行 payload;`data.kind` 明确标识 `invalid_session` / `forbidden` / `unknown_server` / `capability_disabled` 等类别 | +| Peri policy/HITL 拒绝 | ACP 外层或 Apps payload response 中的结构化 policy error | 由调用路径明确来源,不依赖 error code 范围判断 | +| MCP JSON-RPC error | Apps payload 内原样保留 | 不提升为 ACP transport error | +| 工具执行错误(`isError: true`) | 标准 `CallToolResult` | 属正常 JSON-RPC result,不转换为 protocol/transport error | +| timeout/disconnect/cancellation | ACP 外层 lifecycle error | pending request 至多结算一次,并带稳定 `data.kind` | -**view 侧桥 JS 分界规则**:response 的 error code 在 `-32000` ~ `-32099` 视为**信道错误**(提示/重试/关闭 App);其余(含 `-32600` 系列)视为 MCP 侧错误,透传给 App 原样处理。 +禁止按 `-32000..-32099` 范围推断错误来自 Peri 还是 MCP:该范围也可被 MCP server 或下游 Host policy 使用。错误层级由“ACP 外层 error”与“payload 内 JSON-RPC error”的结构位置决定。 + +- 错误分类由 ACP 外层 `data.kind` 与 payload 内 JSON-RPC error 的结构位置决定;具体用户提示与重试策略由下游实现。 ## 9. 可靠性:超时 / 背压 / 协议版本 @@ -267,21 +284,132 @@ agent 侧收到 `peri/mcp/app` 时按序校验,任一失败按 §8 返回: ### 9.3 协议版本 -- `protocolVersion` 由 agent 侧从该 server 的协商结果填充(`McpClientPool` 持有),view 不猜、不存。 -- 桥 JS 按版本分支解析 payload(当前两版在透传相关方法上差异小:`ui/*`、`tools/call`、`toolresult` 基本一致;字段差异集中在握手与订阅,未来版本演进时此字段是唯一判断依据)。 -- `peri/mcp/resource` 响应不需要版本字段(内容是 MCP 原文 content)。 +- `envelopeVersion` 版本化 Peri 私有 envelope schema。 +- `mcpProtocolVersion` 来自 Peri ↔ MCP server 的 core MCP 协商结果。 +- `appsProtocolVersion` 属于下游 App ↔ Web Host 协议;Peri 仅承载和校验支持范围,不用 core MCP version 替代。 +- ACP protocol/capability version 由 ACP initialize contract 单独管理。 +- `peri/mcp/resource` response 应携足以解释 resource 的 envelope/version 信息,不能让下游依赖猜测。 + +### 9.4 Peri 与下游边界 + +本设计的 Peri 改造范围是 **`PERI_MCP_APPS` deployment profile → MCP server capability/data → ACP stdio relay**。下游 Web Host 不由本仓库实现。 + +| 参与方 | 本次是否改造 | 责任 | +| --- | --- | --- | +| 进程 launcher | 配置 | 通过 `PERI_MCP_APPS` 是否存在选择 immutable deployment profile;值不解析 | +| Peri | 是 | prewarm 前冻结 profile;初始连接/重连传播 UI extension;发现 resource/tool metadata;维护 connection-owned binding;resource relay 与 Binding lease/canonical HITL `tools/call` 已接通 | +| MCP server | 按规范提供 | 提供 UI resource、tool metadata 和标准结果 | +| Web Host / our FE | **不由 Peri 实现** | 消费 ACP contract,自行实现 Apps Host/UI | +| `peri-tui` | 否 | 不实现或消费 MCP Apps UI | + +Peri 不负责创建 iframe,不实现 Web Host,不处理浏览器 `postMessage`,不实现 MCP Apps FE SDK。文档中的 Web Host/iframe 仅用于解释下游如何消费 Peri 提供的数据。 + +### 9.5 两段 server-side transport、三层消息、四种数据 + +必须把传输边界画成两段,而不是把 MCP Apps 称为 “stdio UI 协议”: + +```mermaid +flowchart LR + APP["MCP Apps FE\niframe"] <-->|"postMessage\nApps JSON-RPC"| FE["our FE\nApp Host / Bridge"] + FE <-->|"ACP JSON-RPC\nstdio newline framing\nperi/mcp/* envelope"| P["Peri\nACP stdio host"] + P <-->|"MCP JSON-RPC\nrmcp peer\nstdio 或 Streamable HTTP"| S["MCP server"] +``` + +- **Peri → 下游数据**:Peri 只通过 ACP envelope 提供 resource、tool input/result 和 control/context payload;下游 Web Host 自行决定是否及如何转发给 MCP Apps FE。 +- **下游 → Peri 数据**:下游 Web Host 将其 Apps protocol payload 包装进 ACP request;Peri 校验 capability、session、server/tool 归属后才访问 MCP server。 +- **Peri ↔ MCP server**:仍是标准 MCP JSON-RPC,不把 ACP envelope 转发给 MCP server。 +- **D1:UI resource**:HTML、MIME、CSP、permissions、domain 等展示/安全元数据。 +- **D2:tool input**:工具调用参数;host/model 发起时可先推送给 App。 +- **D3:tool result**:完整 `content`、`structuredContent`、`_meta`、`isError`;不得压扁成文本。 +- **D4:control/context**:初始化、host context、尺寸、teardown、能力请求;按 Apps 协议方向传递。 + +stdio 只负责可靠传递 ACP JSON-RPC 报文(当前实现是一行一个 JSON 报文);HTML 不应被拼接到裸 stdout,也不应绕过 JSON-RPC envelope 写入 stdout。所有日志继续走 stderr/tracing,避免污染 stdio 数据流。 + +### 9.6 端到端时序:从能力协商到 App 交互 + +```mermaid +sequenceDiagram + participant F as 下游 ACP client / Web Host + participant P as Peri stdio + participant M as MCP server + participant A as MCP Apps FE(下游) + + Note over P: 启动前读取 PERI_MCP_APPS(存在即启用) + P->>M: MCP initialize(deployment profile 传播 UI extension) + M-->>P: initialize result(core MCP version/capabilities) + F->>P: ACP initialize(不协商 MCP Apps capability) + P-->>F: 普通 ACP initialize response + P->>M: tools/list + M-->>P: Tool + _meta.ui.resourceUri + visibility + F->>P: peri/mcp/open(serverId, toolName) + P-->>F: appSessionId + resourceUri + F->>P: peri/mcp/resource(appSessionId) + P->>M: resources/read(uri=ui://...) + M-->>P: contents[](text|blob, mimeType, _meta.ui) + P-->>F: resource response(完整 resource content) + Note over P,F: open 单次消费初始 canonical invocation 签发的 Binding lease + Note over F,A: 下游自行完成 ui/initialize / initialized + A->>F: tools/call request + F->>P: peri/mcp/app request + P->>M: 经 lease EffectiveToolDispatcher + Permission/HITL 调用 canonical MCP bridge + M-->>P: raw CallToolResult + P-->>F: Apps JSON-RPC response(完整 raw result) +``` + +模型/host 发起的调用是另一条路径:Peri 把完整 tool input/result 及 `resourceUri` 定向投影给下游;下游自行生成 `ui/notifications/tool-input-partial`、`tool-input`、`tool-result` 或 `tool-cancelled`。App 自己发起的调用只返回标准 response,不能再发送一次 tool-result notification。 + +### 9.7 Deployment capability、初始化时序与降级 + +1. `PERI_MCP_APPS` 只按存在性解释;空串、`0` 等值同样启用。 +2. profile 在进程内通过一次性缓存冻结,并在 MCP pool prewarm 前读取。 +3. 初始连接、OAuth 路径、静态重连和 Dynamic MCP 连接均复用同一 profile,在 MCP `initialize` 中传播 `io.modelcontextprotocol/ui` 与 HTML MIME。 +4. 环境变量不存在时不传播 extension,stdio Apps relay backend 不装配,Apps methods 返回 `capability_disabled`。 +5. ACP initialize 不解析或回显任何 MCP Apps capability。 +6. tool 无合法 `_meta.ui.resourceUri`、visibility 不含 `app` 或 resource MIME/body 非法时 fail closed;普通模型 MCP 路径保持可用。 +7. App `tools/call` 必须持有由初始 canonical MCP invocation 签发、`open` 按 server/tool/resource/generation 单次消费并绑定 ACP connection 的 Binding lease;调用经 `EffectiveToolDispatcher` 进入 effective view 与 Permission/HITL,禁止直调 MCP peer。TTL、session 最新 turn generation、cancellation、server generation 与 ACP EOF 共同负责失效/撤销。 + +### 9.8 资源与结果的缓存边界 + +- `resources/list` 仅可作为可选发现快照;UI-only resource 可以不出现在列表中。工具关联的权威 URI 来自 `Tool._meta.ui.resourceUri`。 +- `peri/mcp/resource` 必须保留 `contents[]`、`uri`、`mimeType`、`text|blob` 和 content `_meta.ui`;不能只投影为 HTML 字符串。 +- content item `_meta.ui` 覆盖 listing-level metadata;Peri 原样提供 `csp`、`permissions`、`domain`、`prefersBorder` 等字段,下游负责执行安全策略。 +- `_meta.ui.visibility` 默认按规范解释;`model` 控制是否进入模型 tool list,`app` 控制是否允许 Apps session 调用。 +- 缓存键至少包含 `connection/profile + server generation + serverId + resourceUri + negotiated version`;不得跨 server、generation 或 capability profile 复用。 +- 工具结果通常不可缓存:`structuredContent` 可能含用户数据或一次性状态;如未来缓存,必须按 App session、tool input 和 server identity 隔离,并明确 TTL。 +- 失败的 HTML/CSP 校验不得降级为把 HTML 注入 host DOM;只能关闭 App 并保留普通文本工具结果。 + +### 9.9 可观测性与测试断言 + +不记录 prompt、工具参数中的 secret、HTML 正文、OAuth token 或完整用户数据。允许记录不可逆的 `serverId`(若其本身不含敏感信息)、resource URI 的 scheme/host 摘要、App session hash、方法名、方向、耗时、结果大小和错误类别。 + +最小测试矩阵: + +| 场景 | 必须断言 | +| --- | --- | +| capability absent/present/malformed,或 MIME 不受支持 | absent 时 MCP initialize 不含 UI extension;malformed 安全拒绝;普通 MCP 不回归 | +| MCP 已 prewarm 且 profile 与 ACP capability 不一致 | 不在已初始化 peer 上补发 capability;延迟、独立连接或显式重连 | +| 两个 ACP connections 使用不同 capability | capability、App session、cache 与结果不串 connection | +| 两个 App 同时使用相同 L1 id | 由 connection owner + appSessionId + ACP L2 隔离 | +| App 指定另一个 `serverId` / `resourceUri` / 旧 generation | 被拒绝,server 不收到 `tools/call` | +| visibility 为 `model` 或 `app` | model/app tool surface 按 visibility 分流,默认值符合规范 | +| resource 为多 contents、`text` 或 `blob` | `uri`、`mimeType`、`text|blob`、content `_meta.ui` 完整往返 | +| `structuredContent` + `content` + `_meta` + `isError` | 字段逐项保留;`isError` 是正常 result,不变成 transport error | +| App 主动调用与模型调用 | 前者只返回标准 JSON-RPC response;后者只产生一次下游 input/result/cancelled 投影 | +| session teardown、stdio EOF、server generation 变化、timeout/cancel | 只清理对应 owner/generation;pending request 至多结算一次 | +| 不支持 Apps 的 server/client | 普通 MCP tool/resource/skills/subscription 行为保持不变 | + -## 10. 落地清单与前置依赖 | # | 改动 | 位置 | 依赖 | | --- | --- | --- | --- | -| 0 | **MCP Apps 能力声明**:client 初始化声明 `enable_extensions_with({"io.modelcontextprotocol/ui": {"mimeTypes": ["text/html;profile=mcp-app"]}})` | `peri-middlewares/src/mcp/channel_handler.rs`(当前 `ClientCapabilities::default()`) | **无——所有 App 生态的前提**:不声明则 server 不下发 `ui://` 资源 | -| 1 | `peri/mcp/app`、`peri/mcp/resource` 方法分发(view → agent 方向) | ACP 命令分发处(与 `session/*` 同入口) | 无 | -| 2 | App Host:会话注册表 + 握手校验 + teardown | `peri-middlewares/src/mcp/`(新模块,与 `McpClientPool` 同层) | 1 | -| 3 | App Host:id 映射(L3 → L1/L2)与 `tools/call` 路由(复用 `McpClientPool` + HITL) | 同上 | 1、2 | -| 4 | `peri/mcp/resource`:`resources/read` 透传(复用 `resource_tool.rs`) | 同上 | 1 | -| 5 | 桥 JS:信封编解码 + L2 → L1 映射 + 版本分支 | TUI webview 方案(guide §9.5 方案 A) | 2、3 | -| 6 | 工具结果事件携带 `ui.resourceUri`(**确认未落地**:`tool_bridge.rs` 未读取 `Tool._meta`) | 事件链 tool_result | guide §9.4 | +| 0 | **读取 ACP client 的 MCP Apps capability**:从 UI 侧 `initialize` 保存 connection-scoped capability;不能由 Peri stdio 自行声明 | `peri-acp` ACP initialize contract / connection state | 无——所有 App 生态的前提 | +| 1 | **条件传播 MCP server capability**:仅当 ACP client 声明 UI capability 时,Peri 才在 MCP client initialize 中发送对应 UI extension;否则保持普通 MCP capabilities | `peri-middlewares/src/mcp/channel_handler.rs` 与 MCP 初始化装配 | 0 | +| 2 | `peri/mcp/app`、`peri/mcp/resource` 方法分发(下游 UI → Peri 方向) | ACP 命令分发处(与 `session/*` 同入口) | 0、1 | +| 3 | App session:会话注册表、server/resource 绑定、握手校验与 teardown | `peri-acp`/`peri-middlewares` 的协议实现层 | 2 | +| 4 | id 映射与 `tools/call` 路由(复用 `McpClientPool` + 既有权限/HITL) | 同上 | 2、3 | +| 5 | `peri/mcp/resource`:`resources/read` 透传,保留 HTML/MIME/`_meta.ui` | 同上(复用现有 MCP resource 能力) | 1、2 | +| 6 | agent/model 侧工具结果关联 UI resource,并通过 ACP 定向通知下游 | 事件/结果投影层;不改变 TUI 路径 | 3、4 | +| 7 | **下游实现项(不属于 Peri)**:Web Host 的 iframe、sandbox、CSP、Permissions Policy、`postMessage` bridge、MCP Apps FE | 下游项目 | Peri ACP contract 稳定后 | **rmcp 侧支持情况(已调查,rmcp 3.1.2)**:无 MCP Apps 专用 handler(`ui/*` 消息在规范上不走 MCP 连接,由宿主侧 App Host 逻辑处理,见 §6);但全部透传基础已在——第 0 项能力声明(`ExtensionCapabilities`)、`on_custom_request` / `on_custom_notification` 扩展点(默认 `-32601` 拒绝,安全)、`send_custom_notification`(`mcp_notify.rs` 已用)、`Tool._meta`(`_meta.ui.resourceUri` 可透传)、`resources/read`(`resource_tool.rs` 已用)。 @@ -289,7 +417,6 @@ agent 侧收到 `peri/mcp/app` 时按序校验,任一失败按 §8 返回: ## 11. 未决问题 -1. **高频 App 事件的硬隔离**(§9.2):透传与事件链共用通道的容量上限未量化;流式 App 出现时需评估独立队列方案。 -2. **多 view 连接共存**:当前 TUI 与 ACP Server 一对一(MpscTransport)。未来 stdio 外部 IDE 与 TUI 并存时,App 会话注册表按连接隔离还是全局?(`peri/mcp/*` 信封暂不带 sessionId,届时需评估。) -3. **teardown 与重连的竞态**:server 重连期间 App 会话的处理策略(销毁重来 vs 挂起等待)未定。 -4. **`appInfo.name` ↔ `ui://` 资源匹配的严格度**:§6.3 当前取宽松校验,是否需要缓存资源列表做严格校验,取决于多 server 同名 App 的实际冲突概率。 +1. **高频 App 事件的硬隔离**:透传与事件链共用通道的容量上限未量化;流式 App 出现时需评估独立队列方案。 +2. **Host → App 双向 request**:若下游需要通过 ACP 让 Peri 触发下游 request,需为 `ui/resource-teardown` 等定义反向 ACP request/response contract;不能退化成 notification。 +3. **Lease 长期策略**:首版 TTL 为 5 分钟且与初始 turn cancellation 绑定;未来若要求跨 turn 长驻 App,需要新的 session-owned dispatcher lease,而不是延长 turn-local lease。 diff --git a/peri-acp-types/src/lib.rs b/peri-acp-types/src/lib.rs index b0b23d65..0c09c3ea 100644 --- a/peri-acp-types/src/lib.rs +++ b/peri-acp-types/src/lib.rs @@ -50,6 +50,7 @@ pub mod identity; pub mod interaction; pub mod lsp; pub mod mcp; +pub mod mcp_apps; pub mod mcp_skills; pub mod messages; pub mod meta_harness; diff --git a/peri-acp-types/src/mcp_apps.rs b/peri-acp-types/src/mcp_apps.rs new file mode 100644 index 00000000..088f294d --- /dev/null +++ b/peri-acp-types/src/mcp_apps.rs @@ -0,0 +1,256 @@ +//! MCP Apps Stable ACP relay contracts. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +pub const MCP_APPS_PROTOCOL_VERSION: &str = "2026-01-26"; +pub const MCP_APPS_ENVELOPE_VERSION: &str = "1"; +pub const MCP_APPS_HTML_MIME: &str = "text/html;profile=mcp-app"; +pub const MCP_APPS_ENV: &str = "PERI_MCP_APPS"; + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct McpAppOpenRequest { + pub envelope_version: String, + pub apps_protocol_version: String, + pub server_id: String, + pub tool_name: String, + pub owner_session_id: String, + pub invocation_token: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpAppOpenResponse { + pub envelope_version: String, + pub apps_protocol_version: String, + pub mcp_protocol_version: String, + pub server_id: String, + pub app_session_id: String, + pub resource_uri: String, +} +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct McpAppRequest { + pub envelope_version: String, + pub apps_protocol_version: String, + pub server_id: String, + pub app_session_id: String, + pub resource_uri: String, + pub payload: JsonRpcRequest, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct McpResourceRequest { + pub envelope_version: String, + pub apps_protocol_version: String, + pub server_id: String, + pub app_session_id: String, + pub resource_uri: String, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpAppResponse { + pub envelope_version: String, + pub apps_protocol_version: String, + pub mcp_protocol_version: String, + pub server_id: String, + pub app_session_id: String, + pub resource_uri: String, + pub payload: JsonRpcResponse, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct McpResourceResponse { + pub envelope_version: String, + pub apps_protocol_version: String, + pub mcp_protocol_version: String, + pub server_id: String, + pub resources: Vec, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcRequest { + pub jsonrpc: JsonRpcVersion, + pub id: JsonRpcId, + pub method: String, + pub params: CallToolParams, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct CallToolParams { + pub name: String, + #[serde(default)] + pub arguments: serde_json::Map, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JsonRpcId { + String(String), + Number(i64), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum JsonRpcVersion { + #[serde(rename = "2.0")] + V2, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum JsonRpcResponse { + Result(JsonRpcResultResponse), + Error(JsonRpcErrorResponse), +} + +impl JsonRpcResponse { + pub fn id(&self) -> &JsonRpcId { + match self { + Self::Result(response) => &response.id, + Self::Error(response) => &response.id, + } + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JsonRpcResultResponse { + pub jsonrpc: JsonRpcVersion, + pub id: JsonRpcId, + pub result: Value, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct JsonRpcErrorResponse { + pub jsonrpc: JsonRpcVersion, + pub id: JsonRpcId, + pub error: JsonRpcError, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct JsonRpcError { + pub code: i64, + pub message: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub data: Option, +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct RawResource { + pub uri: String, + #[serde(rename = "mimeType")] + pub mime_type: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub text: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub blob: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "BTreeMap::is_empty")] + pub meta: BTreeMap, + #[serde(flatten)] + pub extra: BTreeMap, +} + +impl RawResource { + pub fn is_valid_app_resource(&self, requested_uri: &str) -> bool { + self.uri == requested_uri + && self.uri.starts_with("ui://") + && self.mime_type == MCP_APPS_HTML_MIME + && self.text.is_some() != self.blob.is_some() + } +} + +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RawCallToolResult { + #[serde(default)] + pub content: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub structured_content: Option, + #[serde(rename = "_meta", default, skip_serializing_if = "BTreeMap::is_empty")] + pub meta: BTreeMap, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub is_error: bool, + #[serde(flatten)] + pub extra: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AppSessionBinding { + pub app_session_id: String, + pub owner_connection_id: String, + pub owner_session_id: String, + pub server_id: String, + pub server_generation: u64, + pub resource_uri: String, + pub instantiating_tool: String, + pub apps_protocol_version: String, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum McpAppsErrorKind { + InvalidEnvelope, + UnsupportedEnvelopeVersion, + UnsupportedAppsVersion, + CapabilityDisabled, + UnknownServer, + ServerDisconnected, + StaleServerGeneration, + InvalidSession, + Forbidden, + ToolNotFound, + ToolNotAppVisible, + ResourceNotFound, + InvalidResource, + PolicyDenied, + Cancelled, + UnsupportedMethod, + UpstreamProtocolError, +} + +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("MCP Apps relay request failed")] +pub struct McpAppsRelayError { + pub kind: McpAppsErrorKind, +} + +#[async_trait::async_trait] +pub trait McpAppsRelayPort: Send + Sync { + fn close_connection(&self, owner_connection_id: &str); + fn close_session(&self, owner_session_id: &str); + fn begin_session_turn(&self, owner_session_id: &str); + + async fn open_app( + &self, + owner_connection_id: &str, + request: &McpAppOpenRequest, + ) -> Result<(String, AppSessionBinding), McpAppsRelayError>; + + async fn validate_binding( + &self, + binding: &AppSessionBinding, + ) -> Result; + + async fn read_resource( + &self, + binding: &AppSessionBinding, + ) -> Result<(String, Vec), McpAppsRelayError>; + + async fn call_tool( + &self, + binding: &AppSessionBinding, + request: JsonRpcRequest, + ) -> Result<(String, JsonRpcResponse), McpAppsRelayError>; +} + +#[cfg(test)] +#[path = "mcp_apps_test.rs"] +mod tests; diff --git a/peri-acp-types/src/mcp_apps_test.rs b/peri-acp-types/src/mcp_apps_test.rs new file mode 100644 index 00000000..af80b52d --- /dev/null +++ b/peri-acp-types/src/mcp_apps_test.rs @@ -0,0 +1,77 @@ +use super::*; +use serde_json::json; + +#[test] +fn inbound_app_envelope_rejects_mcp_protocol_version() { + let value = json!({ + "envelopeVersion": "1", + "appsProtocolVersion": MCP_APPS_PROTOCOL_VERSION, + "mcpProtocolVersion": "2025-11-25", + "serverId": "server", + "appSessionId": "app", + "resourceUri": "ui://app", + "payload": {"jsonrpc":"2.0", "id":1, "method":"tools/call", "params":{}} + }); + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn raw_result_roundtrip_preserves_unknown_fields() { + let value = json!({ + "content": [{"type":"resource", "resource":{"uri":"ui://app", "text":""}}], + "structuredContent": {"answer": 42}, + "_meta": {"vendor": {"opaque": true}}, + "isError": true, + "futureField": {"preserved": true} + }); + let decoded: RawCallToolResult = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(decoded).unwrap(), value); +} + +#[test] +fn json_rpc_response_requires_exactly_one_terminal_shape() { + assert!(serde_json::from_value::(json!({ + "jsonrpc": "2.0", + "id": 1, + "result": {}, + "error": {"code": -1, "message": "bad"} + })) + .is_err()); + assert!(serde_json::from_value::(json!({ + "jsonrpc": "2.0", + "id": 1 + })) + .is_err()); +} + +#[test] +fn call_tool_params_require_name_and_object_arguments() { + assert!(serde_json::from_value::(json!({ + "jsonrpc": "2.0", + "id": "request", + "method": "tools/call", + "params": {"arguments": {}} + })) + .is_err()); + assert!(serde_json::from_value::(json!({ + "jsonrpc": "2.0", + "id": "request", + "method": "tools/call", + "params": {"name": "tool", "arguments": []} + })) + .is_err()); +} + +#[test] +fn app_resource_requires_exact_uri_mime_and_one_body() { + let resource = RawResource { + uri: "ui://app".into(), + mime_type: MCP_APPS_HTML_MIME.into(), + text: Some("".into()), + blob: None, + meta: BTreeMap::from([("ui".into(), json!({"csp": {"connectDomains": []}}))]), + extra: BTreeMap::new(), + }; + assert!(resource.is_valid_app_resource("ui://app")); + assert!(!resource.is_valid_app_resource("ui://other")); +} diff --git a/peri-acp-types/src/tools.rs b/peri-acp-types/src/tools.rs index fbb951c5..e23f63d7 100644 --- a/peri-acp-types/src/tools.rs +++ b/peri-acp-types/src/tools.rs @@ -216,6 +216,10 @@ pub struct ToolContext<'a> { pub invocation_id: Option, /// 当前外层调用的取消令牌。 pub cancellation: tokio_util::sync::CancellationToken, + /// 当前 Agent session identity;仅 canonical dispatch 中存在。 + pub session_id: Option, + /// 当前 turn generation;用于撤销跨 turn 的宿主调用租约。 + pub turn_generation: Option, } impl<'a> ToolContext<'a> { @@ -226,6 +230,8 @@ impl<'a> ToolContext<'a> { effective_tool_dispatcher: None, invocation_id: None, cancellation: tokio_util::sync::CancellationToken::new(), + session_id: None, + turn_generation: None, } } @@ -240,6 +246,16 @@ impl<'a> ToolContext<'a> { self.cancellation = cancellation; self } + + pub fn with_session_identity( + mut self, + session_id: impl Into, + turn_generation: impl Into, + ) -> Self { + self.session_id = Some(session_id.into()); + self.turn_generation = Some(turn_generation.into()); + self + } } /// A target-specific canonical action bound before middleware/HITL. @@ -332,6 +348,12 @@ pub trait BaseTool: Send + Sync { false } + /// Whether this tool may be projected into the model-facing tool catalog. + /// Host-only tools remain dispatchable through the canonical catalog/HITL. + fn visible_to_model(&self) -> bool { + true + } + /// 短显示名(≤ 6 词,名词短语)。缺省时由 [`derive_title_from_name`] 推导。 fn title(&self) -> Option<&str> { None diff --git a/peri-acp/src/host/assemble.rs b/peri-acp/src/host/assemble.rs index f620054c..9135f220 100644 --- a/peri-acp/src/host/assemble.rs +++ b/peri-acp/src/host/assemble.rs @@ -137,6 +137,30 @@ pub fn build_session_manager( /// 边 2 assemble 路径);行为与迁移前三路径(launch / cli_print / stdio) /// 各自装配一致(cron tick 驱动、MCP 初始化、孤儿插件清理时机均复刻)。 pub async fn assemble_server_config(input: HostAssemblyInput) -> AcpServerConfig { + assemble_server_config_with_mcp_profile( + input, + peri_middlewares::mcp::apps::McpCapabilityProfile::disabled(), + ) + .await +} + +/// stdio deployment variant. The assembly boundary derives the concrete MCP profile; +/// TUI/MPSC always use [`assemble_server_config`]. +pub async fn assemble_server_config_with_mcp_apps( + input: HostAssemblyInput, + apps_enabled: bool, +) -> AcpServerConfig { + assemble_server_config_with_mcp_profile( + input, + peri_middlewares::mcp::apps::deployment_profile(apps_enabled), + ) + .await +} + +async fn assemble_server_config_with_mcp_profile( + input: HostAssemblyInput, + mcp_profile: peri_middlewares::mcp::apps::McpCapabilityProfile, +) -> AcpServerConfig { let (host_task_owner, host_task_spawner) = HostTaskOwner::new(); let (mcp_task_owner, mcp_task_spawner) = peri_middlewares::mcp::McpTaskOwner::new(); let HostAssemblyInput { @@ -201,8 +225,9 @@ pub async fn assemble_server_config(input: HostAssemblyInput) -> AcpServerConfig None } else { let pool = Arc::new( - peri_middlewares::mcp::McpClientPool::new_pending_with_spawner( + peri_middlewares::mcp::McpClientPool::new_pending_with_spawner_and_profile( mcp_task_spawner.clone(), + mcp_profile.clone(), ), ); let pool_clone = pool.clone(); @@ -334,8 +359,9 @@ pub async fn assemble_server_config(input: HostAssemblyInput) -> AcpServerConfig mcp_task_spawner.clone(), mcp_pool_concrete.clone().unwrap_or_else(|| { Arc::new( - peri_middlewares::mcp::McpClientPool::new_pending_with_spawner( + peri_middlewares::mcp::McpClientPool::new_pending_with_spawner_and_profile( mcp_task_spawner.clone(), + mcp_profile.clone(), ), ) }), @@ -348,8 +374,19 @@ pub async fn assemble_server_config(input: HostAssemblyInput) -> AcpServerConfig // McpSubscriptionPort(订阅通知 → 会话 inbox 唤醒)两个角色。 let mcp_pool: Option> = mcp_pool_concrete.clone().map(|p| p as Arc); - let mcp_subscription: Option> = - mcp_pool_concrete.map(|p| p as Arc); + let mcp_subscription: Option> = mcp_pool_concrete + .clone() + .map(|p| p as Arc); + let mcp_apps_relay: Option> = + if mcp_profile.apps_enabled() { + mcp_pool_concrete.clone().map(|pool| { + Arc::new(peri_middlewares::mcp::apps_relay::PoolMcpAppsRelay::new( + pool, + )) as Arc + }) + } else { + None + }; // ── 资源类/业务面端口默认实现(构造下沉:ACP Host = 部署单元)── let tool_search_index: Arc = @@ -458,6 +495,7 @@ pub async fn assemble_server_config(input: HostAssemblyInput) -> AcpServerConfig permission_mode, cron_scheduler, mcp_pool, + mcp_apps_relay, dynamic_mcp: Some(dynamic_mcp), oauth_event_tx: Some(oauth_event_tx), oauth_event_rx: Some(oauth_event_rx), diff --git a/peri-acp/src/host/connection.rs b/peri-acp/src/host/connection.rs new file mode 100644 index 00000000..b0118f89 --- /dev/null +++ b/peri-acp/src/host/connection.rs @@ -0,0 +1,95 @@ +use std::collections::HashMap; + +use peri_acp_types::mcp_apps::AppSessionBinding; + +#[allow(dead_code)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ConnectionLifecycle { + Open, + Closing, + Closed, +} + +pub(crate) struct ConnectionContext { + id: String, + lifecycle: ConnectionLifecycle, + initialized: bool, + apps_enabled: bool, + app_sessions: HashMap, + cancellation: tokio_util::sync::CancellationToken, +} + +impl ConnectionContext { + pub(crate) fn new(apps_enabled: bool) -> Self { + Self { + id: uuid::Uuid::now_v7().to_string(), + lifecycle: ConnectionLifecycle::Open, + initialized: false, + apps_enabled, + app_sessions: HashMap::new(), + cancellation: tokio_util::sync::CancellationToken::new(), + } + } + + pub(crate) fn commit_initialize(&mut self) { + if !self.initialized && self.lifecycle == ConnectionLifecycle::Open { + self.initialized = true; + } + } + + pub(crate) fn apps_enabled(&self) -> bool { + self.lifecycle == ConnectionLifecycle::Open && self.apps_enabled + } + + #[allow(dead_code)] + pub(crate) fn insert_app_session(&mut self, binding: AppSessionBinding) -> bool { + if self.apps_enabled() && binding.owner_connection_id == self.id { + self.app_sessions + .insert(binding.app_session_id.clone(), binding); + true + } else { + false + } + } + + pub(crate) fn app_session(&self, id: &str) -> Option<&AppSessionBinding> { + self.apps_enabled() + .then(|| self.app_sessions.get(id)) + .flatten() + } + + pub(crate) fn snapshot_for_request(&self) -> Self { + Self { + id: self.id.clone(), + lifecycle: self.lifecycle, + initialized: self.initialized, + apps_enabled: self.apps_enabled, + app_sessions: self.app_sessions.clone(), + cancellation: self.cancellation.clone(), + } + } + + pub(crate) fn cancellation(&self) -> tokio_util::sync::CancellationToken { + self.cancellation.clone() + } + + pub(crate) fn begin_close(&mut self) { + self.lifecycle = ConnectionLifecycle::Closing; + self.cancellation.cancel(); + self.app_sessions.clear(); + } + + #[allow(dead_code)] + pub(crate) fn finish_close(&mut self) { + self.lifecycle = ConnectionLifecycle::Closed; + self.app_sessions.clear(); + } + + pub(crate) fn id(&self) -> &str { + &self.id + } +} + +#[cfg(test)] +#[path = "connection_test.rs"] +mod tests; diff --git a/peri-acp/src/host/connection_test.rs b/peri-acp/src/host/connection_test.rs new file mode 100644 index 00000000..45543a7f --- /dev/null +++ b/peri-acp/src/host/connection_test.rs @@ -0,0 +1,32 @@ +use super::*; +use peri_acp_types::mcp_apps::{AppSessionBinding, MCP_APPS_PROTOCOL_VERSION}; + +#[test] +fn deployment_capability_is_immutable_and_close_invalidates_sessions() { + let mut connection = ConnectionContext::new(true); + connection.commit_initialize(); + connection.commit_initialize(); + assert!(connection.apps_enabled()); + + connection.insert_app_session(AppSessionBinding { + app_session_id: "app".into(), + owner_connection_id: connection.id().into(), + owner_session_id: String::new(), + server_id: "server".into(), + server_generation: 1, + resource_uri: "ui://app".into(), + instantiating_tool: "open".into(), + apps_protocol_version: MCP_APPS_PROTOCOL_VERSION.into(), + }); + assert!(connection.app_session("app").is_some()); + connection.begin_close(); + assert!(connection.app_session("app").is_none()); + connection.finish_close(); +} + +#[test] +fn absent_deployment_capability_stays_disabled() { + let mut connection = ConnectionContext::new(false); + connection.commit_initialize(); + assert!(!connection.apps_enabled()); +} diff --git a/peri-acp/src/host/mcp_apps.rs b/peri-acp/src/host/mcp_apps.rs new file mode 100644 index 00000000..fd7d6078 --- /dev/null +++ b/peri-acp/src/host/mcp_apps.rs @@ -0,0 +1,208 @@ +use std::sync::Arc; + +use peri_acp_types::mcp_apps::{ + AppSessionBinding, McpAppOpenRequest, McpAppOpenResponse, McpAppRequest, McpAppResponse, + McpAppsErrorKind, McpAppsRelayError, McpAppsRelayPort, McpResourceRequest, McpResourceResponse, + MCP_APPS_ENVELOPE_VERSION, MCP_APPS_PROTOCOL_VERSION, +}; +use serde_json::Value; + +use super::connection::ConnectionContext; +use crate::transport::types::AcpError; + +pub(crate) async fn handle_request( + method: &str, + params: &Value, + connection: &mut ConnectionContext, + relay: Option<&Arc>, +) -> Result { + if !connection.apps_enabled() { + return Err(outer_error(McpAppsErrorKind::CapabilityDisabled)); + } + let relay = relay.ok_or_else(|| outer_error(McpAppsErrorKind::CapabilityDisabled))?; + match method { + "peri/mcp/open" => handle_open(params, connection, relay).await, + "peri/mcp/resource" => handle_resource(params, connection, relay).await, + "peri/mcp/app" => handle_app(params, connection, relay).await, + _ => Err(outer_error(McpAppsErrorKind::UnsupportedMethod)), + } +} + +async fn handle_open( + params: &Value, + connection: &mut ConnectionContext, + relay: &Arc, +) -> Result { + let request: McpAppOpenRequest = decode(params)?; + validate_versions(&request.envelope_version, &request.apps_protocol_version)?; + let (mcp_protocol_version, binding) = relay + .open_app(connection.id(), &request) + .await + .map_err(map_relay_error)?; + if binding.owner_connection_id != connection.id() { + return Err(outer_error(McpAppsErrorKind::InvalidSession)); + } + let response = McpAppOpenResponse { + envelope_version: MCP_APPS_ENVELOPE_VERSION.into(), + apps_protocol_version: MCP_APPS_PROTOCOL_VERSION.into(), + mcp_protocol_version, + server_id: binding.server_id.clone(), + app_session_id: binding.app_session_id.clone(), + resource_uri: binding.resource_uri.clone(), + }; + if !connection.insert_app_session(binding) { + relay.close_connection(connection.id()); + return Err(outer_error(McpAppsErrorKind::InvalidSession)); + } + serialize(response) +} + +async fn handle_resource( + params: &Value, + connection: &ConnectionContext, + relay: &Arc, +) -> Result { + let request: McpResourceRequest = decode(params)?; + validate_versions(&request.envelope_version, &request.apps_protocol_version)?; + let binding = connection + .app_session(&request.app_session_id) + .filter(|binding| { + binding.server_id == request.server_id + && binding.resource_uri == request.resource_uri + && binding.apps_protocol_version == request.apps_protocol_version + }) + .ok_or_else(|| outer_error(McpAppsErrorKind::InvalidSession))?; + let cancellation = connection.cancellation(); + let mcp_protocol_version = tokio::select! { + _ = cancellation.cancelled() => { + return Err(outer_error(McpAppsErrorKind::Cancelled)); + } + result = relay.validate_binding(binding) => result.map_err(map_relay_error)?, + }; + let (resource_protocol_version, resources) = tokio::select! { + _ = cancellation.cancelled() => { + return Err(outer_error(McpAppsErrorKind::Cancelled)); + } + result = relay.read_resource(binding) => result.map_err(map_relay_error)?, + }; + if resource_protocol_version != mcp_protocol_version { + return Err(outer_error(McpAppsErrorKind::StaleServerGeneration)); + } + if !resources + .iter() + .any(|resource| resource.is_valid_app_resource(&request.resource_uri)) + { + return Err(outer_error(McpAppsErrorKind::InvalidResource)); + } + serialize(McpResourceResponse { + envelope_version: MCP_APPS_ENVELOPE_VERSION.into(), + apps_protocol_version: MCP_APPS_PROTOCOL_VERSION.into(), + mcp_protocol_version, + server_id: request.server_id, + resources, + }) +} + +async fn handle_app( + params: &Value, + connection: &ConnectionContext, + relay: &Arc, +) -> Result { + let request: McpAppRequest = decode(params)?; + validate_versions(&request.envelope_version, &request.apps_protocol_version)?; + if request.payload.method != "tools/call" { + return Err(outer_error(McpAppsErrorKind::UnsupportedMethod)); + } + let binding = connection + .app_session(&request.app_session_id) + .filter(|binding| { + binding.server_id == request.server_id + && binding.resource_uri == request.resource_uri + && binding.apps_protocol_version == request.apps_protocol_version + }) + .ok_or_else(|| outer_error(McpAppsErrorKind::InvalidSession))?; + let cancellation = connection.cancellation(); + let mcp_protocol_version = tokio::select! { + _ = cancellation.cancelled() => { + return Err(outer_error(McpAppsErrorKind::Cancelled)); + } + result = relay.validate_binding(binding) => result.map_err(map_relay_error)?, + }; + let request_id = request.payload.id.clone(); + let (response_protocol_version, payload) = tokio::select! { + _ = cancellation.cancelled() => { + return Err(outer_error(McpAppsErrorKind::Cancelled)); + } + result = relay.call_tool(binding, request.payload) => result.map_err(map_relay_error)?, + }; + if response_protocol_version != mcp_protocol_version { + return Err(outer_error(McpAppsErrorKind::StaleServerGeneration)); + } + if payload.id() != &request_id { + return Err(outer_error(McpAppsErrorKind::UpstreamProtocolError)); + } + serialize(McpAppResponse { + envelope_version: MCP_APPS_ENVELOPE_VERSION.into(), + apps_protocol_version: MCP_APPS_PROTOCOL_VERSION.into(), + mcp_protocol_version, + server_id: request.server_id, + app_session_id: request.app_session_id, + resource_uri: request.resource_uri, + payload, + }) +} + +fn validate_versions(envelope: &str, apps: &str) -> Result<(), AcpError> { + if envelope != MCP_APPS_ENVELOPE_VERSION { + return Err(outer_error(McpAppsErrorKind::UnsupportedEnvelopeVersion)); + } + if apps != MCP_APPS_PROTOCOL_VERSION { + return Err(outer_error(McpAppsErrorKind::UnsupportedAppsVersion)); + } + Ok(()) +} + +fn decode(value: &Value) -> Result { + serde_json::from_value(value.clone()) + .map_err(|_| outer_error(McpAppsErrorKind::InvalidEnvelope)) +} + +fn serialize(value: T) -> Result { + serde_json::to_value(value).map_err(|_| AcpError::new(-32603, "MCP Apps relay request failed")) +} + +fn map_relay_error(error: McpAppsRelayError) -> AcpError { + outer_error(error.kind) +} + +fn outer_error(kind: McpAppsErrorKind) -> AcpError { + let kind = + serde_json::to_value(kind).unwrap_or(Value::String("upstream_protocol_error".into())); + AcpError::new(-32000, "MCP Apps relay request failed") + .with_data(serde_json::json!({"kind": kind})) +} + +#[allow(dead_code)] +pub(crate) fn initial_binding( + owner_connection_id: String, + owner_session_id: String, + server_id: String, + server_generation: u64, + resource_uri: String, + instantiating_tool: String, +) -> AppSessionBinding { + AppSessionBinding { + app_session_id: uuid::Uuid::now_v7().to_string(), + owner_connection_id, + owner_session_id, + server_id, + server_generation, + resource_uri, + instantiating_tool, + apps_protocol_version: MCP_APPS_PROTOCOL_VERSION.into(), + } +} + +#[cfg(test)] +#[path = "mcp_apps_test.rs"] +mod tests; diff --git a/peri-acp/src/host/mcp_apps_test.rs b/peri-acp/src/host/mcp_apps_test.rs new file mode 100644 index 00000000..079671fd --- /dev/null +++ b/peri-acp/src/host/mcp_apps_test.rs @@ -0,0 +1,22 @@ +use super::*; + +#[test] +fn version_validation_is_fail_closed() { + assert!(validate_versions(MCP_APPS_ENVELOPE_VERSION, MCP_APPS_PROTOCOL_VERSION).is_ok()); + let error = validate_versions("2", MCP_APPS_PROTOCOL_VERSION).unwrap_err(); + assert_eq!(error.data.unwrap()["kind"], "unsupported_envelope_version"); +} + +#[test] +fn initial_binding_uses_stable_apps_version() { + let binding = initial_binding( + "connection".into(), + "session".into(), + "server".into(), + 7, + "ui://app".into(), + "open".into(), + ); + assert_eq!(binding.server_generation, 7); + assert_eq!(binding.apps_protocol_version, MCP_APPS_PROTOCOL_VERSION); +} diff --git a/peri-acp/src/host/mod.rs b/peri-acp/src/host/mod.rs index 5feac56e..07c3f627 100644 --- a/peri-acp/src/host/mod.rs +++ b/peri-acp/src/host/mod.rs @@ -42,12 +42,14 @@ use peri_acp_types::event_data::PredictionAction; pub mod assemble; pub(crate) mod compact_config; +mod connection; mod continuation; pub mod controller_ports; #[cfg(test)] #[path = "executor_flow_test.rs"] mod executor_flow_tests; pub mod lease; +mod mcp_apps; mod notify; mod prediction_projection; mod prompt; @@ -124,6 +126,8 @@ pub struct AcpServerConfig { pub permission_mode: Arc, pub cron_scheduler: Option>, pub mcp_pool: Option>, + /// Optional stdio-only MCP Apps backend. Absence keeps the capability fail closed. + pub mcp_apps_relay: Option>, pub dynamic_mcp: Option>, /// OAuth 授权事件通道(host 级,跨 session):装配点创建 (tx, rx) 并注入 /// tx(MCP 授权回调经此转发 AcpEvent),run_acp_server take rx 后 spawn @@ -500,6 +504,10 @@ async fn run_acp_server_inner( ), ); + let connection = Arc::new(tokio::sync::Mutex::new(connection::ConnectionContext::new( + cfg.stdio_command_filter && cfg.mcp_apps_relay.is_some(), + ))); + let connection_cancellation = connection.lock().await.cancellation(); while let Some(msg) = transport.recv().await { match msg { IncomingMessage::Request { id, method, params } => { @@ -507,6 +515,11 @@ async fn run_acp_server_inner( // Spawn long-running prompt execution so the server loop // continues processing session/cancel notifications. let prompt_session_id = extract_session_id(¶ms, "").to_string(); + if !prompt_session_id.is_empty() { + if let Some(relay) = cfg.mcp_apps_relay.as_ref() { + relay.begin_session_turn(&prompt_session_id); + } + } let sessions = sessions.clone(); let transport = Arc::clone(&transport); let prompt_locks = prompt_locks.clone(); @@ -535,10 +548,75 @@ async fn run_acp_server_inner( } }, ); + } else if matches!( + method.as_str(), + "peri/mcp/open" | "peri/mcp/app" | "peri/mcp/resource" + ) { + let transport = Arc::clone(&transport); + let relay = cfg.mcp_apps_relay.clone(); + let connection = Arc::clone(&connection); + let app_spawner = cfg.host_task_spawner.clone(); + let connection_cancellation = connection_cancellation.clone(); + let rejected_transport = Arc::clone(&transport); + let rejected_id = id.clone(); + let spawn_result = app_spawner.spawn( + task_scope::HostTaskOwnerKind::Connection, + task_scope::HostTaskKind::McpAppsRelay, + async move { + let result = tokio::select! { + _ = connection_cancellation.cancelled() => { + Err(crate::transport::types::AcpError::new(-32800, "request cancelled")) + } + result = async { + match method.as_str() { + "peri/mcp/open" => { + let mut connection = connection.lock().await; + mcp_apps::handle_request( + &method, + ¶ms, + &mut connection, + relay.as_ref(), + ) + .await + } + _ => { + let mut request_connection = { + let connection = connection.lock().await; + connection.snapshot_for_request() + }; + mcp_apps::handle_request( + &method, + ¶ms, + &mut request_connection, + relay.as_ref(), + ) + .await + } + } + } => result, + }; + let _ = transport.send_response(id, result).await; + }, + ); + if spawn_result.is_err() { + let _ = rejected_transport + .send_response( + rejected_id, + Err(crate::transport::types::AcpError::new( + -32800, + "request cancelled", + )), + ) + .await; + } } else { - let mut sessions = sessions.lock().await; - let result = - handle_request(&method, ¶ms, &cfg, &mut sessions, &transport).await; + let result = { + let mut sessions = sessions.lock().await; + handle_request(&method, ¶ms, &cfg, &mut sessions, &transport).await + }; + if method == "initialize" && result.is_ok() { + connection.lock().await.commit_initialize(); + } let new_session_id = (method == "session/new") .then(|| { result @@ -563,6 +641,14 @@ async fn run_acp_server_inner( } } IncomingMessage::Notification { method, params } => { + if method == "session/cancel" { + let session_id = extract_session_id(¶ms, ""); + if !session_id.is_empty() { + if let Some(relay) = cfg.mcp_apps_relay.as_ref() { + relay.close_session(session_id); + } + } + } // session/cancel 可能需要在锁外补发 continuation 请求 // (race 兜底:bg 结果已 route 为 Defer,但通知可能在 cancel // 置位前被 scheduler 跳过)。unbounded send 虽不阻塞,仍统一 @@ -582,6 +668,12 @@ async fn run_acp_server_inner( } // Transport EOF is the host's single ownership transaction. + connection_cancellation.cancel(); + let connection_id = connection.lock().await.id().to_string(); + if let Some(relay) = cfg.mcp_apps_relay.as_ref() { + relay.close_connection(&connection_id); + } + connection.lock().await.begin_close(); task_owner.begin_shutdown(); if let Some(dynamic_mcp) = cfg.dynamic_mcp.as_ref() { dynamic_mcp.begin_shutdown(); diff --git a/peri-acp/src/host/requests_test.rs b/peri-acp/src/host/requests_test.rs index 8b4d0e5e..c68f5e1e 100644 --- a/peri-acp/src/host/requests_test.rs +++ b/peri-acp/src/host/requests_test.rs @@ -130,6 +130,7 @@ fn make_server_config( permission_mode: SharedPermissionMode::new(PermissionMode::Bypass), cron_scheduler: None, mcp_pool: None, + mcp_apps_relay: None, dynamic_mcp: None, oauth_event_tx: None, oauth_event_rx: None, diff --git a/peri-acp/src/host/stdio/mod.rs b/peri-acp/src/host/stdio/mod.rs index a61023cc..bfd23b2b 100644 --- a/peri-acp/src/host/stdio/mod.rs +++ b/peri-acp/src/host/stdio/mod.rs @@ -141,8 +141,9 @@ async fn assemble_stdio_config(input: StdioInput) -> anyhow::Result anyhow::Result AgentResult { .collect(); let tool_refs: Vec<&dyn crate::tools::BaseTool> = tools_owned .iter() - .filter(|t| t.is_direct()) + .filter(|t| t.is_direct() && t.visible_to_model()) .map(|t| t.as_ref()) .collect(); // 工具数量与名称追踪(调试用;默认 filter 下不写盘) diff --git a/peri-agent/src/agent/stages/tool_dispatch.rs b/peri-agent/src/agent/stages/tool_dispatch.rs index 5bdbd017..b4c62b97 100644 --- a/peri-agent/src/agent/stages/tool_dispatch.rs +++ b/peri-agent/src/agent/stages/tool_dispatch.rs @@ -614,11 +614,21 @@ async fn dispatch_concurrent( let ctx_param = crate::tools::ToolContext::new(&messages, &cwd) .with_effective_tool_dispatcher( Arc::new(StageEffectiveToolDispatcher::new( - dispatch_context, + dispatch_context.clone(), dispatch_catalog, )), raw_call.id.clone(), cancel.clone(), + ) + .with_session_identity( + dispatch_context + .session + .session_context + .read() + .get("session_id") + .cloned() + .unwrap_or_else(|| dispatch_context.session.agent_id.to_string()), + dispatch_context.session.turn.turn_id.to_string(), ); match tool { Some(t) => t.invoke(input, ctx_param).await.map_err(|e| { diff --git a/peri-agent/src/session/tool_catalog.rs b/peri-agent/src/session/tool_catalog.rs index 19d4f331..58d0dd17 100644 --- a/peri-agent/src/session/tool_catalog.rs +++ b/peri-agent/src/session/tool_catalog.rs @@ -271,7 +271,7 @@ fn finalize( ) -> Result { let direct_definitions = tools .values() - .filter(|entry| entry.tool.is_direct()) + .filter(|entry| entry.tool.is_direct() && entry.tool.visible_to_model()) .map(|entry| entry.tool.definition()) .collect(); let mut aliases = BTreeMap::new(); diff --git a/peri-middlewares/src/mcp/apps.rs b/peri-middlewares/src/mcp/apps.rs new file mode 100644 index 00000000..cbe1e85a --- /dev/null +++ b/peri-middlewares/src/mcp/apps.rs @@ -0,0 +1,419 @@ +use std::{ + collections::{BTreeSet, HashMap}, + sync::Arc, + time::{Duration, Instant}, +}; + +use parking_lot::Mutex; +use peri_acp_types::tools::EffectiveToolDispatcher; + +use async_trait::async_trait; +use serde_json::{Map, Value}; +use thiserror::Error; + +pub const MCP_APPS_VERSION: &str = "2026-01-26"; +pub const MCP_APP_MIME_TYPE: &str = "text/html;profile=mcp-app"; +pub const MCP_UI_EXTENSION: &str = "io.modelcontextprotocol/ui"; +const BINDING_LEASE_TTL: Duration = Duration::from_secs(300); +const RAW_RESULT_TTL: Duration = Duration::from_secs(120); +const MAX_PENDING_LEASES: usize = 1024; +const MAX_RAW_RESULTS: usize = 1024; + +#[derive(Clone)] +pub struct McpAppBindingLease { + pub owner_session_id: String, + pub owner_connection_id: Option, + pub turn_generation: String, + pub server_id: String, + pub server_generation: u64, + pub resource_uri: String, + pub instantiating_tool: String, + pub invocation_token: String, + pub allowed_tools: HashMap, + pub dispatcher: Arc, + pub cancellation: tokio_util::sync::CancellationToken, + expires_at: Instant, +} + +impl McpAppBindingLease { + // Identity, routing, dispatcher and revocation fields are all mandatory security inputs. + #[allow(clippy::too_many_arguments)] + pub fn new( + owner_session_id: String, + turn_generation: String, + server_id: String, + server_generation: u64, + resource_uri: String, + instantiating_tool: String, + invocation_token: String, + allowed_tools: HashMap, + dispatcher: Arc, + cancellation: tokio_util::sync::CancellationToken, + ) -> Self { + Self { + owner_session_id, + owner_connection_id: None, + turn_generation, + server_id, + server_generation, + resource_uri, + instantiating_tool, + invocation_token, + allowed_tools, + dispatcher, + cancellation, + expires_at: Instant::now() + BINDING_LEASE_TTL, + } + } + + pub fn is_valid(&self) -> bool { + Instant::now() < self.expires_at && !self.cancellation.is_cancelled() + } +} + +#[derive(Default)] +pub struct McpAppBindingLeaseRegistry { + leases: Mutex>>, + current_turns: Mutex>, + raw_results: Mutex>, +} + +impl McpAppBindingLeaseRegistry { + fn cleanup(&self) { + let mut leases = self.leases.lock(); + leases.retain(|_, values| { + values.retain(McpAppBindingLease::is_valid); + !values.is_empty() + }); + while leases.values().map(Vec::len).sum::() > MAX_PENDING_LEASES { + let Some(key) = leases.keys().next().cloned() else { + break; + }; + if let Some(values) = leases.get_mut(&key) { + values.remove(0); + if values.is_empty() { + leases.remove(&key); + } + } + } + drop(leases); + + let now = Instant::now(); + let mut raw_results = self.raw_results.lock(); + raw_results.retain(|_, (expires_at, _)| now < *expires_at); + while raw_results.len() > MAX_RAW_RESULTS { + let Some(key) = raw_results.keys().next().cloned() else { + break; + }; + raw_results.remove(&key); + } + } + + pub fn begin_session_turn(&self, owner_session_id: &str) { + self.revoke_session(owner_session_id); + } + + pub fn revoke_session(&self, owner_session_id: &str) { + self.current_turns.lock().remove(owner_session_id); + let mut leases = self.leases.lock(); + leases.retain(|_, values| { + values.retain(|lease| { + if lease.owner_session_id == owner_session_id { + lease.cancellation.cancel(); + false + } else { + lease.is_valid() + } + }); + !values.is_empty() + }); + } + + pub fn issue(&self, mut lease: McpAppBindingLease) { + self.cleanup(); + lease.expires_at = Instant::now() + BINDING_LEASE_TTL; + self.current_turns.lock().insert( + lease.owner_session_id.clone(), + lease.turn_generation.clone(), + ); + let key = (lease.server_id.clone(), lease.instantiating_tool.clone()); + self.leases.lock().entry(key).or_default().push(lease); + self.cleanup(); + } + + pub fn is_current_turn(&self, lease: &McpAppBindingLease) -> bool { + lease.is_valid() + && self + .current_turns + .lock() + .get(&lease.owner_session_id) + .is_some_and(|turn| turn == &lease.turn_generation) + } + + pub fn record_raw_result(&self, invocation_id: &str, result: RawCallToolResult) { + self.cleanup(); + self.raw_results.lock().insert( + invocation_id.to_string(), + (Instant::now() + RAW_RESULT_TTL, result), + ); + self.cleanup(); + } + + pub fn take_raw_result(&self, invocation_id: &str) -> Option { + self.cleanup(); + self.raw_results + .lock() + .remove(invocation_id) + .map(|(_, result)| result) + } + + pub fn purge_raw_results_for_connection(&self, owner_connection_id: &str) { + let prefix = format!("mcp-app:{owner_connection_id}:"); + self.raw_results + .lock() + .retain(|invocation_id, _| !invocation_id.starts_with(&prefix)); + } + + #[allow(clippy::too_many_arguments)] + pub fn consume( + &self, + server_id: &str, + tool_name: &str, + generation: u64, + resource_uri: &str, + owner_session_id: &str, + invocation_token: &str, + owner_connection_id: &str, + ) -> Option { + self.cleanup(); + let key = (server_id.to_string(), tool_name.to_string()); + let current_turns = self.current_turns.lock().clone(); + let mut leases = self.leases.lock(); + let values = leases.get_mut(&key)?; + let index = values.iter().position(|lease| { + lease.is_valid() + && lease.owner_session_id == owner_session_id + && lease.invocation_token == invocation_token + && current_turns + .get(&lease.owner_session_id) + .is_some_and(|turn| turn == &lease.turn_generation) + && lease.server_generation == generation + && lease.resource_uri == resource_uri + })?; + let mut lease = values.remove(index); + lease.owner_connection_id = Some(owner_connection_id.to_string()); + if values.is_empty() { + leases.remove(&key); + } + Some(lease) + } +} + +pub const MCP_APPS_ENV: &str = "PERI_MCP_APPS"; + +pub fn deployment_profile(apps_enabled: bool) -> McpCapabilityProfile { + if apps_enabled { + McpCapabilityProfile::negotiated([MCP_APP_MIME_TYPE]) + } else { + McpCapabilityProfile::disabled() + } +} + +/// 由 ACP connection 注入的不可变 MCP capability profile。 +/// +/// 默认值关闭 Apps;只有显式协商出的受支持 MIME 才会进入 profile。 +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct McpCapabilityProfile { + apps_mime_types: BTreeSet, +} + +impl McpCapabilityProfile { + pub fn disabled() -> Self { + Self::default() + } + + pub fn negotiated<'a>(mime_types: impl IntoIterator) -> Self { + let apps_mime_types = mime_types + .into_iter() + .filter(|mime| *mime == MCP_APP_MIME_TYPE) + .map(str::to_owned) + .collect(); + Self { apps_mime_types } + } + + pub fn apps_enabled(&self) -> bool { + !self.apps_mime_types.is_empty() + } + + pub fn apps_mime_types(&self) -> impl Iterator { + self.apps_mime_types.iter().map(String::as_str) + } + + pub(crate) fn ui_extension(&self) -> Option> { + self.apps_enabled().then(|| { + Map::from_iter([( + "mimeTypes".to_string(), + Value::Array( + self.apps_mime_types() + .map(|mime| Value::String(mime.to_string())) + .collect(), + ), + )]) + }) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ToolVisibility { + pub model: bool, + pub app: bool, +} + +impl ToolVisibility { + /// 缺失时兼容 model + app;任何 malformed、空数组或未知值均关闭两面。 + pub fn from_tool_meta(meta: Option<&Value>) -> Self { + let Some(meta) = meta else { + return Self { + model: true, + app: true, + }; + }; + let Some(meta) = meta.as_object() else { + return Self { + model: false, + app: false, + }; + }; + let Some(ui) = meta.get("ui") else { + return Self { + model: true, + app: true, + }; + }; + let Some(ui) = ui.as_object() else { + return Self { + model: false, + app: false, + }; + }; + let Some(visibility) = ui.get("visibility") else { + return Self { + model: true, + app: true, + }; + }; + let Some(values) = visibility.as_array() else { + return Self { + model: false, + app: false, + }; + }; + if values.is_empty() + || values + .iter() + .any(|value| !matches!(value.as_str(), Some("model") | Some("app"))) + { + return Self { + model: false, + app: false, + }; + } + Self { + model: values.iter().any(|value| value == "model"), + app: values.iter().any(|value| value == "app"), + } + } +} + +/// 读取并 canonicalize `_meta.ui.resourceUri`;legacy key 仅作兼容输入。 +/// canonical 与 legacy 冲突或值类型错误时 fail closed。 +pub fn canonical_resource_uri(meta: Option<&Value>) -> Option { + let meta = meta?.as_object()?; + let canonical = meta + .get("ui") + .and_then(Value::as_object) + .and_then(|ui| ui.get("resourceUri")); + let legacy = meta.get("ui/resourceUri"); + match (canonical, legacy) { + (Some(left), Some(right)) if left != right => None, + (Some(value), _) | (_, Some(value)) => value + .as_str() + .filter(|uri| uri.starts_with("ui://")) + .map(str::to_owned), + (None, None) => None, + } +} + +pub fn tool_visibility(tool: &rmcp::model::Tool) -> ToolVisibility { + let value = serde_json::to_value(tool).unwrap_or(Value::Null); + ToolVisibility::from_tool_meta(value.get("_meta")) +} + +pub fn tool_resource_uri(tool: &rmcp::model::Tool) -> Option { + let value = serde_json::to_value(tool).ok()?; + canonical_resource_uri(value.get("_meta")) +} + +pub fn raw_tool(tool: &rmcp::model::Tool) -> RawMcpTool { + serde_json::to_value(tool).unwrap_or(Value::Null) +} + +pub fn raw_resource(resource: &rmcp::model::Resource) -> RawMcpResource { + serde_json::to_value(resource).unwrap_or(Value::Null) +} + +/// Raw MCP payload stays as JSON until the legacy model projection boundary. +pub type RawMcpTool = Value; +pub type RawMcpResource = Value; +pub type RawCallToolResult = Value; + +#[derive(Debug, Error, PartialEq, Eq)] +pub enum McpAppsInvocationError { + #[error("MCP Apps canonical invocation seam is unavailable")] + Unavailable, + #[error("MCP App tool is not visible to this session")] + Forbidden, + #[error("MCP App tool invocation failed")] + InvocationFailed, +} + +/// 只能由 session-local canonical dispatcher/HITL 实现;不得以 MCP peer 实现此 seam。 +#[async_trait] +pub trait McpAppsInvocationSeam: Send + Sync { + async fn call_tool( + &self, + effective_tool_name: &str, + arguments: Value, + ) -> Result; +} + +#[derive(Clone, Default)] +pub struct McpAppsInvoker { + seam: Option>, +} + +impl McpAppsInvoker { + pub fn unavailable() -> Self { + Self::default() + } + + pub fn with_seam(seam: Arc) -> Self { + Self { seam: Some(seam) } + } + + pub async fn call_tool( + &self, + effective_tool_name: &str, + arguments: Value, + ) -> Result { + let seam = self + .seam + .as_ref() + .ok_or(McpAppsInvocationError::Unavailable)?; + seam.call_tool(effective_tool_name, arguments).await + } +} + +#[cfg(test)] +#[path = "apps_test.rs"] +mod tests; diff --git a/peri-middlewares/src/mcp/apps_relay.rs b/peri-middlewares/src/mcp/apps_relay.rs new file mode 100644 index 00000000..daf92872 --- /dev/null +++ b/peri-middlewares/src/mcp/apps_relay.rs @@ -0,0 +1,284 @@ +use std::{collections::HashMap, sync::Arc}; + +use async_trait::async_trait; +use parking_lot::Mutex; +use peri_acp_types::{ + mcp_apps::{ + AppSessionBinding, JsonRpcRequest, JsonRpcResponse, JsonRpcResultResponse, + McpAppOpenRequest, McpAppsErrorKind, McpAppsRelayError, McpAppsRelayPort, RawResource, + }, + tools::{EffectiveToolCall, EffectiveToolErrorCode}, +}; + +use super::{ + apps::{tool_resource_uri, tool_visibility}, + client::McpClientPool, +}; + +const MAX_ACTIVE_LEASES: usize = 1024; + +/// Production MCP Apps relay backed by the deployment-scoped pool. +/// +/// Resource/catalog access and App `tools/call` are backed by the same deployment pool. +/// Tool calls require a consumed, connection-bound canonical dispatcher lease. +pub struct PoolMcpAppsRelay { + pool: Arc, + active_leases: Mutex>, +} + +impl PoolMcpAppsRelay { + pub fn new(pool: Arc) -> Self { + Self { + pool, + active_leases: Mutex::new(HashMap::new()), + } + } + + fn cleanup_active_leases(&self) { + let mut leases = self.active_leases.lock(); + leases.retain(|_, lease| lease.is_valid()); + while leases.len() > MAX_ACTIVE_LEASES { + let Some(key) = leases.keys().next().cloned() else { + break; + }; + if let Some(lease) = leases.remove(&key) { + lease.cancellation.cancel(); + } + } + } + + fn handle( + &self, + server_id: &str, + ) -> Result, McpAppsRelayError> { + self.pool + .get_client(server_id) + .filter(|handle| handle.peer.is_some()) + .ok_or_else(|| relay_error(McpAppsErrorKind::ServerDisconnected)) + } +} + +#[async_trait] +impl McpAppsRelayPort for PoolMcpAppsRelay { + fn close_connection(&self, owner_connection_id: &str) { + self.pool + .app_binding_leases + .purge_raw_results_for_connection(owner_connection_id); + let mut active = self.active_leases.lock(); + active.retain(|_, lease| { + let keep = lease.owner_connection_id.as_deref() != Some(owner_connection_id); + if !keep { + lease.cancellation.cancel(); + } + keep + }); + } + + fn close_session(&self, owner_session_id: &str) { + self.pool + .app_binding_leases + .revoke_session(owner_session_id); + let mut active = self.active_leases.lock(); + active.retain(|_, lease| { + let keep = lease.owner_session_id != owner_session_id; + if !keep { + lease.cancellation.cancel(); + } + keep + }); + } + + fn begin_session_turn(&self, owner_session_id: &str) { + self.close_session(owner_session_id); + } + + async fn open_app( + &self, + owner_connection_id: &str, + request: &McpAppOpenRequest, + ) -> Result<(String, AppSessionBinding), McpAppsRelayError> { + let handle = self.handle(&request.server_id)?; + let tool = handle + .tools + .iter() + .find(|tool| tool.name.as_ref() == request.tool_name) + .ok_or_else(|| relay_error(McpAppsErrorKind::ToolNotFound))?; + if !tool_visibility(tool).app { + return Err(relay_error(McpAppsErrorKind::ToolNotAppVisible)); + } + let resource_uri = tool_resource_uri(tool) + .ok_or_else(|| relay_error(McpAppsErrorKind::InvalidResource))?; + let generation = self.pool.handle_generation(&handle); + let lease = self + .pool + .app_binding_leases + .consume( + &request.server_id, + &request.tool_name, + generation, + &resource_uri, + &request.owner_session_id, + &request.invocation_token, + owner_connection_id, + ) + .ok_or_else(|| relay_error(McpAppsErrorKind::PolicyDenied))?; + let app_session_id = uuid::Uuid::now_v7().to_string(); + let binding = AppSessionBinding { + app_session_id: app_session_id.clone(), + owner_connection_id: owner_connection_id.to_string(), + owner_session_id: lease.owner_session_id.clone(), + server_id: request.server_id.clone(), + server_generation: generation, + resource_uri, + instantiating_tool: request.tool_name.clone(), + apps_protocol_version: request.apps_protocol_version.clone(), + }; + self.cleanup_active_leases(); + self.active_leases.lock().insert(app_session_id, lease); + self.cleanup_active_leases(); + let protocol = handle + .peer + .as_ref() + .and_then(|peer| peer.peer_info()) + .map(|info| info.protocol_version.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + Ok((protocol, binding)) + } + + async fn validate_binding( + &self, + binding: &AppSessionBinding, + ) -> Result { + self.cleanup_active_leases(); + let handle = self.handle(&binding.server_id)?; + if self.pool.handle_generation(&handle) != binding.server_generation { + return Err(relay_error(McpAppsErrorKind::StaleServerGeneration)); + } + handle + .peer + .as_ref() + .and_then(|peer| peer.peer_info()) + .map(|info| info.protocol_version.to_string()) + .ok_or_else(|| relay_error(McpAppsErrorKind::ServerDisconnected)) + } + + async fn read_resource( + &self, + binding: &AppSessionBinding, + ) -> Result<(String, Vec), McpAppsRelayError> { + let handle = self.handle(&binding.server_id)?; + if self.pool.handle_generation(&handle) != binding.server_generation { + return Err(relay_error(McpAppsErrorKind::StaleServerGeneration)); + } + let peer = handle + .peer + .as_ref() + .ok_or_else(|| relay_error(McpAppsErrorKind::ServerDisconnected))?; + let result = peer + .read_resource(rmcp::model::ReadResourceRequestParams::new( + binding.resource_uri.clone(), + )) + .await + .map_err(|_| relay_error(McpAppsErrorKind::ResourceNotFound))?; + let raw: Vec = serde_json::to_value(result) + .ok() + .and_then(|value| value.get("contents")?.as_array().cloned()) + .and_then(|values| { + values + .into_iter() + .map(serde_json::from_value) + .collect::, _>>() + .ok() + }) + .filter(|values| !values.is_empty()) + .ok_or_else(|| relay_error(McpAppsErrorKind::InvalidResource))?; + let protocol = peer + .peer_info() + .map(|info| info.protocol_version.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + Ok((protocol, raw)) + } + + async fn call_tool( + &self, + binding: &AppSessionBinding, + request: JsonRpcRequest, + ) -> Result<(String, JsonRpcResponse), McpAppsRelayError> { + self.cleanup_active_leases(); + let protocol = self.validate_binding(binding).await?; + let lease = self + .active_leases + .lock() + .get(&binding.app_session_id) + .cloned() + .filter(super::apps::McpAppBindingLease::is_valid) + .filter(|lease| self.pool.app_binding_leases.is_current_turn(lease)) + .ok_or_else(|| relay_error(McpAppsErrorKind::PolicyDenied))?; + if lease.owner_session_id != binding.owner_session_id + || lease.server_generation != binding.server_generation + { + return Err(relay_error(McpAppsErrorKind::StaleServerGeneration)); + } + let effective_tool_name = lease + .allowed_tools + .get(&request.params.name) + .cloned() + .ok_or_else(|| relay_error(McpAppsErrorKind::Forbidden))?; + let invocation_id = format!( + "mcp-app:{}:{}", + binding.owner_connection_id, + uuid::Uuid::new_v4() + ); + let result = lease + .dispatcher + .dispatch( + EffectiveToolCall { + invocation_id: invocation_id.clone(), + tool_name: effective_tool_name, + input: serde_json::Value::Object(request.params.arguments), + parent_invocation_id: None, + }, + lease.cancellation.child_token(), + ) + .await; + let response = match result { + Ok(_) => { + let raw = self + .pool + .app_binding_leases + .take_raw_result(&invocation_id) + .ok_or_else(|| relay_error(McpAppsErrorKind::UpstreamProtocolError))?; + JsonRpcResponse::Result(JsonRpcResultResponse { + jsonrpc: request.jsonrpc, + id: request.id, + result: raw, + }) + } + Err(error) => { + // MCP `isError: true` is still a protocol-successful raw CallToolResult. + // McpToolBridge records it before projecting the model-facing error text. + if let Some(raw) = self.pool.app_binding_leases.take_raw_result(&invocation_id) { + JsonRpcResponse::Result(JsonRpcResultResponse { + jsonrpc: request.jsonrpc, + id: request.id, + result: raw, + }) + } else { + let kind = match error.code { + EffectiveToolErrorCode::Cancelled => McpAppsErrorKind::Cancelled, + EffectiveToolErrorCode::PermissionDenied + | EffectiveToolErrorCode::UserRejected => McpAppsErrorKind::PolicyDenied, + EffectiveToolErrorCode::UnknownTool => McpAppsErrorKind::ToolNotFound, + _ => McpAppsErrorKind::UpstreamProtocolError, + }; + return Err(relay_error(kind)); + } + } + }; + Ok((protocol, response)) + } +} + +fn relay_error(kind: McpAppsErrorKind) -> McpAppsRelayError { + McpAppsRelayError { kind } +} diff --git a/peri-middlewares/src/mcp/apps_test.rs b/peri-middlewares/src/mcp/apps_test.rs new file mode 100644 index 00000000..5f75d418 --- /dev/null +++ b/peri-middlewares/src/mcp/apps_test.rs @@ -0,0 +1,264 @@ +use super::*; +use async_trait::async_trait; +use peri_acp_types::tools::{ + EffectiveToolCall, EffectiveToolDefinition, EffectiveToolDispatcher, EffectiveToolError, +}; + +struct FakeDispatcher; + +#[async_trait] +impl EffectiveToolDispatcher for FakeDispatcher { + async fn dispatch( + &self, + _call: EffectiveToolCall, + _cancel: tokio_util::sync::CancellationToken, + ) -> Result { + Ok("ok".into()) + } + + fn tools(&self) -> Vec { + Vec::new() + } +} + +#[test] +fn binding_lease_is_single_consume_and_cancel_aware() { + let registry = McpAppBindingLeaseRegistry::default(); + let cancel = tokio_util::sync::CancellationToken::new(); + registry.issue(McpAppBindingLease::new( + "session".into(), + "turn".into(), + "server".into(), + 7, + "ui://app".into(), + "tool".into(), + "token-1".into(), + HashMap::from([("tool".into(), "mcp__server__tool".into())]), + Arc::new(FakeDispatcher), + cancel.clone(), + )); + assert!(registry + .consume( + "server", + "tool", + 8, + "ui://app", + "session", + "token-1", + "connection" + ) + .is_none()); + assert!(registry + .consume( + "server", + "tool", + 7, + "ui://other", + "session", + "token-1", + "connection" + ) + .is_none()); + assert!(registry + .consume( + "server", + "tool", + 7, + "ui://app", + "session", + "wrong-token", + "connection" + ) + .is_none()); + let consumed = registry + .consume( + "server", + "tool", + 7, + "ui://app", + "session", + "token-1", + "connection", + ) + .expect("matching lease should be consumed once"); + assert!(registry.is_current_turn(&consumed)); + assert!(registry + .consume( + "server", + "tool", + 7, + "ui://app", + "session", + "token-1", + "connection" + ) + .is_none()); + + registry.issue(McpAppBindingLease::new( + "session".into(), + "turn-2".into(), + "server".into(), + 7, + "ui://app".into(), + "tool".into(), + "token-1".into(), + HashMap::from([("tool".into(), "mcp__server__tool".into())]), + Arc::new(FakeDispatcher), + cancel.clone(), + )); + assert!(!registry.is_current_turn(&consumed)); + cancel.cancel(); + assert!(registry + .consume( + "server", + "tool", + 7, + "ui://app", + "session", + "token-1", + "connection" + ) + .is_none()); +} + +#[test] +fn connection_cleanup_purges_only_owned_raw_results() { + let registry = McpAppBindingLeaseRegistry::default(); + registry.record_raw_result( + "mcp-app:connection-a:one", + serde_json::json!({"content": []}), + ); + registry.record_raw_result( + "mcp-app:connection-b:two", + serde_json::json!({"content": []}), + ); + + registry.purge_raw_results_for_connection("connection-a"); + + assert!(registry + .take_raw_result("mcp-app:connection-a:one") + .is_none()); + assert!(registry + .take_raw_result("mcp-app:connection-b:two") + .is_some()); +} + +#[test] +fn deployment_presence_enables_apps_regardless_of_value() { + assert!(deployment_profile(true).apps_enabled()); + assert!(!deployment_profile(false).apps_enabled()); +} + +#[test] +fn profile_only_negotiates_supported_mime() { + let profile = + McpCapabilityProfile::negotiated(["text/plain", MCP_APP_MIME_TYPE, MCP_APP_MIME_TYPE]); + assert_eq!( + profile.apps_mime_types().collect::>(), + [MCP_APP_MIME_TYPE] + ); +} + +#[test] +fn negotiated_profile_builds_ui_extension() { + let profile = McpCapabilityProfile::negotiated([MCP_APP_MIME_TYPE]); + assert_eq!( + profile.ui_extension(), + Some(Map::from_iter([( + "mimeTypes".to_string(), + serde_json::json!([MCP_APP_MIME_TYPE]), + )])) + ); +} + +#[test] +fn disabled_profile_has_no_ui_extension() { + assert!(McpCapabilityProfile::disabled().ui_extension().is_none()); +} + +#[test] +fn visibility_missing_defaults_to_model_and_app() { + assert_eq!( + ToolVisibility::from_tool_meta(None), + ToolVisibility { + model: true, + app: true + } + ); +} + +#[test] +fn app_only_visibility_excludes_model() { + let meta = serde_json::json!({"ui": {"visibility": ["app"]}}); + assert_eq!( + ToolVisibility::from_tool_meta(Some(&meta)), + ToolVisibility { + model: false, + app: true + } + ); +} + +#[test] +fn malformed_ui_metadata_fails_closed() { + let meta = serde_json::json!({"ui": "invalid"}); + assert_eq!( + ToolVisibility::from_tool_meta(Some(&meta)), + ToolVisibility { + model: false, + app: false + } + ); +} + +#[test] +fn unknown_visibility_fails_closed() { + let meta = serde_json::json!({"ui": {"visibility": ["app", "future"]}}); + assert_eq!( + ToolVisibility::from_tool_meta(Some(&meta)), + ToolVisibility { + model: false, + app: false + } + ); +} + +#[test] +fn conflicting_resource_uri_fails_closed() { + let meta = serde_json::json!({ + "ui": {"resourceUri": "ui://canonical"}, + "ui/resourceUri": "ui://legacy" + }); + assert_eq!(canonical_resource_uri(Some(&meta)), None); +} + +#[test] +fn legacy_resource_uri_is_canonicalized() { + let meta = serde_json::json!({"ui/resourceUri": "ui://app/index.html"}); + assert_eq!( + canonical_resource_uri(Some(&meta)).as_deref(), + Some("ui://app/index.html") + ); +} + +#[tokio::test] +async fn invocation_without_canonical_seam_is_unavailable() { + let error = McpAppsInvoker::unavailable() + .call_tool("mcp__server__tool", serde_json::json!({})) + .await + .unwrap_err(); + assert_eq!(error, McpAppsInvocationError::Unavailable); +} + +#[test] +fn raw_result_round_trip_preserves_standard_and_unknown_fields() { + let raw = serde_json::json!({ + "content": [{"type": "resource", "resource": {"uri": "ui://app", "mimeType": MCP_APP_MIME_TYPE, "text": ""}}], + "structuredContent": {"answer": 42}, + "isError": false, + "_meta": {"ui": {"domain": "example"}, "unknown": {"nested": true}} + }); + let encoded = serde_json::to_string(&raw).unwrap(); + let decoded: RawCallToolResult = serde_json::from_str(&encoded).unwrap(); + assert_eq!(decoded, raw); +} diff --git a/peri-middlewares/src/mcp/client.rs b/peri-middlewares/src/mcp/client.rs index 56cc1ca4..775bfd61 100644 --- a/peri-middlewares/src/mcp/client.rs +++ b/peri-middlewares/src/mcp/client.rs @@ -146,11 +146,21 @@ pub fn redact_mcp_error(input: &str) -> String { pub(crate) const SERVER_CACHE_VERSION_EXTENSION: &str = "io.mcpp/server-cache-version"; pub(crate) fn mcpp_client_info() -> InitializeRequestParams { - let mut capabilities = ClientCapabilities::default(); - capabilities.extensions = Some(std::collections::BTreeMap::from([( + mcpp_client_info_for_profile(&super::apps::McpCapabilityProfile::disabled()) +} + +pub(crate) fn mcpp_client_info_for_profile( + profile: &super::apps::McpCapabilityProfile, +) -> InitializeRequestParams { + let mut extensions = std::collections::BTreeMap::from([( SERVER_CACHE_VERSION_EXTENSION.to_string(), serde_json::Map::new(), - )])); + )]); + if let Some(extension) = profile.ui_extension() { + extensions.insert(super::apps::MCP_UI_EXTENSION.to_string(), extension); + } + let mut capabilities = ClientCapabilities::default(); + capabilities.extensions = Some(extensions); InitializeRequestParams::new(capabilities, Implementation::from_build_env()) } @@ -352,6 +362,8 @@ pub struct McpClientPool { service_shutdown: tokio::sync::Mutex, pub(crate) task_spawner: super::task_scope::McpTaskSpawner, pub(crate) clients: parking_lot::RwLock>>, + handle_generations: parking_lot::Mutex>, + next_handle_generation: std::sync::atomic::AtomicU64, pub(crate) services: parking_lot::Mutex>, pub(crate) configs: parking_lot::RwLock>, pub(crate) cache_versions: parking_lot::RwLock>, @@ -385,6 +397,10 @@ pub struct McpClientPool { pub(crate) session_inboxes: parking_lot::RwLock>, /// 跨进程的 MCP Resource Cache;是否写入由响应 scope 与安全上下文共同决定。 pub(crate) resource_cache: super::resource_cache::McpResourceCache, + /// 进程启动时冻结的 deployment capability profile;初始连接和重连复用。 + pub(crate) capability_profile: super::apps::McpCapabilityProfile, + /// 初始模型 MCP tool invocation 签发、`peri/mcp/open` 单次消费的租约。 + pub(crate) app_binding_leases: Arc, } enum ServiceShutdownState { @@ -454,6 +470,24 @@ async fn close_services(services: Vec<(String, McpServiceWrapper)>) -> McpPoolSh } impl McpClientPool { + pub(crate) fn handle_generation(&self, handle: &Arc) -> u64 { + self.handle_generations + .lock() + .get(&handle.name) + .copied() + .unwrap_or(0) + } + + fn advance_handle_generation(&self, name: &str) -> u64 { + let generation = self + .next_handle_generation + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + self.handle_generations + .lock() + .insert(name.to_string(), generation); + generation + } + fn config_allows_persistent_cache(config: &McpServerConfig) -> bool { // `private` 只可在匿名上下文复用。任意静态 header、HTTP query 与 // stdio env 都可能携带 Cookie、API key 或服务自定义凭据,保守禁用。 @@ -820,12 +854,24 @@ impl McpClientPool { } pub fn new_pending_with_spawner(spawner: super::task_scope::McpTaskSpawner) -> Self { + Self::new_pending_with_spawner_and_profile( + spawner, + super::apps::McpCapabilityProfile::disabled(), + ) + } + + pub fn new_pending_with_spawner_and_profile( + spawner: super::task_scope::McpTaskSpawner, + capability_profile: super::apps::McpCapabilityProfile, + ) -> Self { Self { lifecycle: std::sync::atomic::AtomicU8::new(0), lifecycle_registration: parking_lot::Mutex::new(()), service_shutdown: tokio::sync::Mutex::new(ServiceShutdownState::Idle), task_spawner: spawner, clients: parking_lot::RwLock::new(HashMap::new()), + handle_generations: parking_lot::Mutex::new(HashMap::new()), + next_handle_generation: std::sync::atomic::AtomicU64::new(1), services: parking_lot::Mutex::new(HashMap::new()), configs: parking_lot::RwLock::new(HashMap::new()), cache_versions: parking_lot::RwLock::new(HashMap::new()), @@ -839,6 +885,8 @@ impl McpClientPool { active_oauth_flows: parking_lot::Mutex::new(HashMap::new()), session_inboxes: parking_lot::RwLock::new(HashMap::new()), resource_cache: super::resource_cache::McpResourceCache::new(), + capability_profile, + app_binding_leases: Arc::new(super::apps::McpAppBindingLeaseRegistry::default()), } } @@ -1163,6 +1211,7 @@ impl McpClientPool { .map(|c| (c.source.clone(), c.url.clone())) .unwrap_or((None, None)); let old_status = pool.clients.read().get(name).map(|c| c.status.clone()); + pool.advance_handle_generation(name); pool.clients.write().insert( name.to_string(), Arc::new(McpClientHandle { @@ -1210,6 +1259,7 @@ impl McpClientPool { .map(|c| (c.source.clone(), c.url.clone())) .unwrap_or((None, None)); let old_status = pool.clients.read().get(name).map(|c| c.status.clone()); + pool.advance_handle_generation(name); pool.clients.write().insert( name.to_string(), Arc::new(McpClientHandle { @@ -1463,6 +1513,7 @@ impl McpClientPool { if !self.is_open() { return Err(service); } + self.advance_handle_generation(&name); self.services.lock().insert(name.clone(), service); self.clients.write().insert(name, handle); Ok(()) diff --git a/peri-middlewares/src/mcp/client/transport.rs b/peri-middlewares/src/mcp/client/transport.rs index ce67b59a..93818af3 100644 --- a/peri-middlewares/src/mcp/client/transport.rs +++ b/peri-middlewares/src/mcp/client/transport.rs @@ -53,6 +53,7 @@ pub(crate) async fn serve_client_auto( transport: T, channel_handler: Option<&Arc>, protocol_version: Option<&McpProtocolVersion>, + capability_profile: &crate::mcp::apps::McpCapabilityProfile, timeout: std::time::Duration, ) -> Result, tokio::time::error::Elapsed> where @@ -78,7 +79,7 @@ where ConnectionMode::DiscoverDefault => tokio::time::timeout( timeout, rmcp::service::serve_client_with_lifecycle( - super::mcpp_client_info(), + super::mcpp_client_info_for_profile(capability_profile), transport, ClientLifecycleMode::Discover { preferred_versions: vec![ProtocolVersion::V_2026_07_28], @@ -98,7 +99,10 @@ where } ConnectionMode::LegacyDefault => tokio::time::timeout( timeout, - rmcp::service::serve_client(super::mcpp_client_info(), transport), + rmcp::service::serve_client( + super::mcpp_client_info_for_profile(capability_profile), + transport, + ), ) .await .map(|inner| inner.map(McpServiceWrapper::Default)), @@ -251,6 +255,7 @@ mod tests { client_io, None, protocol_version, + &crate::mcp::apps::McpCapabilityProfile::disabled(), std::time::Duration::from_secs(2), ) .await diff --git a/peri-middlewares/src/mcp/client_oauth.rs b/peri-middlewares/src/mcp/client_oauth.rs index 63c46132..5d63988d 100644 --- a/peri-middlewares/src/mcp/client_oauth.rs +++ b/peri-middlewares/src/mcp/client_oauth.rs @@ -168,7 +168,7 @@ impl McpClientPool { let result = tokio::time::timeout( HTTP_CONNECT_TIMEOUT, rmcp::service::serve_client( - super::client::mcpp_client_info(), + super::client::mcpp_client_info_for_profile(&self.capability_profile), build_authed_transport(&url, &headers, auth_manager), ), ) diff --git a/peri-middlewares/src/mcp/client_test.rs b/peri-middlewares/src/mcp/client_test.rs index 85c83526..9718222a 100644 --- a/peri-middlewares/src/mcp/client_test.rs +++ b/peri-middlewares/src/mcp/client_test.rs @@ -48,6 +48,34 @@ impl Drop for TestDropSignal { } } +#[test] +fn server_generation_advances_explicitly_on_each_committed_connection() { + let pool = McpClientPool::new_pending(); + let first = connected_test_handle("server"); + let second = connected_test_handle("server"); + let service = || { + let (entered, _entered_rx) = tokio::sync::oneshot::channel(); + controlled_service( + entered, + Arc::new(tokio::sync::Notify::new()), + Arc::new(std::sync::atomic::AtomicUsize::new(0)), + ) + }; + + assert!(pool + .try_commit_connection("server".into(), Arc::clone(&first), service()) + .is_ok()); + let first_generation = pool.handle_generation(&first); + assert!(pool + .try_commit_connection("server".into(), Arc::clone(&second), service()) + .is_ok()); + let second_generation = pool.handle_generation(&second); + + assert!(first_generation > 0); + assert!(second_generation > first_generation); + assert_eq!(pool.handle_generation(&first), second_generation); +} + #[test] fn test_pool_get_all_clients_filters_disconnected() { let pool = McpClientPool::new_empty(); diff --git a/peri-middlewares/src/mcp/dynamic/staged_connection.rs b/peri-middlewares/src/mcp/dynamic/staged_connection.rs index 878715d9..662f1c38 100644 --- a/peri-middlewares/src/mcp/dynamic/staged_connection.rs +++ b/peri-middlewares/src/mcp/dynamic/staged_connection.rs @@ -379,7 +379,14 @@ pub async fn prepare_single_server( "Dynamic MCP stdio process could not be started", ) })?; - serve_client_auto(transport, None, config.protocol_version.as_ref(), timeout).await + serve_client_auto( + transport, + None, + config.protocol_version.as_ref(), + &oauth_pool.capability_profile, + timeout, + ) + .await } CanonicalDynamicMcpTransport::StreamableHttp { url, headers } => { let headers = resolve_headers(headers, resolver).await?; @@ -462,6 +469,7 @@ pub async fn prepare_single_server( build_authed_transport(url, &headers, auth_manager), None, config.protocol_version.as_ref(), + &oauth_pool.capability_profile, timeout, ) .await diff --git a/peri-middlewares/src/mcp/initialize.rs b/peri-middlewares/src/mcp/initialize.rs index 08a612d2..c90e0595 100644 --- a/peri-middlewares/src/mcp/initialize.rs +++ b/peri-middlewares/src/mcp/initialize.rs @@ -115,6 +115,7 @@ impl McpClientPool { transport, channel_handler.as_ref(), protocol_version, + &pool.capability_profile, timeout, ) .await @@ -160,6 +161,7 @@ impl McpClientPool { build_http_transport(url, headers), channel_handler.as_ref(), protocol_version, + &pool.capability_profile, timeout, ) .await @@ -367,8 +369,14 @@ impl McpClientPool { ref env, } => match spawn_stdio_transport(command, args, env) { Ok(t) => { - serve_client_auto(t, channel_handler.as_ref(), protocol_version, timeout) - .await + serve_client_auto( + t, + channel_handler.as_ref(), + protocol_version, + &pool.capability_profile, + timeout, + ) + .await } Err(e) => { Self::insert_failed(&pool, name, format!("stdio 失败: {e}")); @@ -411,6 +419,7 @@ impl McpClientPool { build_http_transport(url, headers), channel_handler.as_ref(), protocol_version, + &pool.capability_profile, timeout, ) .await diff --git a/peri-middlewares/src/mcp/mod.rs b/peri-middlewares/src/mcp/mod.rs index 7ffc1b23..785983e5 100644 --- a/peri-middlewares/src/mcp/mod.rs +++ b/peri-middlewares/src/mcp/mod.rs @@ -1,4 +1,6 @@ pub mod agent_registry; +pub mod apps; +pub mod apps_relay; pub mod auth_store; pub mod callback_server; pub mod channel_handler; @@ -22,6 +24,12 @@ pub mod tool_bridge; pub mod transport; pub use agent_registry::{ActivatedMcpAgent, McpAgentMetadata, McpAgentRegistry}; +pub use apps::{ + canonical_resource_uri, raw_resource, raw_tool, tool_resource_uri, tool_visibility, + McpAppsInvocationError, McpAppsInvocationSeam, McpAppsInvoker, McpCapabilityProfile, + RawCallToolResult, RawMcpResource, RawMcpTool, ToolVisibility, MCP_APPS_VERSION, + MCP_APP_MIME_TYPE, MCP_UI_EXTENSION, +}; pub use auth_store::{AuthStoreError, FileCredentialStore, PerServerCredentialStore}; pub use callback_server::{parse_code_from_url, CallbackError, OAuthCallbackServer}; pub use channel_handler::ChannelHandler; diff --git a/peri-middlewares/src/mcp/reconnect.rs b/peri-middlewares/src/mcp/reconnect.rs index 6c2c8a6c..4fc0d7bf 100644 --- a/peri-middlewares/src/mcp/reconnect.rs +++ b/peri-middlewares/src/mcp/reconnect.rs @@ -84,7 +84,16 @@ impl McpClientPool { let result = match &tc { TransportConfig::Stdio { command, args, env } => { match spawn_stdio_transport(command, args, env) { - Ok(t) => serve_client_auto(t, None, protocol_version, timeout).await, + Ok(t) => { + serve_client_auto( + t, + None, + protocol_version, + &self.capability_profile, + timeout, + ) + .await + } Err(e) => { McpClientPool::insert_failed(self, server_name, format!("stdio 失败: {e}")); return Err(McpPoolError::ConnectionFailed { @@ -145,6 +154,7 @@ impl McpClientPool { build_authed_transport(url, headers, am), None, protocol_version, + &self.capability_profile, timeout, ) .await @@ -153,6 +163,7 @@ impl McpClientPool { build_http_transport(url, headers), None, protocol_version, + &self.capability_profile, timeout, ) .await @@ -164,6 +175,7 @@ impl McpClientPool { build_http_transport(url, headers), None, protocol_version, + &self.capability_profile, timeout, ) .await @@ -174,6 +186,7 @@ impl McpClientPool { build_http_transport(url, headers), None, protocol_version, + &self.capability_profile, timeout, ) .await diff --git a/peri-middlewares/src/mcp/tool_bridge.rs b/peri-middlewares/src/mcp/tool_bridge.rs index f809d1ba..d3133296 100644 --- a/peri-middlewares/src/mcp/tool_bridge.rs +++ b/peri-middlewares/src/mcp/tool_bridge.rs @@ -36,7 +36,10 @@ pub struct McpToolBridge { full_name: String, description: String, input_schema: serde_json::Value, + model_visible: bool, + server_generation: u64, client: Arc, + binding_leases: Option>, admission: Option, } @@ -56,6 +59,37 @@ fn sanitize_name_component(name: &str) -> String { .collect() } +fn app_allowed_tools( + server_name: &str, + resource_uri: &str, + tools: &[Tool], + dispatcher: &dyn peri_acp_types::tools::EffectiveToolDispatcher, +) -> std::collections::HashMap { + let dispatcher_tools = dispatcher + .tools() + .into_iter() + .map(|tool| tool.name) + .collect::>(); + tools + .iter() + .filter(|tool| { + super::apps::tool_visibility(tool).app + && super::apps::tool_resource_uri(tool).as_deref() == Some(resource_uri) + }) + .filter_map(|tool| { + let name = tool.name.to_string(); + let effective = format!( + "mcp__{}__{}", + sanitize_name_component(server_name), + sanitize_name_component(&name) + ); + dispatcher_tools + .contains(&effective) + .then_some((name, effective)) + }) + .collect() +} + impl McpToolBridge { pub fn new(server_name: &str, tool: &Tool, client: Arc) -> Self { let tool_name = tool.name.to_string(); @@ -77,7 +111,10 @@ impl McpToolBridge { full_name, description, input_schema, + model_visible: super::apps::tool_visibility(tool).model, + server_generation: 0, client, + binding_leases: None, admission: None, } } @@ -109,10 +146,26 @@ impl McpToolBridge { description, input_schema: serde_json::to_value(&*tool.input_schema) .unwrap_or(serde_json::Value::Object(serde_json::Map::new())), + model_visible: super::apps::tool_visibility(tool).model, + server_generation: 0, client, + binding_leases: None, admission: Some(admission), }) } + + pub fn with_server_generation(mut self, generation: u64) -> Self { + self.server_generation = generation; + self + } + + pub fn with_binding_leases( + mut self, + registry: Arc, + ) -> Self { + self.binding_leases = Some(registry); + self + } } fn valid_name_component(name: &str) -> bool { @@ -144,10 +197,14 @@ impl BaseTool for McpToolBridge { None } + fn visible_to_model(&self) -> bool { + self.model_visible + } + async fn invoke( &self, input: serde_json::Value, - _ctx: peri_agent::tools::ToolContext<'_>, + ctx: peri_agent::tools::ToolContext<'_>, ) -> Result> { let _permit = match &self.admission { Some(gate) => Some(gate.try_acquire().map_err(|_| { @@ -189,6 +246,57 @@ impl BaseTool for McpToolBridge { reason: e.to_string(), })?; + if let ( + Some(registry), + Some(dispatcher), + Some(session_id), + Some(turn_generation), + Some(invocation_id), + ) = ( + self.binding_leases.as_ref(), + ctx.effective_tool_dispatcher.clone(), + ctx.session_id.clone(), + ctx.turn_generation.clone(), + ctx.invocation_id.clone(), + ) { + if invocation_id.starts_with("mcp-app:") { + if let Ok(raw_result) = serde_json::to_value(&result) + .and_then(serde_json::from_value::) + { + registry.record_raw_result( + &invocation_id, + serde_json::to_value(raw_result).unwrap_or(serde_json::Value::Null), + ); + } + } else if let Some(resource_uri) = self + .client + .tools + .iter() + .find(|tool| tool.name.as_ref() == self.tool_name) + .filter(|tool| super::apps::tool_visibility(tool).app) + .and_then(super::apps::tool_resource_uri) + { + let allowed_tools = app_allowed_tools( + &self.server_name, + &resource_uri, + &self.client.tools, + dispatcher.as_ref(), + ); + registry.issue(super::apps::McpAppBindingLease::new( + session_id, + turn_generation, + self.server_name.clone(), + self.server_generation, + resource_uri, + self.tool_name.clone(), + invocation_id, + allowed_tools, + dispatcher, + ctx.cancellation.clone(), + )); + } + } + // 4. 处理 is_error 标志 if result.is_error.unwrap_or(false) { let error_text = format_contents(&result.content); @@ -262,12 +370,13 @@ fn format_contents(contents: &[ContentBlock]) -> String { pub fn build_tool_bridges(pool: &McpClientPool) -> Vec> { let mut bridges: Vec> = Vec::new(); for client in pool.get_all_clients() { + let generation = pool.handle_generation(&client); for tool in &client.tools { - bridges.push(Box::new(McpToolBridge::new( - &client.name, - tool, - Arc::clone(&client), - ))); + bridges.push(Box::new( + McpToolBridge::new(&client.name, tool, Arc::clone(&client)) + .with_server_generation(generation) + .with_binding_leases(Arc::clone(&pool.app_binding_leases)), + )); } } bridges diff --git a/peri-middlewares/src/mcp/tool_bridge_test.rs b/peri-middlewares/src/mcp/tool_bridge_test.rs index 09a51208..efb7e0a5 100644 --- a/peri-middlewares/src/mcp/tool_bridge_test.rs +++ b/peri-middlewares/src/mcp/tool_bridge_test.rs @@ -115,6 +115,90 @@ fn test_format_content_mixed() { assert_eq!(format_contents(&contents), "line1\nline2"); } +struct CatalogDispatcher(Vec); + +#[async_trait::async_trait] +impl peri_acp_types::tools::EffectiveToolDispatcher for CatalogDispatcher { + async fn dispatch( + &self, + _call: peri_acp_types::tools::EffectiveToolCall, + _cancel: tokio_util::sync::CancellationToken, + ) -> Result { + Ok(String::new()) + } + + fn tools(&self) -> Vec { + self.0.clone() + } +} + +#[test] +fn app_allowed_tools_intersects_resource_visibility_and_canonical_catalog() { + let tool = |name: &str, resource: &str, visibility: &str| { + serde_json::from_value::(serde_json::json!({ + "name": name, + "description": name, + "inputSchema": {"type": "object"}, + "_meta": {"ui": {"resourceUri": resource, "visibility": [visibility]}} + })) + .unwrap() + }; + let tools = vec![ + tool("app_only", "ui://app", "app"), + tool("other_resource", "ui://other", "app"), + tool("model_only", "ui://app", "model"), + ]; + let dispatcher = CatalogDispatcher(vec![ + peri_acp_types::tools::EffectiveToolDefinition { + name: "mcp__server__app_only".into(), + description: String::new(), + parameters: serde_json::json!({}), + }, + peri_acp_types::tools::EffectiveToolDefinition { + name: "mcp__server__other_resource".into(), + description: String::new(), + parameters: serde_json::json!({}), + }, + ]); + + assert_eq!( + app_allowed_tools("server", "ui://app", &tools, &dispatcher), + std::collections::HashMap::from([("app_only".into(), "mcp__server__app_only".into())]) + ); +} + +#[test] +fn test_build_tool_bridges_filters_app_only_tool_from_model_catalog() { + let pool = McpClientPool::new_empty(); + let tool: Tool = serde_json::from_value(serde_json::json!({ + "name": "app_only", + "description": "App only", + "inputSchema": {"type": "object"}, + "_meta": {"ui": {"visibility": ["app"]}} + })) + .unwrap(); + pool.clients.write().insert( + "apps".to_string(), + Arc::new(McpClientHandle { + name: "apps".to_string(), + version: None, + cache_version: None, + peer: None, + tools: vec![tool], + resources: vec![], + status: ClientStatus::Connected, + oauth_status: Default::default(), + source: None, + url: None, + skills_capable: false, + channel_capable: false, + }), + ); + let bridges = build_tool_bridges(&pool); + assert_eq!(bridges.len(), 1); + assert!(!bridges[0].visible_to_model()); +} + #[test] fn test_build_tool_bridges_empty_pool() { let pool = McpClientPool::new_empty(); diff --git a/spec/issues/2026-08-27-mcp-apps-stdio-relay.md b/spec/issues/2026-08-27-mcp-apps-stdio-relay.md new file mode 100644 index 00000000..481afa44 --- /dev/null +++ b/spec/issues/2026-08-27-mcp-apps-stdio-relay.md @@ -0,0 +1,144 @@ +# MCP Apps Stable stdio relay + +**状态**:Implemented(环境 capability、resource relay、Binding lease + canonical HITL tools/call 已接通) +**优先级**:高 +**类型**:协议适配 / ACP stdio / MCP capability +**更新日期**:2026-08-27 + +## 最终决策 + +```text +PERI_MCP_APPS 环境变量存在 + → 当前进程使用 immutable Apps deployment profile + → MCP pool prewarm / reconnect / Dynamic MCP initialize 声明 + io.modelcontextprotocol/ui + text/html;profile=mcp-app + → stdio 装配 MCP Apps relay backend + +环境变量不存在 + → 不声明 extension + → 不装配 relay + → peri/mcp/* fail closed +``` + +环境变量**只看是否存在,值不解析**;空串、`0` 等均表示启用。进程内 profile 通过 `OnceLock` 冻结。完全移除 ACP `peri.mcpApps` capability 的解析、协商和回显。 + +## 范围 + +### Peri 实现 + +- 环境 deployment profile。 +- MCP initialize/reconnect capability propagation。 +- `_meta.ui.resourceUri` canonical/legacy 兼容读取与冲突 fail closed。 +- `_meta.ui.visibility` model/app 解释;malformed fail closed。 +- `peri/mcp/open`:按 server/tool 建立 connection-owned resource binding。 +- `peri/mcp/resource`:读取并返回 `ui://` HTML resource,保留 `text|blob` 与 `_meta`。 +- `peri/mcp/app` typed envelope;`tools/call` 经单次消费 Binding lease 进入 canonical `EffectiveToolDispatcher` 与 Permission/HITL。 +- ACP EOF 清理该 connection 的 binding 和 pending relay。 + +### 明确不实现 + +- Web Host、iframe、sandbox、CSP/Permissions Policy 执行、浏览器 `postMessage`、MCP Apps FE SDK。 +- `peri-tui` Apps 能力或渲染。 +- ACP MCP Apps capability。 +- 任意 MCP proxy。 +- 绕过 effective tool view / Permission / HITL 的直接 `Peer::call_tool()`。 + +## Wire contract + +### Open + +```json +{ + "method": "peri/mcp/open", + "params": { + "envelopeVersion": "1", + "appsProtocolVersion": "2026-01-26", + "serverId": "server", + "toolName": "open_dashboard" + } +} +``` + +Peri 校验:server 已连接、tool 存在、visibility 包含 `app`、tool metadata 含合法且无冲突的 `ui://` resource URI。成功返回 opaque `appSessionId`、resource URI 和 MCP core version。 + +### Resource + +`peri/mcp/resource` 必须携带 `appSessionId + serverId + resourceUri`。Peri 校验 connection owner、server generation、resource binding,然后执行 `resources/read`。首版要求: + +- URI 精确匹配且为 `ui://`; +- MIME 为 `text/html;profile=mcp-app`; +- `text` / `blob` 恰有一个; +- `_meta` 与未知字段保留。 + +### App tools/call + +DTO 与 request/response id 分层已接通。初始模型 MCP tool invocation 在 `ToolContext` 中取得 canonical dispatcher、session/turn identity 与 cancellation;仅当 tool 同时 app-visible 且绑定合法 `ui://` resource 时签发短期 lease。`peri/mcp/open` 必须携带该 invocation 的 opaque correlation token 与 owner session identity,并单次消费精确匹配 server/tool/resource/generation/session/token 的 lease;同 connection 内并发 open 也不能消费彼此 lease。后续 `tools/call` 通过 lease dispatcher 执行 canonical full tool name。 + +allowed tool set 是“同 server、同 resource URI、visibility 包含 `app`”与 lease 签发时 canonical effective catalog 的交集。它包含 app-only tool,但 app-only tool 不进入模型 definitions;调用仍进入同一 `EffectiveToolDispatcher`、Permission 与 HITL 路径。 + +MCP bridge 在专用 `mcp-app:` invocation identity 下将 raw `CallToolResult` 写入无日志 side channel;dispatcher 完成后 relay 取走并构造 Apps JSON-RPC response,完整保留 `content`、`structuredContent`、`_meta`、`isError` 和未知字段,不把 dispatcher 的文本结果伪装成 raw result。 + +## Binding lease + +```text +InitialMcpAppBindingLease { + agent_session_id, + turn_generation, + server_id, + server_generation, + resource_uri, + instantiating_tool, + opaque_invocation_token, + app_visible_effective_tool_set, + effective_dispatcher, + cancellation, + expires_at +} +``` + +租约由 canonical initial invocation 签发,`peri/mcp/open` 按 server/tool/resource/generation/session/opaque token 单次消费并绑定 ACP connection;TTL 为 5 分钟。registry 记录 session 的最新 turn generation,较旧 pending/active lease 均失效;turn/session cancellation、server generation 变化、过期同样使租约无效。pending/active lease 与 raw result side channel 均有 TTL、容量上限和 opportunistic cleanup;raw result invocation identity 含 connection owner,ACP EOF 撤销该 connection 的 active relay lease、清除 connection-owned binding 和未取走 raw result。 + +server generation 在每次 pool connection commit/失败替换时显式递增;验证只查 server name 对应的 generation,不使用 `Arc` 地址或指针相等推断。stdio 装配点读取一次环境并构造 immutable profile;pool、initial/reconnect 和 Dynamic MCP connector 复用同一 profile,TUI/MPSC 入口固定 disabled 且不读取/传播环境。Apps requests 由 connection-owned task 异步执行;除 `open` 的短临界提交外不跨网络 await 持 connection 锁,EOF cancellation 与 response 发送竞争后每个已接收 request 至多产生一个 terminal response。 + +## 安全不变量 + +1. 环境变量不存在时,extension 与 relay 都不存在。 +2. Apps profile 在 prewarm 前冻结,初始连接和重连一致。 +3. binding 属于 ACP connection,不能跨 connection 使用。 +4. server handle replacement 改变 generation,旧 binding 返回 `stale_server_generation`。 +5. App visibility 不含 `app` 时拒绝 open。 +6. `isError: true` 属正常 `CallToolResult`,不得映射成 transport error。 +7. error 层级由 ACP outer error 与 payload JSON-RPC error 的结构位置区分。 +8. 不记录 HTML 正文、tool 参数、OAuth token、header、stdio env 或其他 secret。 + +## 验证 + +已覆盖: + +- env profile presence/absence helper; +- MCP UI extension 构造; +- visibility 与 resource URI canonicalization; +- raw resource/result roundtrip; +- connection-owned binding 与 EOF 清理; +- Apps envelope/version/JSON-RPC result/error 校验; +- `peri-acp` 全量 lib 测试。 + +最终验收还需: + +- fake MCP server 断言环境变量存在时 initial/reconnect initialize extension; +- stdio wire `open → resource` E2E; +- Binding lease 的 HITL approve/deny、cancel、expiry、server generation E2E; +- App 主动调用不重复进入模型事件投影。 + +## 目标命令 + +```bash +cargo fmt --check +cargo test -p peri-acp-types --lib mcp_apps +cargo test -p peri-middlewares --lib mcp::apps +cargo test -p peri-middlewares --lib mcp::tool_bridge +cargo test -p peri-acp --lib +cargo check -p peri-acp-types -p peri-middlewares -p peri-acp +cargo clippy -p peri-acp-types -p peri-middlewares -p peri-acp --all-targets -- -D warnings +git diff --check +``` From 0b026d9ad429882899f2a8c0a52cec19b81d8801 Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Fri, 28 Aug 2026 09:14:19 +0800 Subject: [PATCH 2/8] fix(resources): serialize filesystem meta updates Co-Authored-By: gpt-5.6-sol --- peri-resources/src/sessions/filesystem.rs | 9 ++++++- .../src/sessions/filesystem_test.rs | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/peri-resources/src/sessions/filesystem.rs b/peri-resources/src/sessions/filesystem.rs index dd0456d2..972dfe20 100644 --- a/peri-resources/src/sessions/filesystem.rs +++ b/peri-resources/src/sessions/filesystem.rs @@ -16,6 +16,10 @@ use peri_acp_types::{ /// 进程内计数器:保证并发写同一目标时临时文件名唯一。 static TMP_COUNTER: AtomicU64 = AtomicU64::new(0); +/// 序列化 meta.json 的 read-modify-write,避免 transcript writer 与生命周期 +/// 状态更新互相覆盖。FilesystemThreadStore 仅用于测试,进程级锁足够。 +static META_UPDATE_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + /// 原子写入 JSON 文件:先写同目录唯一临时文件再 rename。 /// /// tokio `fs::write` 是「截断 + 写数据」两步(经 spawn_blocking 独立线程), @@ -145,7 +149,9 @@ impl ThreadStore for FilesystemThreadStore { } file.flush().await?; - // 更新 meta 的 message_count 和 updated_at + // 更新 meta 的 message_count 和 updated_at。与状态更新共用锁,避免基于 + // 旧快照写回时把 done/error/cancelled 覆盖成 active。 + let _guard = META_UPDATE_LOCK.lock().await; let mut meta = self.load_meta(id).await?; meta.message_count += msgs.len(); meta.updated_at = Utc::now(); @@ -281,6 +287,7 @@ impl ThreadStore for FilesystemThreadStore { // 关键约束:参数字符串必须经 FromStr 解析,非法值直接返回错误,不静默 fallback let status = AgentStatus::from_str(status) .with_context(|| format!("非法 agent_status 值: {status:?}"))?; + let _guard = META_UPDATE_LOCK.lock().await; let mut meta = self.load_meta(id).await?; meta.agent_status = status; meta.updated_at = Utc::now(); diff --git a/peri-resources/src/sessions/filesystem_test.rs b/peri-resources/src/sessions/filesystem_test.rs index c6d070bd..69c56163 100644 --- a/peri-resources/src/sessions/filesystem_test.rs +++ b/peri-resources/src/sessions/filesystem_test.rs @@ -82,6 +82,32 @@ async fn test_message_count_updates() { assert_eq!(loaded.message_count, 1); } +#[tokio::test] +async fn test_concurrent_append_preserves_terminal_status() { + let dir = tempdir().unwrap(); + let store: Arc = Arc::new(FilesystemThreadStore::new(dir.path())); + let id = store.create_thread(make_meta("/test")).await.unwrap(); + let append_store = Arc::clone(&store); + let append_id = id.clone(); + let status_store = Arc::clone(&store); + let status_id = id.clone(); + + let (append_result, status_result) = tokio::join!( + async move { + append_store + .append_messages(&append_id, &[BaseMessage::human("msg")]) + .await + }, + async move { status_store.update_thread_status(&status_id, "error").await } + ); + append_result.unwrap(); + status_result.unwrap(); + + let loaded = store.load_meta(&id).await.unwrap(); + assert_eq!(loaded.message_count, 1); + assert_eq!(loaded.agent_status, AgentStatus::Error); +} + #[tokio::test] async fn test_title_extracted_from_first_human() { let dir = tempdir().unwrap(); From afbe2012d47ec2ffbaed6493266cad989b27f9eb Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Fri, 28 Aug 2026 10:36:42 +0800 Subject: [PATCH 3/8] fix(mcp-apps): complete stdio relay successful path Use the deployment MCP pool for static tool bridges so app binding leases and server generations remain shared with the stdio relay. Add official-style fixtures, lifecycle coverage, and document the successful-path incident and verification. Co-Authored-By: gpt-5.6-sol --- docs/design/mcp-multiplexing.md | 6 +- peri-acp/src/host/connection.rs | 2 +- peri-acp/src/host/connection_test.rs | 1 + peri-acp/src/host/mod.rs | 11 + peri-middlewares/src/assembly.rs | 1 + peri-middlewares/src/mcp/channel_handler.rs | 18 +- peri-middlewares/src/mcp/client.rs | 95 ++-- peri-middlewares/src/mcp/client/transport.rs | 68 ++- peri-middlewares/src/mcp/client_test.rs | 2 +- peri-middlewares/src/mcp/middleware.rs | 15 +- peri-middlewares/src/mcp/middleware_test.rs | 46 ++ peri-middlewares/src/mcp/tool_bridge.rs | 42 +- side-projects/mcp-apps/check-peri.ts | 421 ++++++++++++++++++ side-projects/mcp-apps/check.ts | 83 ++++ side-projects/mcp-apps/package.json | 7 +- side-projects/mcp-apps/stdio-server.ts | 82 ++++ .../issues/2026-08-27-mcp-apps-stdio-relay.md | 64 ++- 17 files changed, 863 insertions(+), 101 deletions(-) create mode 100644 side-projects/mcp-apps/check-peri.ts create mode 100644 side-projects/mcp-apps/check.ts create mode 100644 side-projects/mcp-apps/stdio-server.ts diff --git a/docs/design/mcp-multiplexing.md b/docs/design/mcp-multiplexing.md index 0b0f8f95..fb2eb7b5 100644 --- a/docs/design/mcp-multiplexing.md +++ b/docs/design/mcp-multiplexing.md @@ -392,7 +392,7 @@ sequenceDiagram | 两个 App 同时使用相同 L1 id | 由 connection owner + appSessionId + ACP L2 隔离 | | App 指定另一个 `serverId` / `resourceUri` / 旧 generation | 被拒绝,server 不收到 `tools/call` | | visibility 为 `model` 或 `app` | model/app tool surface 按 visibility 分流,默认值符合规范 | -| resource 为多 contents、`text` 或 `blob` | `uri`、`mimeType`、`text|blob`、content `_meta.ui` 完整往返 | +| resource 为多 contents、`text` 或 `blob` | `uri`、`mimeType`、`text/blob`、content `_meta.ui` 完整往返 | | `structuredContent` + `content` + `_meta` + `isError` | 字段逐项保留;`isError` 是正常 result,不变成 transport error | | App 主动调用与模型调用 | 前者只返回标准 JSON-RPC response;后者只产生一次下游 input/result/cancelled 投影 | | session teardown、stdio EOF、server generation 变化、timeout/cancel | 只清理对应 owner/generation;pending request 至多结算一次 | @@ -402,8 +402,8 @@ sequenceDiagram | # | 改动 | 位置 | 依赖 | | --- | --- | --- | --- | -| 0 | **读取 ACP client 的 MCP Apps capability**:从 UI 侧 `initialize` 保存 connection-scoped capability;不能由 Peri stdio 自行声明 | `peri-acp` ACP initialize contract / connection state | 无——所有 App 生态的前提 | -| 1 | **条件传播 MCP server capability**:仅当 ACP client 声明 UI capability 时,Peri 才在 MCP client initialize 中发送对应 UI extension;否则保持普通 MCP capabilities | `peri-middlewares/src/mcp/channel_handler.rs` 与 MCP 初始化装配 | 0 | +| 0 | **读取冻结的 MCP Apps deployment profile**:stdio 启动时只读取一次 `PERI_MCP_APPS`,作为 Apps relay 与 MCP UI extension 的唯一启用来源 | stdio host 装配 / immutable capability profile | 无——所有 App 生态的前提 | +| 1 | **按 deployment profile 构造 MCP server capability**:profile enabled 时在 MCP client initialize 中发送 UI extension;disabled 时保持普通 MCP capabilities | `peri-middlewares` MCP 初始化与重连装配 | 0 | | 2 | `peri/mcp/app`、`peri/mcp/resource` 方法分发(下游 UI → Peri 方向) | ACP 命令分发处(与 `session/*` 同入口) | 0、1 | | 3 | App session:会话注册表、server/resource 绑定、握手校验与 teardown | `peri-acp`/`peri-middlewares` 的协议实现层 | 2 | | 4 | id 映射与 `tools/call` 路由(复用 `McpClientPool` + 既有权限/HITL) | 同上 | 2、3 | diff --git a/peri-acp/src/host/connection.rs b/peri-acp/src/host/connection.rs index b0118f89..96dcc89a 100644 --- a/peri-acp/src/host/connection.rs +++ b/peri-acp/src/host/connection.rs @@ -38,7 +38,7 @@ impl ConnectionContext { } pub(crate) fn apps_enabled(&self) -> bool { - self.lifecycle == ConnectionLifecycle::Open && self.apps_enabled + self.initialized && self.lifecycle == ConnectionLifecycle::Open && self.apps_enabled } #[allow(dead_code)] diff --git a/peri-acp/src/host/connection_test.rs b/peri-acp/src/host/connection_test.rs index 45543a7f..766f63a9 100644 --- a/peri-acp/src/host/connection_test.rs +++ b/peri-acp/src/host/connection_test.rs @@ -4,6 +4,7 @@ use peri_acp_types::mcp_apps::{AppSessionBinding, MCP_APPS_PROTOCOL_VERSION}; #[test] fn deployment_capability_is_immutable_and_close_invalidates_sessions() { let mut connection = ConnectionContext::new(true); + assert!(!connection.apps_enabled()); connection.commit_initialize(); connection.commit_initialize(); assert!(connection.apps_enabled()); diff --git a/peri-acp/src/host/mod.rs b/peri-acp/src/host/mod.rs index 07c3f627..c556e44f 100644 --- a/peri-acp/src/host/mod.rs +++ b/peri-acp/src/host/mod.rs @@ -610,6 +610,10 @@ async fn run_acp_server_inner( .await; } } else { + let closed_session_id = + matches!(method.as_str(), "session/close" | "session/delete") + .then(|| extract_session_id(¶ms, "").to_string()) + .filter(|session_id| !session_id.is_empty()); let result = { let mut sessions = sessions.lock().await; handle_request(&method, ¶ms, &cfg, &mut sessions, &transport).await @@ -617,6 +621,13 @@ async fn run_acp_server_inner( if method == "initialize" && result.is_ok() { connection.lock().await.commit_initialize(); } + if result.is_ok() { + if let (Some(session_id), Some(relay)) = + (closed_session_id.as_deref(), cfg.mcp_apps_relay.as_ref()) + { + relay.close_session(session_id); + } + } let new_session_id = (method == "session/new") .then(|| { result diff --git a/peri-middlewares/src/assembly.rs b/peri-middlewares/src/assembly.rs index daefef15..49503022 100644 --- a/peri-middlewares/src/assembly.rs +++ b/peri-middlewares/src/assembly.rs @@ -542,6 +542,7 @@ impl MiddlewareChainAssembler for ProductionChainAssembler { Arc::clone(pool) }; let mw = McpMiddleware::new(Arc::clone(&effective_pool)) + .with_tool_pool(Arc::clone(pool)) .with_skill_discovery( ctx.mcp_skill_registry.clone(), ctx.cancel.clone(), diff --git a/peri-middlewares/src/mcp/channel_handler.rs b/peri-middlewares/src/mcp/channel_handler.rs index 83afd768..479adaa6 100644 --- a/peri-middlewares/src/mcp/channel_handler.rs +++ b/peri-middlewares/src/mcp/channel_handler.rs @@ -16,11 +16,25 @@ use rmcp::{ /// 根据 `method` 字段路由到 channel 消息推送或权限响应处理。 pub struct ChannelHandler { pub state: Arc, + capability_profile: super::apps::McpCapabilityProfile, } impl ChannelHandler { pub fn new(state: Arc) -> Self { - Self { state } + Self { + state, + capability_profile: super::apps::McpCapabilityProfile::disabled(), + } + } + + pub(crate) fn with_capability_profile( + &self, + capability_profile: super::apps::McpCapabilityProfile, + ) -> Self { + Self { + state: Arc::clone(&self.state), + capability_profile, + } } } @@ -94,7 +108,7 @@ impl ChannelHandler { impl ClientHandler for ChannelHandler { fn get_info(&self) -> InitializeRequestParams { - super::client::mcpp_client_info() + super::client::mcpp_client_info_for_profile(&self.capability_profile) } // rmcp trait 要求返回 impl Future,无法改为 async fn diff --git a/peri-middlewares/src/mcp/client.rs b/peri-middlewares/src/mcp/client.rs index 775bfd61..e411d042 100644 --- a/peri-middlewares/src/mcp/client.rs +++ b/peri-middlewares/src/mcp/client.rs @@ -145,10 +145,6 @@ pub fn redact_mcp_error(input: &str) -> String { pub(crate) const SERVER_CACHE_VERSION_EXTENSION: &str = "io.mcpp/server-cache-version"; -pub(crate) fn mcpp_client_info() -> InitializeRequestParams { - mcpp_client_info_for_profile(&super::apps::McpCapabilityProfile::disabled()) -} - pub(crate) fn mcpp_client_info_for_profile( profile: &super::apps::McpCapabilityProfile, ) -> InitializeRequestParams { @@ -362,7 +358,8 @@ pub struct McpClientPool { service_shutdown: tokio::sync::Mutex, pub(crate) task_spawner: super::task_scope::McpTaskSpawner, pub(crate) clients: parking_lot::RwLock>>, - handle_generations: parking_lot::Mutex>, + handle_generations: + parking_lot::Mutex, u64)>>>, next_handle_generation: std::sync::atomic::AtomicU64, pub(crate) services: parking_lot::Mutex>, pub(crate) configs: parking_lot::RwLock>, @@ -474,17 +471,25 @@ impl McpClientPool { self.handle_generations .lock() .get(&handle.name) - .copied() + .and_then(|entries| { + entries.iter().find_map(|(candidate, generation)| { + candidate + .upgrade() + .filter(|candidate| Arc::ptr_eq(candidate, handle)) + .map(|_| *generation) + }) + }) .unwrap_or(0) } - fn advance_handle_generation(&self, name: &str) -> u64 { + fn advance_handle_generation(&self, handle: &Arc) -> u64 { let generation = self .next_handle_generation .fetch_add(1, std::sync::atomic::Ordering::Relaxed); - self.handle_generations - .lock() - .insert(name.to_string(), generation); + let mut generations = self.handle_generations.lock(); + let entries = generations.entry(handle.name.clone()).or_default(); + entries.retain(|(candidate, _)| candidate.strong_count() > 0); + entries.push((Arc::downgrade(handle), generation)); generation } @@ -1211,24 +1216,22 @@ impl McpClientPool { .map(|c| (c.source.clone(), c.url.clone())) .unwrap_or((None, None)); let old_status = pool.clients.read().get(name).map(|c| c.status.clone()); - pool.advance_handle_generation(name); - pool.clients.write().insert( - name.to_string(), - Arc::new(McpClientHandle { - name: name.to_string(), - version: None, - cache_version: None, - peer: None, - tools: vec![], - resources: vec![], - status: ClientStatus::Failed(reason.clone()), - oauth_status: OAuthStatus::default(), - source, - url, - skills_capable: false, - channel_capable: false, - }), - ); + let handle = Arc::new(McpClientHandle { + name: name.to_string(), + version: None, + cache_version: None, + peer: None, + tools: vec![], + resources: vec![], + status: ClientStatus::Failed(reason.clone()), + oauth_status: OAuthStatus::default(), + source, + url, + skills_capable: false, + channel_capable: false, + }); + pool.advance_handle_generation(&handle); + pool.clients.write().insert(name.to_string(), handle); old_status }; pool.record_status_change(name, old_status.as_ref()); @@ -1259,24 +1262,22 @@ impl McpClientPool { .map(|c| (c.source.clone(), c.url.clone())) .unwrap_or((None, None)); let old_status = pool.clients.read().get(name).map(|c| c.status.clone()); - pool.advance_handle_generation(name); - pool.clients.write().insert( - name.to_string(), - Arc::new(McpClientHandle { - name: name.to_string(), - version: None, - cache_version: None, - peer: None, - tools: vec![], - resources: vec![], - status: ClientStatus::Failed(reason), - oauth_status: OAuthStatus::NeedsAuthorization, - source, - url, - skills_capable: false, - channel_capable: false, - }), - ); + let handle = Arc::new(McpClientHandle { + name: name.to_string(), + version: None, + cache_version: None, + peer: None, + tools: vec![], + resources: vec![], + status: ClientStatus::Failed(reason), + oauth_status: OAuthStatus::NeedsAuthorization, + source, + url, + skills_capable: false, + channel_capable: false, + }); + pool.advance_handle_generation(&handle); + pool.clients.write().insert(name.to_string(), handle); old_status }; pool.record_status_change(name, old_status.as_ref()); @@ -1513,7 +1514,7 @@ impl McpClientPool { if !self.is_open() { return Err(service); } - self.advance_handle_generation(&name); + self.advance_handle_generation(&handle); self.services.lock().insert(name.clone(), service); self.clients.write().insert(name, handle); Ok(()) diff --git a/peri-middlewares/src/mcp/client/transport.rs b/peri-middlewares/src/mcp/client/transport.rs index 93818af3..3d110885 100644 --- a/peri-middlewares/src/mcp/client/transport.rs +++ b/peri-middlewares/src/mcp/client/transport.rs @@ -62,7 +62,10 @@ where { match connection_mode(protocol_version, channel_handler.is_some()) { ConnectionMode::DiscoverChannel => { - let handler = channel_handler.expect("channel mode requires handler"); + let handler = channel_handler + .expect("channel mode requires handler") + .with_capability_profile(capability_profile.clone()); + let handler = Arc::new(handler); tokio::time::timeout( timeout, rmcp::service::serve_client_with_lifecycle( @@ -89,7 +92,10 @@ where .await .map(|inner| inner.map(McpServiceWrapper::Default)), ConnectionMode::LegacyChannel => { - let handler = channel_handler.expect("channel mode requires handler"); + let handler = channel_handler + .expect("channel mode requires handler") + .with_capability_profile(capability_profile.clone()); + let handler = Arc::new(handler); tokio::time::timeout( timeout, rmcp::service::serve_client(handler.clone(), transport), @@ -213,7 +219,11 @@ mod tests { use super::*; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; - async fn observe_first_request(protocol_version: Option<&McpProtocolVersion>) -> String { + async fn observe_first_request( + protocol_version: Option<&McpProtocolVersion>, + channel: bool, + capability_profile: &crate::mcp::apps::McpCapabilityProfile, + ) -> serde_json::Value { let (client_io, server_io) = tokio::io::duplex(8192); let server = tokio::spawn(async move { let (read, mut write) = tokio::io::split(server_io); @@ -248,34 +258,66 @@ mod tests { let notification: serde_json::Value = serde_json::from_str(&initialized).unwrap(); assert_eq!(notification["method"], "notifications/initialized"); } - method + request }); + let channel_handler = channel.then(|| { + Arc::new(ChannelHandler::new( + peri_agent::interaction::ChannelState::new(), + )) + }); let _service = serve_client_auto( client_io, - None, + channel_handler.as_ref(), protocol_version, - &crate::mcp::apps::McpCapabilityProfile::disabled(), + capability_profile, std::time::Duration::from_secs(2), ) .await .expect("握手不应超时") .expect("握手应成功"); - let method = server.await.unwrap(); - method + server.await.unwrap() } #[tokio::test] async fn none_starts_with_initialize_and_accepts_2025_11_25_response() { - assert_eq!(observe_first_request(None).await, "initialize"); + let request = observe_first_request( + None, + false, + &crate::mcp::apps::McpCapabilityProfile::disabled(), + ) + .await; + assert_eq!(request["method"], "initialize"); } #[tokio::test] async fn explicit_2026_07_28_transport_starts_with_discover() { - assert_eq!( - observe_first_request(Some(&McpProtocolVersion::V2026_07_28)).await, - "server/discover" - ); + let request = observe_first_request( + Some(&McpProtocolVersion::V2026_07_28), + false, + &crate::mcp::apps::McpCapabilityProfile::disabled(), + ) + .await; + assert_eq!(request["method"], "server/discover"); + } + + #[tokio::test] + async fn enabled_profile_is_advertised_in_both_channel_modes() { + let profile = + crate::mcp::apps::McpCapabilityProfile::negotiated([crate::mcp::MCP_APP_MIME_TYPE]); + for protocol_version in [None, Some(&McpProtocolVersion::V2026_07_28)] { + let request = observe_first_request(protocol_version, true, &profile).await; + let extensions = if request["method"] == "server/discover" { + &request["params"]["_meta"]["io.modelcontextprotocol/clientCapabilities"] + ["extensions"] + } else { + &request["params"]["capabilities"]["extensions"] + }; + assert!( + extensions[crate::mcp::MCP_UI_EXTENSION].is_object(), + "request: {request}" + ); + } } #[test] diff --git a/peri-middlewares/src/mcp/client_test.rs b/peri-middlewares/src/mcp/client_test.rs index 9718222a..79e8d50a 100644 --- a/peri-middlewares/src/mcp/client_test.rs +++ b/peri-middlewares/src/mcp/client_test.rs @@ -73,7 +73,7 @@ fn server_generation_advances_explicitly_on_each_committed_connection() { assert!(first_generation > 0); assert!(second_generation > first_generation); - assert_eq!(pool.handle_generation(&first), second_generation); + assert_eq!(pool.handle_generation(&first), first_generation); } #[test] diff --git a/peri-middlewares/src/mcp/middleware.rs b/peri-middlewares/src/mcp/middleware.rs index b2415ad4..888dc81c 100644 --- a/peri-middlewares/src/mcp/middleware.rs +++ b/peri-middlewares/src/mcp/middleware.rs @@ -22,7 +22,12 @@ use super::{ /// MCP 中间件 —— 将所有已连接 MCP 服务器的工具和资源注入 ReAct 循环, /// 并向模型通报 MCP 连接状态(首 turn 概览 + 运行中上下线变化)。 pub struct McpMiddleware { + /// Session-projected MCP view used by resources, discovery, and status reporting. pool: Arc, + /// Deployment-owned pool used to build static MCP tool bridges. Static bridges must retain + /// deployment identity such as handle generations and MCP Apps binding leases; dynamic + /// tools are overlaid separately by `SessionToolCatalog`. + tool_pool: Arc, /// 会话级 MCP skill 远端注册表(None = 未装配 session 透传;DiscoverMCP /// 的 skill 域查询读它)。 registry: Option>, @@ -40,6 +45,7 @@ pub struct McpMiddleware { impl McpMiddleware { pub fn new(pool: Arc) -> Self { Self { + tool_pool: Arc::clone(&pool), pool, registry: None, command_registry: None, @@ -48,6 +54,13 @@ impl McpMiddleware { } } + /// Use a deployment-owned pool for static tool bridges while retaining the session-projected + /// pool for resources and discovery. + pub fn with_tool_pool(mut self, tool_pool: Arc) -> Self { + self.tool_pool = tool_pool; + self + } + /// 注入 skill 发现装配(session 级 registry + cancel token;assembly 槽位 /// 调用)。不调用时保持无发现行为(既有测试/print 模式兼容)。 pub fn with_skill_discovery( @@ -322,7 +335,7 @@ impl Middleware for McpMiddleware { } fn collect_tools(&self, _cwd: &str) -> Vec> { - let mut tools = build_tool_bridges(&self.pool); + let mut tools = build_tool_bridges(&self.tool_pool); tools.push(Box::new(McpResourceTool::new( Arc::clone(&self.pool), diff --git a/peri-middlewares/src/mcp/middleware_test.rs b/peri-middlewares/src/mcp/middleware_test.rs index d40110b3..1e0f83d7 100644 --- a/peri-middlewares/src/mcp/middleware_test.rs +++ b/peri-middlewares/src/mcp/middleware_test.rs @@ -27,6 +27,30 @@ fn test_collect_tools_empty_pool() { assert_eq!(tools[1].name(), "DiscoverMCP"); } +#[test] +fn static_tool_bridges_use_deployment_pool_not_session_projection() { + let deployment_pool = Arc::new(McpClientPool::new_empty()); + deployment_pool.clients.write().insert( + "static".to_string(), + make_connected_handle_with_tool("static", "instantiate_app"), + ); + let projected_pool = Arc::new(McpClientPool::new_empty()); + projected_pool.clients.write().insert( + "dynamic".to_string(), + make_connected_handle_with_tool("dynamic", "shadow_tool"), + ); + + let mw = McpMiddleware::new(Arc::clone(&projected_pool)) + .with_tool_pool(Arc::clone(&deployment_pool)); + let names = ::collect_tools(&mw, "/tmp") + .into_iter() + .map(|tool| tool.name().to_string()) + .collect::>(); + + assert!(names.contains(&"mcp__static__instantiate_app".to_string())); + assert!(!names.contains(&"mcp__dynamic__shadow_tool".to_string())); +} + // ─── first_turn_reminder:首 turn 概览 ─────────────────────────────────────── /// 空池(无任何服务器配置)→ None(零噪音) @@ -54,6 +78,28 @@ fn make_connected_handle(name: &str, tools: usize) -> Arc { }) } +fn make_connected_handle_with_tool(name: &str, tool_name: &str) -> Arc { + let tool = rmcp::model::Tool::new( + tool_name.to_string(), + "fixture".to_string(), + serde_json::Map::new(), + ); + Arc::new(McpClientHandle { + name: name.to_string(), + version: None, + cache_version: None, + peer: None, + tools: vec![tool], + resources: vec![], + status: ClientStatus::Connected, + oauth_status: OAuthStatus::default(), + source: None, + url: None, + skills_capable: false, + channel_capable: false, + }) +} + /// 混合状态概览:connected 带工具数、failed 带错误、disabled 计数 #[test] fn test_overview_mixed_statuses() { diff --git a/peri-middlewares/src/mcp/tool_bridge.rs b/peri-middlewares/src/mcp/tool_bridge.rs index d3133296..2e2d3caa 100644 --- a/peri-middlewares/src/mcp/tool_bridge.rs +++ b/peri-middlewares/src/mcp/tool_bridge.rs @@ -246,6 +246,27 @@ impl BaseTool for McpToolBridge { reason: e.to_string(), })?; + // 4. 处理 is_error 标志。失败的实例化调用不得签发 App lease。 + if result.is_error.unwrap_or(false) { + let error_text = format_contents(&result.content); + let lines: Vec<&str> = error_text.lines().collect(); + let reason = if lines.len() > MAX_MCP_LINES { + let persist_hint = persist_truncated_output(&error_text); + let truncated: String = lines[..MAX_MCP_LINES].join("\n"); + format!( + "{truncated}\n\n[MCP error output truncated: {} total lines]{persist_hint}", + lines.len() + ) + } else { + error_text + }; + return Err(Box::new(ToolCallError::CallFailed { + server: self.server_name.clone(), + tool: self.tool_name.clone(), + reason, + })); + } + if let ( Some(registry), Some(dispatcher), @@ -297,27 +318,6 @@ impl BaseTool for McpToolBridge { } } - // 4. 处理 is_error 标志 - if result.is_error.unwrap_or(false) { - let error_text = format_contents(&result.content); - let lines: Vec<&str> = error_text.lines().collect(); - let reason = if lines.len() > MAX_MCP_LINES { - let persist_hint = persist_truncated_output(&error_text); - let truncated: String = lines[..MAX_MCP_LINES].join("\n"); - format!( - "{truncated}\n\n[MCP error output truncated: {} total lines]{persist_hint}", - lines.len() - ) - } else { - error_text - }; - return Err(Box::new(ToolCallError::CallFailed { - server: self.server_name.clone(), - tool: self.tool_name.clone(), - reason, - })); - } - // 5. 格式化返回(截断超大输出) let formatted = format_contents(&result.content); let lines: Vec<&str> = formatted.lines().collect(); diff --git a/side-projects/mcp-apps/check-peri.ts b/side-projects/mcp-apps/check-peri.ts new file mode 100644 index 00000000..82ca9376 --- /dev/null +++ b/side-projects/mcp-apps/check-peri.ts @@ -0,0 +1,421 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import fs from "node:fs/promises"; +import http from "node:http"; +import os from "node:os"; +import path from "node:path"; +import readline from "node:readline"; +import { fileURLToPath } from "node:url"; + +const projectDir = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.resolve(projectDir, "../.."); +const fixtureServer = path.join(projectDir, "stdio-server.ts"); +const tsxCli = path.join(projectDir, "node_modules", "tsx", "dist", "cli.mjs"); +const serverId = "official-apps-fixture"; +const toolName = "get-time"; +const effectiveToolName = `mcp__${serverId}__${toolName}`; +const invocationToken = "fixture-model-call-1"; +const resourceUri = "ui://get-time/mcp-app.html"; +const envelopeVersion = "1"; +const appsProtocolVersion = "2026-01-26"; +const appRequestId = "app-call-1"; + +interface JsonRpcResponse { + id?: number; + result?: Record; + error?: { code: number; message: string; data?: { kind?: string } }; +} + +function invariant(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function findToolCallId(value: unknown, title: string): string | undefined { + if (Array.isArray(value)) { + for (const item of value) { + const found = findToolCallId(item, title); + if (found) return found; + } + return undefined; + } + if (!value || typeof value !== "object") return undefined; + const object = value as Record; + if (object.title === title && typeof object.toolCallId === "string") return object.toolCallId; + for (const child of Object.values(object)) { + const found = findToolCallId(child, title); + if (found) return found; + } + return undefined; +} + +function hasCompletedToolCall(value: unknown, toolCallId: string): boolean { + if (Array.isArray(value)) return value.some((item) => hasCompletedToolCall(item, toolCallId)); + if (!value || typeof value !== "object") return false; + const object = value as Record; + if (object.toolCallId === toolCallId && object.status === "completed") return true; + return Object.values(object).some((child) => hasCompletedToolCall(child, toolCallId)); +} + +class AcpClient { + private readonly pending = new Map< + number, + { resolve: (response: JsonRpcResponse) => void; reject: (error: Error) => void; timeout: NodeJS.Timeout } + >(); + private readonly notifications: Record[] = []; + private readonly waiters: Array<{ + predicate: (message: Record) => boolean; + resolve: (message: Record) => void; + timeout: NodeJS.Timeout; + }> = []; + private readonly lines: readline.Interface; + private stderr = ""; + + constructor(readonly process: ChildProcessWithoutNullStreams) { + process.stderr.on("data", (chunk) => { + this.stderr += String(chunk); + }); + this.lines = readline.createInterface({ input: process.stdout }); + this.lines.on("line", (line) => { + let response: JsonRpcResponse; + try { + response = JSON.parse(line) as JsonRpcResponse; + } catch { + return; + } + if (typeof response.id !== "number") { + const notification = response as Record; + this.notifications.push(notification); + for (let index = this.waiters.length - 1; index >= 0; index -= 1) { + const waiter = this.waiters[index]; + if (!waiter.predicate(notification)) continue; + clearTimeout(waiter.timeout); + this.waiters.splice(index, 1); + waiter.resolve(notification); + } + return; + } + const pending = this.pending.get(response.id); + if (!pending) return; + clearTimeout(pending.timeout); + this.pending.delete(response.id); + pending.resolve(response); + }); + process.once("exit", (code, signal) => { + const error = new Error(`Peri exited: code=${String(code)} signal=${String(signal)}\n${this.stderr}`); + for (const pending of this.pending.values()) { + clearTimeout(pending.timeout); + pending.reject(error); + } + this.pending.clear(); + }); + } + + request(id: number, method: string, params: unknown): Promise { + const response = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`timed out waiting for ACP response ${id}\n${this.stderr}`)); + }, 120_000); + this.pending.set(id, { resolve, reject, timeout }); + }); + this.process.stdin.write(`${JSON.stringify({ jsonrpc: "2.0", id, method, params })}\n`); + return response; + } + + waitForNotification( + predicate: (message: Record) => boolean, + description: string, + ): Promise> { + const existing = this.notifications.find(predicate); + if (existing) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + const index = this.waiters.findIndex((waiter) => waiter.resolve === resolve); + if (index >= 0) this.waiters.splice(index, 1); + reject(new Error(`timed out waiting for ${description}\n${this.stderr}`)); + }, 120_000); + this.waiters.push({ predicate, resolve, timeout }); + }); + } + + diagnostics() { + return this.stderr; + } + + close() { + this.lines.close(); + this.process.stdin.end(); + this.process.kill("SIGTERM"); + } +} + +async function startModelServer() { + let calls = 0; + const server = http.createServer((request, response) => { + if (request.method !== "POST" || request.url !== "/v1/chat/completions") { + response.writeHead(404).end(); + return; + } + request.resume(); + request.on("end", () => { + calls += 1; + response.writeHead(200, { + "content-type": "text/event-stream", + "cache-control": "no-cache", + connection: "keep-alive", + }); + const chunk = + calls === 1 + ? { + id: "fixture-model-tool-call", + choices: [ + { + delta: { + tool_calls: [ + { + index: 0, + id: invocationToken, + function: { name: effectiveToolName, arguments: "{}" }, + }, + ], + }, + finish_reason: "tool_calls", + }, + ], + } + : { + id: "fixture-model-complete", + choices: [{ delta: { content: "fixture complete" }, finish_reason: "stop" }], + }; + response.write(`data: ${JSON.stringify(chunk)}\n\n`); + response.end("data: [DONE]\n\n"); + }); + }); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address(); + invariant(address && typeof address === "object", "mock model server did not bind a TCP port"); + return { + baseUrl: `http://127.0.0.1:${address.port}/v1`, + calls: () => calls, + close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))), + }; +} + +async function writeWorkspace(workspace: string, modelBaseUrl: string) { + await fs.writeFile( + path.join(workspace, ".mcp.json"), + JSON.stringify({ + mcpServers: { + [serverId]: { command: process.execPath, args: [tsxCli, fixtureServer] }, + }, + }), + ); + const settingsPath = path.join(workspace, "settings.json"); + await fs.writeFile( + settingsPath, + JSON.stringify({ + config: { + active_alias: "sonnet", + providers: [ + { + id: "fixture-model", + type: "openai", + apiKey: "fixture-not-a-secret", + baseUrl: modelBaseUrl, + models: { sonnet: "fixture-model" }, + }, + ], + profiles: { + sonnet: { provider: "fixture-model", model: "fixture-model", effort: "medium", max_tokens: 1024 }, + }, + }, + }), + ); + return settingsPath; +} + +function startPeri(workspace: string, settingsPath: string, appsEnabled: boolean) { + const env = { ...process.env }; + if (appsEnabled) env.PERI_MCP_APPS = ""; + else delete env.PERI_MCP_APPS; + delete env.ANTHROPIC_API_KEY; + delete env.OPENAI_API_KEY; + const child = spawn( + "cargo", + [ + "run", + "-q", + "-p", + "peri-tui", + "--", + "--config-file", + settingsPath, + "acp", + "--cwd", + workspace, + ], + { cwd: repoRoot, env, stdio: ["pipe", "pipe", "pipe"] }, + ); + return new AcpClient(child); +} + +function expectResult(response: JsonRpcResponse, context: string): Record { + invariant(response.result, `${context} failed: ${JSON.stringify(response.error)}`); + return response.result; +} + +async function runSuccessfulRelay(workspace: string, settingsPath: string) { + const client = startPeri(workspace, settingsPath, true); + try { + expectResult( + await client.request(1, "initialize", { + protocolVersion: 1, + clientCapabilities: {}, + clientInfo: { name: "mcp-apps-e2e", version: "1.0.0" }, + }), + "initialize", + ); + await new Promise((resolve) => setTimeout(resolve, 2_000)); + + const session = expectResult(await client.request(2, "session/new", { cwd: workspace }), "session/new"); + const sessionId = session.sessionId; + invariant(typeof sessionId === "string", "session/new did not return sessionId"); + + const toolStarted = client.waitForNotification( + (message) => findToolCallId(message, effectiveToolName) !== undefined, + "canonical MCP ToolCall notification", + ); + const promptResponse = client.request(3, "session/prompt", { + sessionId, + prompt: [{ type: "text", text: "Call the MCP Apps fixture tool exactly once." }], + }); + const startedMessage = await toolStarted; + const observedInvocationToken = findToolCallId(startedMessage, effectiveToolName); + invariant(typeof observedInvocationToken === "string", "ToolCall notification did not expose toolCallId"); + invariant( + observedInvocationToken === invocationToken, + "ACP wire changed the MCP tool invocation token", + ); + const toolCompleted = client.waitForNotification( + (message) => hasCompletedToolCall(message, observedInvocationToken), + "completed canonical MCP ToolCallUpdate notification", + ); + await toolCompleted; + + const openResponse = await client.request(4, "peri/mcp/open", { + envelopeVersion, + appsProtocolVersion, + serverId, + ownerSessionId: sessionId, + invocationToken: observedInvocationToken, + toolName, + }); + const opened = expectResult( + openResponse, + "peri/mcp/open", + ); + invariant(typeof opened.appSessionId === "string", "open response is missing appSessionId"); + invariant(opened.resourceUri === resourceUri, "open response changed resourceUri"); + const appSessionId = opened.appSessionId; + + const resource = expectResult( + await client.request(5, "peri/mcp/resource", { + envelopeVersion, + appsProtocolVersion, + serverId, + appSessionId, + resourceUri, + }), + "peri/mcp/resource", + ); + const resources = resource.resources; + invariant(Array.isArray(resources) && resources.length === 1, "resource relay did not preserve contents[]"); + const html = resources[0] as Record; + invariant(html.uri === resourceUri, "resource relay changed uri"); + invariant(html.mimeType === "text/html;profile=mcp-app", "resource relay changed MIME"); + invariant(typeof html.text === "string" && html.text.includes(""), "resource relay lost HTML text"); + const resourceMeta = html._meta as { ui?: { csp?: { connectDomains?: unknown[]; resourceDomains?: unknown[] } } }; + invariant(resourceMeta.ui?.csp?.connectDomains?.length === 0, "resource relay lost _meta.ui.csp.connectDomains"); + invariant(resourceMeta.ui?.csp?.resourceDomains?.length === 0, "resource relay lost _meta.ui.csp.resourceDomains"); + + const app = expectResult( + await client.request(6, "peri/mcp/app", { + envelopeVersion, + appsProtocolVersion, + serverId, + appSessionId, + resourceUri, + payload: { + jsonrpc: "2.0", + id: appRequestId, + method: "tools/call", + params: { name: toolName, arguments: {} }, + }, + }), + "peri/mcp/app", + ); + const payload = app.payload as { id?: unknown; result?: Record }; + invariant(payload.id === appRequestId, "App JSON-RPC request id did not round-trip"); + invariant(payload.result, "App tools/call is missing result"); + invariant(payload.result.isError !== true, "App tools/call returned isError=true"); + const content = payload.result.content; + invariant(Array.isArray(content) && content.length === 1, "App tools/call lost content[]"); + invariant( + (content[0] as { type?: string; text?: string }).type === "text" && + typeof (content[0] as { text?: string }).text === "string", + "App tools/call lost text fallback", + ); + const structured = payload.result.structuredContent as { iso?: string } | undefined; + invariant(typeof structured?.iso === "string" && !Number.isNaN(Date.parse(structured.iso)), "App tools/call lost structuredContent.iso"); + invariant( + (payload.result._meta as { fixture?: string } | undefined)?.fixture === "peri-mcp-apps", + "App tools/call lost result _meta", + ); + expectResult(await promptResponse, "session/prompt"); + + return { sessionId, appSessionId, resourceCount: resources.length, appRequestId }; + } finally { + client.close(); + } +} + +async function runDisabledProbe(workspace: string, settingsPath: string) { + const client = startPeri(workspace, settingsPath, false); + try { + expectResult( + await client.request(10, "initialize", { + protocolVersion: 1, + clientCapabilities: {}, + clientInfo: { name: "mcp-apps-disabled-check", version: "1.0.0" }, + }), + "disabled initialize", + ); + const opened = await client.request(11, "peri/mcp/open", { + envelopeVersion, + appsProtocolVersion, + serverId, + ownerSessionId: "disabled", + invocationToken: "disabled", + toolName, + }); + invariant(opened.error?.data?.kind === "capability_disabled", "missing PERI_MCP_APPS did not disable relay"); + return opened.error.data.kind; + } finally { + client.close(); + } +} + +const workspace = await fs.mkdtemp(path.join(os.tmpdir(), "peri-mcp-apps-e2e-")); +const model = await startModelServer(); +try { + const settingsPath = await writeWorkspace(workspace, model.baseUrl); + const success = await runSuccessfulRelay(workspace, settingsPath); + invariant(model.calls() >= 2, "canonical session/prompt did not execute the model/tool loop"); + const disabledProbe = await runDisabledProbe(workspace, settingsPath); + process.stdout.write(JSON.stringify({ ok: true, ...success, modelCalls: model.calls(), disabledProbe }, null, 2) + "\n"); +} finally { + await model.close(); + await fs.rm(workspace, { recursive: true, force: true }); +} diff --git a/side-projects/mcp-apps/check.ts b/side-projects/mcp-apps/check.ts new file mode 100644 index 00000000..259dd6d5 --- /dev/null +++ b/side-projects/mcp-apps/check.ts @@ -0,0 +1,83 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const RESOURCE_URI = "ui://get-time/mcp-app.html"; +const MIME = "text/html;profile=mcp-app"; +const projectDir = path.dirname(fileURLToPath(import.meta.url)); + +function invariant(condition: unknown, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +const client = new Client( + { name: "peri-mcp-apps-check", version: "1.0.0" }, + { + capabilities: { + extensions: { + "io.modelcontextprotocol/ui": { + mimeTypes: [MIME], + }, + }, + }, + }, +); + +const transport = new StdioClientTransport({ + command: process.execPath, + args: [path.join(projectDir, "node_modules", "tsx", "dist", "cli.mjs"), "stdio-server.ts"], + cwd: projectDir, + stderr: "pipe", +}); + +try { + await client.connect(transport); + + const tools = await client.listTools(); + const getTime = tools.tools.find((tool) => tool.name === "get-time"); + invariant(getTime, "tools/list did not return get-time"); + + const meta = getTime._meta as + | { ui?: { resourceUri?: string; visibility?: string[] } } + | undefined; + invariant( + meta?.ui?.resourceUri === RESOURCE_URI, + "get-time is missing _meta.ui.resourceUri", + ); + invariant( + meta.ui.visibility?.includes("app") === true, + "get-time is not app-visible", + ); + + const resource = await client.readResource({ uri: RESOURCE_URI }); + invariant(resource.contents.length > 0, "resources/read returned no contents"); + const content = resource.contents[0]; + invariant(content.uri === RESOURCE_URI, "resource URI did not round-trip"); + invariant(content.mimeType === MIME, "resource MIME is not MCP Apps HTML"); + invariant("text" in content && content.text.includes(""), "resource is not bundled HTML"); + + const result = await client.callTool({ name: "get-time", arguments: {} }); + const structured = result.structuredContent as { iso?: string } | undefined; + invariant(typeof structured?.iso === "string", "tools/call is missing structuredContent.iso"); + invariant(!Number.isNaN(Date.parse(structured.iso)), "structuredContent.iso is not an ISO date"); + const contentBlocks = Array.isArray(result.content) ? result.content : []; + invariant(contentBlocks.length > 0, "tools/call is missing text fallback content"); + invariant(result.isError !== true, "tools/call returned isError=true"); + + process.stdout.write( + JSON.stringify( + { + ok: true, + tool: getTime.name, + resourceUri: content.uri, + mimeType: content.mimeType, + structuredContent: structured, + }, + null, + 2, + ) + "\n", + ); +} finally { + await client.close(); +} diff --git a/side-projects/mcp-apps/package.json b/side-projects/mcp-apps/package.json index 0deb7efc..491c4ffd 100644 --- a/side-projects/mcp-apps/package.json +++ b/side-projects/mcp-apps/package.json @@ -7,11 +7,16 @@ "scripts": { "chat": "bun run chat/server.ts", "get-time": "bunx tsx server.ts", + "get-time:stdio": "bunx tsx stdio-server.ts", + "check": "npm run build && bunx tsx check.ts", + "check:peri": "npm run build && bunx tsx check-peri.ts", + "typecheck": "bunx tsc --noEmit", "build": "INPUT=mcp-app.html bunx vite build" }, "dependencies": { "@modelcontextprotocol/ext-apps": "^1.7.5", - "@modelcontextprotocol/sdk": "^1.30.0" + "@modelcontextprotocol/sdk": "^1.30.0", + "zod": "^4.4.3" }, "devDependencies": { "@types/cors": "^2.8.19", diff --git a/side-projects/mcp-apps/stdio-server.ts b/side-projects/mcp-apps/stdio-server.ts new file mode 100644 index 00000000..10a64d56 --- /dev/null +++ b/side-projects/mcp-apps/stdio-server.ts @@ -0,0 +1,82 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { + registerAppResource, + registerAppTool, + RESOURCE_MIME_TYPE, +} from "@modelcontextprotocol/ext-apps/server"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { z } from "zod"; + +export const RESOURCE_URI = "ui://get-time/mcp-app.html"; + +const server = new McpServer({ + name: "Peri MCP Apps Fixture", + version: "1.0.0", +}); + +registerAppTool( + server, + "get-time", + { + title: "Get Time", + description: "Returns the current server time for the MCP Apps fixture.", + inputSchema: {}, + outputSchema: { iso: z.string().datetime() }, + _meta: { + ui: { + resourceUri: RESOURCE_URI, + visibility: ["model", "app"], + }, + }, + }, + async () => { + const iso = new Date().toISOString(); + return { + content: [{ type: "text", text: iso }], + structuredContent: { iso }, + _meta: { fixture: "peri-mcp-apps" }, + }; + }, +); + +registerAppResource( + server, + RESOURCE_URI, + RESOURCE_URI, + { + mimeType: RESOURCE_MIME_TYPE, + _meta: { + ui: { + prefersBorder: true, + }, + }, + }, + async () => { + const html = await fs.readFile( + path.join(import.meta.dirname, "dist", "mcp-app.html"), + "utf-8", + ); + return { + contents: [ + { + uri: RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + prefersBorder: true, + csp: { + connectDomains: [], + resourceDomains: [], + }, + }, + }, + }, + ], + }; + }, +); + +await server.connect(new StdioServerTransport()); diff --git a/spec/issues/2026-08-27-mcp-apps-stdio-relay.md b/spec/issues/2026-08-27-mcp-apps-stdio-relay.md index 481afa44..ba1e5573 100644 --- a/spec/issues/2026-08-27-mcp-apps-stdio-relay.md +++ b/spec/issues/2026-08-27-mcp-apps-stdio-relay.md @@ -54,12 +54,14 @@ PERI_MCP_APPS 环境变量存在 "envelopeVersion": "1", "appsProtocolVersion": "2026-01-26", "serverId": "server", - "toolName": "open_dashboard" + "toolName": "open_dashboard", + "ownerSessionId": "agent-session", + "invocationToken": "opaque-invocation-token" } } ``` -Peri 校验:server 已连接、tool 存在、visibility 包含 `app`、tool metadata 含合法且无冲突的 `ui://` resource URI。成功返回 opaque `appSessionId`、resource URI 和 MCP core version。 +Peri 校验:`ownerSessionId` 与 `invocationToken` 两个 lease 字段均为必填,并与待消费 lease 精确匹配;同时校验 server 已连接、tool 存在、visibility 包含 `app`、tool metadata 含合法且无冲突的 `ui://` resource URI。成功返回 opaque `appSessionId`、resource URI 和 MCP core version。 ### Resource @@ -100,6 +102,28 @@ InitialMcpAppBindingLease { server generation 在每次 pool connection commit/失败替换时显式递增;验证只查 server name 对应的 generation,不使用 `Arc` 地址或指针相等推断。stdio 装配点读取一次环境并构造 immutable profile;pool、initial/reconnect 和 Dynamic MCP connector 复用同一 profile,TUI/MPSC 入口固定 disabled 且不读取/传播环境。Apps requests 由 connection-owned task 异步执行;除 `open` 的短临界提交外不跨网络 await 持 connection 锁,EOF cancellation 与 response 发送竞争后每个已接收 request 至多产生一个 terminal response。 +## 成功路径故障记录 + +### 现象 + +官方风格 fixture 已能驱动模型调用 App 实例化工具并收到 completed tool update,但随后 `peri/mcp/open` 无法消费对应 Binding lease。逐项排除 prompt 完成时序、invocation token、普通 cleanup 与 session-turn revoke 后,registry identity 诊断确认:tool bridge 在 registry A 签发,relay 在 registry B 消费。 + +### 根因 + +启用 Dynamic MCP 时,`McpMiddleware` 使用 `CheckedSessionMcpProjection` 持有的 session projection pool。该 pool 是独立 `McpClientPool`,仅复制 effective handles,不共享 deployment pool 的 `app_binding_leases` 与 handle generation identity。因此 static MCP tool bridge 从 projection pool 构建后,签发的 lease 无法被绑定 deployment pool 的 `PoolMcpAppsRelay` 消费。 + +这不是 stale catalog:dynamic tools 本来就由 `SessionToolCatalog` capability overlay 注入;问题是 static bridge 错用了 projection pool。 + +### 修复 + +`McpMiddleware` 明确区分两个视图: + +- session projection pool 继续负责 resources、discovery 与 status; +- deployment-owned pool 专门构建 static MCP tool bridges,保留真实 server generation 与 Apps lease registry; +- dynamic tools 仍由 `SessionToolCatalog` overlay 注入,保持 session 隔离和 shadow 语义。 + +同时保留 initial invocation cancellation 对 lease 的约束;未采用“lease 自持独立 cancellation token”的临时实验。失败的 MCP `CallToolResult` 仍先按 `isError` 结算,不得签发 App lease。 + ## 安全不变量 1. 环境变量不存在时,extension 与 relay 都不存在。 @@ -115,20 +139,38 @@ server generation 在每次 pool connection commit/失败替换时显式递增 已覆盖: -- env profile presence/absence helper; -- MCP UI extension 构造; +- env profile presence/absence;环境变量存在且值为空时启用,不存在时 fail closed; +- initial/reconnect/channel MCP initialize 的 UI extension 构造; - visibility 与 resource URI canonicalization; - raw resource/result roundtrip; -- connection-owned binding 与 EOF 清理; +- connection-owned binding、session close 与 EOF 清理; - Apps envelope/version/JSON-RPC result/error 校验; -- `peri-acp` 全量 lib 测试。 +- static MCP bridge 使用 deployment pool,而 resources/discovery 保持 session projection; +- 官方风格 stdio fixture 的 `tools/list → resources/read → tools/call`; +- 真实 Peri successful path:模型实例化工具 → completed update → `peri/mcp/open` → resource → App `tools/call`; +- disabled deployment probe 返回 `capability_disabled`。 + +本次验收证据: + +```text +cargo test -p peri-middlewares --lib + 1587 passed +cargo test -p peri-acp --lib mcp_apps + 2 passed +cargo clippy -p peri-middlewares -p peri-acp --all-targets -- -D warnings + passed +cd side-projects/mcp-apps && npm run check:peri + ok=true, resourceCount=1, modelCalls=3, disabledProbe=capability_disabled +cargo fmt --all -- --check + passed +git diff --check + passed +``` -最终验收还需: +后续增强项不阻塞 stdio successful path: -- fake MCP server 断言环境变量存在时 initial/reconnect initialize extension; -- stdio wire `open → resource` E2E; -- Binding lease 的 HITL approve/deny、cancel、expiry、server generation E2E; -- App 主动调用不重复进入模型事件投影。 +- Binding lease 的 HITL deny、expiry 与 server reconnect wire-level fixture; +- 更高并发下多个 App session 的隔离压力测试。 ## 目标命令 From 9a288f4441ab6dafa8621901a89cac49bf62683a Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Fri, 28 Aug 2026 10:39:40 +0800 Subject: [PATCH 4/8] fix(resources): cover all filesystem metadata mutations Hold the filesystem metadata lock across message append and metadata update, and serialize cache invalidation and rewind mutations to prevent lost updates. Co-Authored-By: gpt-5.6-sol --- peri-resources/src/sessions/filesystem.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/peri-resources/src/sessions/filesystem.rs b/peri-resources/src/sessions/filesystem.rs index 972dfe20..0c246e69 100644 --- a/peri-resources/src/sessions/filesystem.rs +++ b/peri-resources/src/sessions/filesystem.rs @@ -134,6 +134,7 @@ impl ThreadStore for FilesystemThreadStore { if msgs.is_empty() { return Ok(()); } + let _guard = META_UPDATE_LOCK.lock().await; let path = self.messages_path(id); let mut file = tokio::fs::OpenOptions::new() .create(true) @@ -151,7 +152,6 @@ impl ThreadStore for FilesystemThreadStore { // 更新 meta 的 message_count 和 updated_at。与状态更新共用锁,避免基于 // 旧快照写回时把 done/error/cancelled 覆盖成 active。 - let _guard = META_UPDATE_LOCK.lock().await; let mut meta = self.load_meta(id).await?; meta.message_count += msgs.len(); meta.updated_at = Utc::now(); @@ -295,6 +295,7 @@ impl ThreadStore for FilesystemThreadStore { } async fn invalidate_context_cache(&self, thread_id: &ThreadId) -> Result<()> { + let _guard = META_UPDATE_LOCK.lock().await; let mut meta = self.load_meta(thread_id).await?; meta.cached_context = None; self.update_meta(thread_id, meta).await @@ -337,6 +338,7 @@ impl ThreadStore for FilesystemThreadStore { thread_id: &ThreadId, message_id: &peri_acp_types::messages::MessageId, ) -> Result<()> { + let _guard = META_UPDATE_LOCK.lock().await; // 重写 messages.jsonl:保留 message_id 所在位置(含)之前所有行。 let path = self.messages_path(thread_id); if !path.exists() { From 1d3e08c456c3bf043745cb95d4638f9d6f7fee40 Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Fri, 28 Aug 2026 11:13:00 +0800 Subject: [PATCH 5/8] =?UTF-8?q?chore:=20windows=20=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/mcp/dynamic/registry_test.rs | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/peri-middlewares/src/mcp/dynamic/registry_test.rs b/peri-middlewares/src/mcp/dynamic/registry_test.rs index 705267ed..7f663c6e 100644 --- a/peri-middlewares/src/mcp/dynamic/registry_test.rs +++ b/peri-middlewares/src/mcp/dynamic/registry_test.rs @@ -890,17 +890,28 @@ async fn drain_timeout_never_reports_unloaded_and_can_be_retried() { DynamicMcpResponse::Accepted(accepted) => accepted.operation_id, response => panic!("unexpected response: {response:?}"), }; - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - let status = registry - .execute( - "session-a", - CanonicalDynamicMcpAction::Status(DynamicMcpStatusRequest { - operation_id: Some(operation_id), - ..Default::default() - }), - ) - .await - .unwrap(); + let status = tokio::time::timeout(std::time::Duration::from_secs(2), async { + loop { + let status = registry + .execute( + "session-a", + CanonicalDynamicMcpAction::Status(DynamicMcpStatusRequest { + operation_id: Some(operation_id.clone()), + ..Default::default() + }), + ) + .await + .unwrap(); + if matches!(status, DynamicMcpResponse::Status(ref value) + if value.operations[0].state == DynamicMcpOperationState::Failed) + { + break status; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("drain timeout operation did not settle"); assert!(matches!(status, DynamicMcpResponse::Status(ref value) if value.operations[0].state == DynamicMcpOperationState::Failed && value.operations[0].error.as_ref().is_some_and(|failure| failure.code == DynamicMcpErrorCode::ShutdownIncomplete))); From 30a05f17387396cd70e92f182ea5b596fb95105c Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Fri, 28 Aug 2026 13:59:45 +0800 Subject: [PATCH 6/8] fix(tui): correct subagent snapshot cache fingerprint Co-Authored-By: gpt-5.6-sol --- peri-tui/src/kit/acp_events/render.rs | 5 +- .../src/kit/acp_events_test/snapshot_test.rs | 46 +++++++++++++++++++ .../acp_events_test/subagent_loading_test.rs | 24 +++++----- 3 files changed, 61 insertions(+), 14 deletions(-) diff --git a/peri-tui/src/kit/acp_events/render.rs b/peri-tui/src/kit/acp_events/render.rs index 69537fda..41280635 100644 --- a/peri-tui/src/kit/acp_events/render.rs +++ b/peri-tui/src/kit/acp_events/render.rs @@ -194,8 +194,8 @@ fn group_input_fingerprint(segment: &im::Vector) -> (u64, bool) { let last = segment.len().saturating_sub(1); for (i, vm) in segment.iter().enumerate() { let is_trailing_bubble = i == last && matches!(vm, TuiRenderUnit::TuiAssistantBubble(_)); - h = if is_trailing_bubble { - tui_hash_combine(h, TRAILING_BUBBLE_MARKER) + let entry_hash = if is_trailing_bubble { + TRAILING_BUBBLE_MARKER } else { match vm { TuiRenderUnit::TuiToolCard(t) => { @@ -227,6 +227,7 @@ fn group_input_fingerprint(segment: &im::Vector) -> (u64, bool) { } } }; + h = tui_hash_combine(h, entry_hash); } h = tui_hash_combine(h, segment.len() as u64); // [S2 单一事实源] 焦点键——焦点工具免疫随焦点变化切换(命中/未命中必须 diff --git a/peri-tui/src/kit/acp_events_test/snapshot_test.rs b/peri-tui/src/kit/acp_events_test/snapshot_test.rs index eca74810..e7761178 100644 --- a/peri-tui/src/kit/acp_events_test/snapshot_test.rs +++ b/peri-tui/src/kit/acp_events_test/snapshot_test.rs @@ -139,6 +139,52 @@ fn test_snapshot_todo_summary_before_final_answer() { .all(|vm| !matches!(vm, TuiRenderUnit::TuiTodoSummary(_))), "回答后无 todo 摘要" ); + crate::kit::atoms::TODO_ITEMS.state().write().clear(); +} + +/// [回归] todo 摘要位于 subagent group 之后时,快照分组缓存的指纹必须包含 +/// group 本身;否则 child 内容变化但末尾摘要不变会误命中旧缓存。 +#[test] +#[serial] +fn test_snapshot_cache_tracks_subagent_before_todo_summary() { + let mut state = make_fold_test_state(); + *crate::kit::atoms::TODO_ITEMS.state().write() = vec![crate::kit::message_area::TodoItem { + status: crate::kit::message_area::TodoStatus::InProgress, + content: "subagent fingerprint regression sentinel".into(), + }]; + + dispatch_and_notify( + &mut state, + &AcpEventData::SubagentStarted { + agent_id: "cache-agent".into(), + agent_name: "researcher".into(), + is_background: false, + }, + ); + dispatch_and_notify( + &mut state, + &AcpEventData::TextChunk(TuiTextChunk { + text: "child text".into(), + message_id: Some("cache-child".into()), + agent_id: Some("cache-agent".into()), + }), + ); + + let snap = VIEW_MODELS.state().read().clone(); + assert_eq!(snap.items.len(), 2, "group 后应保留 todo 摘要"); + match &snap.items[0] { + TuiRenderUnit::TuiSubAgentGroup(group) => { + assert_eq!(group.agent_id, "cache-agent"); + assert_eq!( + group.view_models.len(), + 1, + "child 更新后不得复用启动时的空 group 缓存" + ); + } + other => panic!("expected TuiSubAgentGroup at [0], got {other:?}"), + } + + crate::kit::atoms::TODO_ITEMS.state().write().clear(); } /// §7 工具分组:相邻成功 Generic 工具压成 TuiCollapsedGroup(标题含隐藏数); diff --git a/peri-tui/src/kit/acp_events_test/subagent_loading_test.rs b/peri-tui/src/kit/acp_events_test/subagent_loading_test.rs index 8e1a984b..3f49222c 100644 --- a/peri-tui/src/kit/acp_events_test/subagent_loading_test.rs +++ b/peri-tui/src/kit/acp_events_test/subagent_loading_test.rs @@ -43,9 +43,9 @@ fn test_dispatch_subagent_streaming_updates_current_turn_group() { }), ); - let snapshot = VIEW_MODELS.state().read().clone(); - assert_eq!(snapshot.items.len(), 1); - match &snapshot.items[0] { + let current_turn = state.current_turn.view_models().clone(); + assert_eq!(current_turn.len(), 1); + match ¤t_turn[0] { TuiRenderUnit::TuiSubAgentGroup(group) => { assert_eq!(group.agent_id, "agent-1"); assert_eq!(group.view_models.len(), 1); @@ -102,8 +102,8 @@ fn test_subagent_stopped_freezes_child_trailing_bubble() { ); // 流式期间:子 turn trailing bubble 持有 started_at(Running 形态)。 - let running_snap = VIEW_MODELS.state().read().clone(); - let b = match &running_snap.items[0] { + let running_vms = state.current_turn.view_models().clone(); + let b = match &running_vms[0] { TuiRenderUnit::TuiSubAgentGroup(g) => match &g.view_models[0] { TuiRenderUnit::TuiAssistantBubble(b) => b, other => panic!("expected child TuiAssistantBubble, got {other:?}"), @@ -127,8 +127,8 @@ fn test_subagent_stopped_freezes_child_trailing_bubble() { // stop 后:started_at 清除 + duration_ms 冻结(详情面板不再显示增长中的 // `◐ Thinking… Ns`)。 - let snap = VIEW_MODELS.state().read().clone(); - let b = match &snap.items[0] { + let stopped_vms = state.current_turn.view_models().clone(); + let b = match &stopped_vms[0] { TuiRenderUnit::TuiSubAgentGroup(g) => match &g.view_models[0] { TuiRenderUnit::TuiAssistantBubble(b) => b, other => panic!("expected child TuiAssistantBubble, got {other:?}"), @@ -618,7 +618,7 @@ fn test_prompt_submitted_sets_loading() { } /// 同步 sub-agent 的 ToolStarted/ToolEnded 事件应路由到 SubAgentAccumulator, -/// 并反映在 VIEW_MODELS 的 TuiSubAgentGroup 中。 +/// 并反映在当前 turn 的 TuiSubAgentGroup 中。 #[test] #[serial] fn test_dispatch_sync_subagent_tool_routed_to_group() { @@ -676,10 +676,10 @@ fn test_dispatch_sync_subagent_tool_routed_to_group() { }), ); - let snapshot = VIEW_MODELS.state().read().clone(); - // items 中应有 1 个 TuiSubAgentGroup - assert_eq!(snapshot.items.len(), 1, "items 应包含 1 个元素"); - match &snapshot.items[0] { + let current_turn = state.current_turn.view_models().clone(); + // current_turn 中应有 1 个 TuiSubAgentGroup + assert_eq!(current_turn.len(), 1, "current_turn 应包含 1 个元素"); + match ¤t_turn[0] { TuiRenderUnit::TuiSubAgentGroup(group) => { assert_eq!(group.agent_id, "sync-1"); assert!( From 30214067e875b720b0745c6d26af5a7fbcb83b70 Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Fri, 28 Aug 2026 16:57:07 +0800 Subject: [PATCH 7/8] fix(acp): harden stdio error responses Return protocol errors for malformed and invalid JSON-RPC input, preserve terminal responses when host task admission closes, and make response write failures observable.\n\nCo-Authored-By: gpt-5.6-sol --- peri-acp/src/host/mod.rs | 27 ++++- .../host/stdio/run_server_integration_test.rs | 34 ++++++ peri-acp/src/transport/stdio.rs | 108 ++++++++++++++++-- peri-acp/src/transport/stdio_test.rs | 102 ++++++++++++++++- 4 files changed, 257 insertions(+), 14 deletions(-) diff --git a/peri-acp/src/host/mod.rs b/peri-acp/src/host/mod.rs index c556e44f..db4977e8 100644 --- a/peri-acp/src/host/mod.rs +++ b/peri-acp/src/host/mod.rs @@ -526,7 +526,9 @@ async fn run_acp_server_inner( let cfg = Arc::clone(&cfg); let cont_tx = cont_tx.clone(); let prompt_spawner = cfg.host_task_spawner.clone(); - let _ = prompt_spawner.spawn( + let rejected_transport = Arc::clone(&transport); + let rejected_id = id.clone(); + let spawn_result = prompt_spawner.spawn( task_scope::HostTaskOwnerKind::Session, task_scope::HostTaskKind::Prompt, async move { @@ -541,13 +543,30 @@ async fn run_acp_server_inner( &cont_tx, ) .await; - let _ = transport.send_response(id, result).await; + if let Err(error) = transport.send_response(id, result).await { + tracing::warn!(%error, "prompt terminal response send failed"); + return; + } if !prompt_session_id.is_empty() { send_session_info_update(transport.as_ref(), &prompt_session_id) .await; } }, ); + if spawn_result.is_err() { + if let Err(error) = rejected_transport + .send_response( + rejected_id, + Err(crate::transport::types::AcpError::new( + -32800, + "request cancelled", + )), + ) + .await + { + tracing::warn!(%error, "rejected prompt response send failed"); + } + } } else if matches!( method.as_str(), "peri/mcp/open" | "peri/mcp/app" | "peri/mcp/resource" @@ -595,7 +614,9 @@ async fn run_acp_server_inner( } } => result, }; - let _ = transport.send_response(id, result).await; + if let Err(error) = transport.send_response(id, result).await { + tracing::warn!(%error, "MCP Apps terminal response send failed"); + } }, ); if spawn_result.is_err() { diff --git a/peri-acp/src/host/stdio/run_server_integration_test.rs b/peri-acp/src/host/stdio/run_server_integration_test.rs index 2068ca8d..245cf1b6 100644 --- a/peri-acp/src/host/stdio/run_server_integration_test.rs +++ b/peri-acp/src/host/stdio/run_server_integration_test.rs @@ -412,6 +412,40 @@ async fn await_server_exit(server_task: tokio::task::JoinHandle<()>, input: Dupl // ── 测试 ────────────────────────────────────────────────────────────────── +/// host task scope 已关闭时,prompt request 仍必须收到一次 terminal error response。 +#[tokio::test] +async fn test_rejected_prompt_task_returns_terminal_error() { + let tmp = tempfile::TempDir::new().unwrap(); + let cfg = test_config(&tmp); + cfg.host_task_owner + .as_ref() + .expect("test config should own host task scope") + .begin_shutdown(); + let (transport, mut input_write, mut output_read) = duplex_transport(); + let server_task = tokio::spawn(host::run_acp_server(Arc::new(transport), cfg)); + + write_line( + &mut input_write, + &json!({ + "jsonrpc": "2.0", + "id": "prompt-rejected", + "method": "session/prompt", + "params": { "sessionId": "missing", "prompt": [] } + }) + .to_string(), + ) + .await; + + let response: Value = serde_json::from_str(&read_line(&mut output_read).await).unwrap(); + assert_eq!(response["id"], "prompt-rejected"); + assert_eq!(response["error"]["code"], -32800); + assert_eq!(response["error"]["message"], "request cancelled"); + assert!(response.get("result").is_none()); + + drop(input_write); + server_task.await.expect("server task 不应 panic"); +} + /// initialize → session/new → AvailableCommandsUpdate 通知:stdout 侧完整断言。 /// 证明 StdioTransport 可承载 run_acp_server 的生命周期链路(wire 兼容 live 证明)。 #[tokio::test] diff --git a/peri-acp/src/transport/stdio.rs b/peri-acp/src/transport/stdio.rs index df936210..cece50e3 100644 --- a/peri-acp/src/transport/stdio.rs +++ b/peri-acp/src/transport/stdio.rs @@ -109,6 +109,9 @@ impl StdioTransport { let (incoming_tx, incoming_rx) = mpsc::unbounded_channel(); let router = RequestRouter::new(); let pump_router = router.clone(); + let writer: Arc>>> = + Arc::new(Mutex::new(BufWriter::new(Box::new(writer)))); + let pump_writer = Arc::clone(&writer); let cancel_hook = Arc::new(std::sync::Mutex::new(cancel_hook)); let pump_cancel_hook = Arc::clone(&cancel_hook); @@ -152,10 +155,46 @@ impl StdioTransport { continue; } - let mut envelope: JsonRpcEnvelope = match serde_json::from_str(&line) { - Ok(e) => e, - Err(e) => { - tracing::error!(error = %e, "Failed to parse JSON-RPC from stdin"); + let raw: Value = match serde_json::from_str(&line) { + Ok(value) => value, + Err(error) => { + tracing::warn!(%error, "Stdio transport: malformed JSON-RPC input"); + if send_protocol_error( + &pump_writer, + &pump_router, + Value::Null, + -32700, + "Parse error", + ) + .await + .is_err() + { + break; + } + continue; + } + }; + let invalid_id = raw + .get("id") + .filter(|id| is_domain_id(id)) + .cloned() + .unwrap_or(Value::Null); + let mut envelope: JsonRpcEnvelope = match serde_json::from_value(raw) { + Ok(envelope) => envelope, + Err(error) => { + tracing::warn!(%error, "Stdio transport: invalid JSON-RPC envelope"); + if send_protocol_error( + &pump_writer, + &pump_router, + invalid_id, + -32600, + "Invalid Request", + ) + .await + .is_err() + { + break; + } continue; } }; @@ -164,6 +203,31 @@ impl StdioTransport { let result_val = envelope.result.take(); let error_val = envelope.error.take(); + if envelope.jsonrpc != "2.0" + || (has_method && (result_val.is_some() || error_val.is_some())) + || (!has_method && result_val.is_some() == error_val.is_some()) + { + let id = envelope + .id + .as_ref() + .filter(|id| is_domain_id(id)) + .cloned() + .unwrap_or(Value::Null); + if send_protocol_error( + &pump_writer, + &pump_router, + id, + -32600, + "Invalid Request", + ) + .await + .is_err() + { + break; + } + continue; + } + // JSON-RPC 2.0 §2.2:Request 的 id 成员存在但为 null 时视为 // 通知(客户端无兴趣于对应响应,等同「无 id」)——进入 // `(None, true)` 通知分支,而非压 0 成请求(决策点 7 收口)。 @@ -203,8 +267,20 @@ impl StdioTransport { if !is_domain_id(&id) { tracing::warn!( id = %id, - "Ignoring JSON-RPC request with out-of-domain id" + "Rejecting JSON-RPC request with out-of-domain id" ); + if send_protocol_error( + &pump_writer, + &pump_router, + Value::Null, + -32600, + "Invalid Request", + ) + .await + .is_err() + { + break; + } continue; } let method = envelope.method.unwrap(); @@ -246,9 +322,7 @@ impl StdioTransport { Self { incoming_rx: tokio::sync::Mutex::new(incoming_rx), router, - writer: Arc::new(Mutex::new(BufWriter::new( - Box::new(writer) as Box - ))), + writer, cancel_hook, } } @@ -323,6 +397,24 @@ impl AcpTransport for StdioTransport { // ── helpers ────────────────────────────────────────────────────────────────── +async fn send_protocol_error( + writer: &Arc>>>, + router: &RequestRouter, + id: Value, + code: i64, + message: &'static str, +) -> Result<(), AcpError> { + let envelope = JsonRpcEnvelope { + jsonrpc: "2.0".to_string(), + id: Some(id), + method: None, + params: None, + result: None, + error: Some(AcpError::new(code, message)), + }; + write_envelope(writer, router, &envelope).await +} + async fn write_envelope( writer: &Arc>>>, router: &RequestRouter, diff --git a/peri-acp/src/transport/stdio_test.rs b/peri-acp/src/transport/stdio_test.rs index 5d5979ab..7378e630 100644 --- a/peri-acp/src/transport/stdio_test.rs +++ b/peri-acp/src/transport/stdio_test.rs @@ -158,10 +158,10 @@ async fn test_pump_parses_three_message_kinds_and_eof_closes() { ); } -/// 空行被跳过、非法 JSON 行仅 error 日志后继续——不中断后续报文解析。 +/// 空行被跳过;非法 JSON 返回 parse error 后继续解析后续报文。 #[tokio::test] -async fn test_invalid_json_line_skipped_keeps_parsing() { - let (transport, mut input, _output) = duplex_transport(); +async fn test_invalid_json_returns_error_and_keeps_parsing() { + let (transport, mut input, mut output) = duplex_transport(); write_line(&mut input, "").await; write_line(&mut input, "this is not json {").await; write_line( @@ -171,6 +171,10 @@ async fn test_invalid_json_line_skipped_keeps_parsing() { .await; drop(input); + let error: Value = serde_json::from_str(&read_line(&mut output).await).unwrap(); + assert_eq!(error["id"], Value::Null); + assert_eq!(error["error"]["code"], -32700); + match recv(&transport).await { Some(IncomingMessage::Request { id, method, .. }) => { assert_eq!(id, RequestId::Number(1)); @@ -643,6 +647,98 @@ async fn test_pump_rejects_out_of_domain_ids_and_null_id_becomes_notification() assert!(recv(&transport).await.is_none(), "EOF 后 pump 退出"); } +/// 非法 JSON 必须产生标准 JSON-RPC parse error,且不能阻断后续合法请求。 +#[tokio::test] +async fn test_malformed_json_returns_parse_error_and_pump_continues() { + let (transport, mut input, mut output) = duplex_transport(); + write_line(&mut input, r#"{"jsonrpc":"2.0","id":1,"#).await; + write_line( + &mut input, + r#"{"jsonrpc":"2.0","id":2,"method":"m/req","params":{}}"#, + ) + .await; + + let error: Value = serde_json::from_str(&read_line(&mut output).await).unwrap(); + assert_eq!(error["jsonrpc"], "2.0"); + assert!(error["id"].is_null()); + assert_eq!(error["error"]["code"], -32700); + assert_eq!(error["error"]["message"], "Parse error"); + assert!(error.get("result").is_none()); + + match recv(&transport).await { + Some(IncomingMessage::Request { id, method, .. }) => { + assert_eq!(id, RequestId::Number(2)); + assert_eq!(method, "m/req"); + } + other => panic!("parse error 后合法请求应继续进入 host,实际 {other:?}"), + } +} + +/// 可解析但不符合 JSON-RPC request envelope 的输入必须返回 Invalid Request。 +#[tokio::test] +async fn test_invalid_request_envelope_returns_invalid_request_error() { + let (_transport, mut input, mut output) = duplex_transport(); + write_line( + &mut input, + r#"{"jsonrpc":"1.0","id":"bad-1","method":"m/req","params":{}}"#, + ) + .await; + + let error: Value = serde_json::from_str(&read_line(&mut output).await).unwrap(); + assert_eq!(error["jsonrpc"], "2.0"); + assert_eq!(error["id"], "bad-1"); + assert_eq!(error["error"]["code"], -32600); + assert_eq!(error["error"]["message"], "Invalid Request"); + assert!(error.get("result").is_none()); +} + +/// 域外 request id 无法安全关联,必须以 id=null 返回 Invalid Request。 +#[tokio::test] +async fn test_out_of_domain_request_id_returns_invalid_request_error() { + let (_transport, mut input, mut output) = duplex_transport(); + write_line( + &mut input, + r#"{"jsonrpc":"2.0","id":true,"method":"m/req","params":{}}"#, + ) + .await; + + let error: Value = serde_json::from_str(&read_line(&mut output).await).unwrap(); + assert!(error["id"].is_null()); + assert_eq!(error["error"]["code"], -32600); + assert_eq!(error["error"]["message"], "Invalid Request"); +} + +/// Response 必须恰好包含 result/error 之一;两者并存不得结算 pending request。 +#[tokio::test] +async fn test_response_with_result_and_error_is_rejected() { + let (transport, mut input, mut output) = duplex_transport(); + let transport = Arc::new(transport); + let request = { + let transport = Arc::clone(&transport); + tokio::spawn(async move { transport.send_request("m/echo", json!({})).await }) + }; + let sent: Value = serde_json::from_str(&read_line(&mut output).await).unwrap(); + + write_line( + &mut input, + &json!({ + "jsonrpc": "2.0", + "id": sent["id"], + "result": "ignored", + "error": { "code": -32000, "message": "boom" } + }) + .to_string(), + ) + .await; + drop(input); + + let result = tokio::time::timeout(Duration::from_secs(5), request) + .await + .expect("EOF 后 pending request 应及时结束") + .expect("request task 不应 panic"); + assert!(result.is_err(), "非法 response 不得结算为业务响应"); +} + // ── legacy type:cancel(批 3 §7 #10 移植)────────────────────────────────── /// pump 对 `{"type":"cancel"}` 行(非 JSON-RPC)拦截:注入的 hook 收到原始行, From c1251ddcf50d9fd932e1a3a91b74d3a9b7ff283d Mon Sep 17 00:00:00 2001 From: KonghaYao <3446798488@qq.com> Date: Sat, 29 Aug 2026 06:20:41 +0800 Subject: [PATCH 8/8] feat(wasi): add preview 2 component and node gate --- .cargo/config.toml | 2 - Cargo.lock | 153 + Cargo.toml | 3 + docs/code-index/peri-acp-types.md | 5 +- docs/code-index/peri-agent.md | 9 +- docs/code-index/peri-turn-policy.md | 28 + docs/code-index/peri-wasi.md | 41 + docs/design/wasi.md | 90 +- peri-acp-types/Cargo.toml | 1 + peri-acp-types/src/messages/content.rs | 12 +- peri-acp-types/src/messages/content_test.rs | 1 + peri-agent/Cargo.toml | 1 + peri-agent/src/agent/compact_v2/mod.rs | 26 +- .../src/agent/compact_v2/trigger_test.rs | 83 + peri-turn-policy/Cargo.toml | 8 + peri-turn-policy/src/compact.rs | 24 + peri-turn-policy/src/content.rs | 15 + peri-turn-policy/src/lib.rs | 11 + peri-turn-policy/src/lib_test.rs | 88 + peri-wasi/Cargo.toml | 13 + peri-wasi/src/lib.rs | 53 + peri-wasi/wit/world.wit | 36 + .../2026-08-28-wasi-p2-node-validation.md | 96 + wasi-e2e/acquire-cargo.mjs | 263 ++ wasi-e2e/exercise.mjs | 84 + wasi-e2e/harness.mjs | 713 ++++ wasi-e2e/package-lock.json | 3382 +++++++++++++++++ wasi-e2e/package.json | 18 + wasi-e2e/wasi-p2.test.mjs | 128 + 29 files changed, 5338 insertions(+), 49 deletions(-) create mode 100644 docs/code-index/peri-turn-policy.md create mode 100644 docs/code-index/peri-wasi.md create mode 100644 peri-turn-policy/Cargo.toml create mode 100644 peri-turn-policy/src/compact.rs create mode 100644 peri-turn-policy/src/content.rs create mode 100644 peri-turn-policy/src/lib.rs create mode 100644 peri-turn-policy/src/lib_test.rs create mode 100644 peri-wasi/Cargo.toml create mode 100644 peri-wasi/src/lib.rs create mode 100644 peri-wasi/wit/world.wit create mode 100644 spec/issues/2026-08-28-wasi-p2-node-validation.md create mode 100644 wasi-e2e/acquire-cargo.mjs create mode 100644 wasi-e2e/exercise.mjs create mode 100644 wasi-e2e/harness.mjs create mode 100644 wasi-e2e/package-lock.json create mode 100644 wasi-e2e/package.json create mode 100644 wasi-e2e/wasi-p2.test.mjs diff --git a/.cargo/config.toml b/.cargo/config.toml index 2d4bca00..d9b78877 100644 --- a/.cargo/config.toml +++ b/.cargo/config.toml @@ -3,7 +3,5 @@ linker = "arm-linux-gnueabihf-gcc" ar = "arm-linux-gnueabihf-ar" # Point to the Linaro toolchain sysroot # CARGO_CFG_* are set via environment variables - -[build] # Disable cross-compile warnings as errors rustflags = ["-C", "target-feature=-crt-static"] diff --git a/Cargo.lock b/Cargo.lock index f6b71042..76ec759a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2790,6 +2790,12 @@ dependencies = [ "thiserror 2.0.20", ] +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + [[package]] name = "ident_case" version = "1.0.1" @@ -3169,6 +3175,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + [[package]] name = "libc" version = "0.2.189" @@ -3917,6 +3929,7 @@ dependencies = [ "chrono", "parking_lot", "peri-model", + "peri-turn-policy", "serde", "serde_json", "sha2 0.10.9", @@ -3944,6 +3957,7 @@ dependencies = [ "peri-acp-types", "peri-model", "peri-resources", + "peri-turn-policy", "serde", "serde_json", "sqlx", @@ -4188,6 +4202,18 @@ dependencies = [ "zeroize", ] +[[package]] +name = "peri-turn-policy" +version = "0.2.0" + +[[package]] +name = "peri-wasi" +version = "0.2.0" +dependencies = [ + "peri-turn-policy", + "wit-bindgen", +] + [[package]] name = "peri-web-pty" version = "0.2.0" @@ -4498,6 +4524,16 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -7220,6 +7256,28 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "wasm-encoder" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b6733b8b91d010a6ac5b0fb237dc46a19650bc4c67db66857e2e787d437204" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "665fe59e56cc9b419ca6fcca56673e3421d1a5011e3b65caf6b726fd9e041d10" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + [[package]] name = "wasm-streams" version = "0.5.0" @@ -7233,6 +7291,18 @@ dependencies = [ "web-sys", ] +[[package]] +name = "wasmparser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e6fb4c2bee46c5ea4d40f8cdb5c131725cd976718ec56f1c8e82fbde5fa2a80" +dependencies = [ + "bitflags 2.13.1", + "hashbrown 0.17.1", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "web-sys" version = "0.3.104" @@ -7764,6 +7834,89 @@ name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen-core" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02dee27a2dc20d1008016c742ec9fc6ea498492994ba3750be7454cbc97ff04c" +dependencies = [ + "anyhow", + "heck", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5007dae772945b7a5003d69d90a3a4a78929d41f19d004e980c4259a6af4484" +dependencies = [ + "anyhow", + "heck", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.119", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9237d678e3513ad24e96fe98beacdc0db6405284ba2a2400418cf0d42caa89" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d567162a6b9843080e5e0053f696623ff694bae8ae017c9ec536d1873bbe3d8" +dependencies = [ + "anyhow", + "bitflags 2.13.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.247.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ffe4064318cdf3c08cb99343b44c039fcefe61ccdf58aa9975285f13d74d1fc" +dependencies = [ + "anyhow", + "hashbrown 0.17.1", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] [[package]] name = "writeable" diff --git a/Cargo.toml b/Cargo.toml index 0713ef79..4ec907c5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,11 +9,13 @@ members = [ "peri-resources", "peri-runtime", "peri-tui", + "peri-turn-policy", "langfuse-client", "peri-lsp", "peri-js-runtime", "peri-web-pty", "peri-workflow", + "peri-wasi", ] resolver = "2" @@ -84,6 +86,7 @@ lru = "0.18" # --- Build --- tempfile = "3" +wit-bindgen = { version = "=0.57.1", default-features = false, features = ["macros"] } # --- Allocator --- tikv-jemallocator = "0.6" diff --git a/docs/code-index/peri-acp-types.md b/docs/code-index/peri-acp-types.md index 5cbda1f7..417fcbaa 100644 --- a/docs/code-index/peri-acp-types.md +++ b/docs/code-index/peri-acp-types.md @@ -1,12 +1,13 @@ # peri-acp-types 代码索引 -> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-08-16 +> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-08-29(MessageContent 判空共享 policy) > 依据:peri-acp-types/src/lib.rs、docs/standards/architecture-contracts.md、源码(本 crate 无 CLAUDE.md) ## 架构速览 - 定位:契约类型层(type contract layer between layers)——被 peri-agent、peri-acp、peri-middlewares、peri-runtime、peri-tui、peri-workflow、peri-lsp、peri-controller、peri-resources 共同依赖(各 Cargo.toml 均声明 `peri-acp-types`);只定义类型/枚举/trait,不含执行逻辑(cancel 判定函数除外,是纯函数) - 事实源矩阵(本层定义、他层 re-export 或消费):`identity`(AgentId/EventEnvelope/CancelRequest)、`event_v2`(三层事件 + `*_event_to_executor`)、`compact`(CompactConfig/CompactOutcome)、`tools`(BaseTool)、`session`(TurnId/MessageQueue/AgentRuntime)、`messages`(BaseMessage/MessageContent) +- portable seam:`MessageContent` 类型与投影仍属于本 crate;`is_empty` 的纯判定委托 dependency-free `peri-turn-policy`,供 native 与 `peri-wasi` 共用,不把 WIT 类型带入契约层 - 消费方式:peri-agent 大量 re-export(`src/agent/events_v2.rs:9`、`src/tools/mod.rs:8`、`src/agent/compact_v2/config.rs:8`、`src/session/turn.rs:17`、`src/messages/mod.rs:7`、`src/error.rs:6`);peri-acp 消费事件映射与 cancel(`src/event/mod.rs:31`、`src/host/prompt_handle.rs:20`);controller/runtime 直连 identity(`peri-controller/src/controller.rs:27`、`peri-runtime/src/runtime.rs:17`) - 稳定不变量:身份三元组 + epoch/attempt 不可复用(防迟到消息命中新实例);`SessionSeq` 单调且**不实现 Default**(缺失必须显式 `Option`);v2 事件强制携带 `turn_id` + `agent_id`;v1 `ExecutorEvent` 仅作协议序列化面载体,发射统一 v2(ARC-EVENT-001) @@ -18,7 +19,7 @@ | 改 BaseTool trait / is_direct 默认值 | `src/tools.rs`(trait 事实源;`peri-agent/src/tools/mod.rs:8` re-export;实现方在 peri-middlewares 各工具) | `BaseTool`(:146);`is_direct`(:199,默认 **false** = deferred);`context_retention`(:193,默认 `Preserve`);`timeout`(:170,默认 120s);`definition`(:152 组合 name/desc/params);`derive_title_from_name`(:70) | 默认值即行为契约:新工具不覆写 `is_direct` 即为 deferred(经 SearchExtraTools 发现);`context_retention` 默认 Preserve = 不被压缩;`ToolContext`(:129)只读借用 state,工具不可绕过 dispatch 统一写入 | | 改 CancelRequest 三元组 | `src/identity.rs`(`CancelRequest` 事实源 :262;`CancelPolicy` 事实源 `src/thread/types.rs:17`) | `CancelRequest::new(identity, policy)`(:273,clear_queue 默认 **false**);`with_clear_queue`(:282);`AttemptIdentity`(:140,四元组) | 定位四元组 (session_id, session_epoch, turn_id, attempt_id),**幂等判定取三元组** (session_id, turn_id, attempt_id);epoch 不可复用(`SessionEpoch::next` :70 只增);cancel ≠ 清除待办;消费方仅传递不解释语义:controller `cancel`(controller.rs:336)、runtime `cancel`(runtime.rs:169)、`RuntimePort::cancel`(`src/runtime.rs:85`)、prompt_handle(peri-acp:20 / peri-agent:23) | | 改 v2 事件枚举 / `*_event_to_executor` 映射 | `src/event_v2.rs`(三层事件唯一事实源):`RenderEvent` :87、`StateEvent` :204、`ObserveEvent` :278、`Event` :492、`EventBus` :526、`EventHandles` :617 | 发射:`emit_render`(:584,try_send 满时丢弃)、`emit_state`(:592)、`emit_observe`(:601,broadcast 慢消费者 lagging);映射:`render_event_to_executor` :666、`state_event_to_executor` :745、`observe_event_to_executor` :776 | 三层通道契约:render/state = 有界 mpsc critical,observe = 无界 broadcast;映射为**穷尽匹配**返回 `Option`,无 v1 等价物显式返回 None(如 HitlPending,走独立审批通道);TextChunk/ThinkingChunk 用消息级 `message_id`(:677),ToolStart/ToolEnd 用 turn_id 派生(:698/:712);禁止 wildcard 兜底 | -| 改 MessageContent 判空(is_empty) | `src/messages/content.rs`(`MessageContent` :330;`peri-agent/src/messages/mod.rs:7` re-export) | `is_empty`(:399);`text_content`(:356);`content_blocks`(:378);`has_tool_use`(:408);`strip_system_reminders`(:469) | 判空按变体:`Text(s) => s.is_empty()`(**不 trim**——纯空白字符串不算空)、`Blocks/Raw` 判 vec 空;消费方(如 peri-agent `is_keepgoing`)须用本函数判空,禁止 trim 替代 | +| 改 MessageContent 判空(is_empty) | `src/messages/content.rs`(`MessageContent` 事实源;`peri-agent/src/messages/mod.rs` re-export)+ `peri-turn-policy/src/content.rs`(纯判定) | `MessageContent::is_empty` 投影为 `MessageContentShape` 后调用 `is_message_content_empty`;同文件 `text_content`、`content_blocks`、`has_tool_use`、`strip_system_reminders` | 类型投影按变体传递 text 借用或 blocks/raw 长度;共享判定仍是 `Text(s) => s.is_empty()`(**不 trim**——纯空白字符串不算空)、`Blocks/Raw` 长度为 0。消费方(如 peri-agent `is_keepgoing`)须用本函数,禁止 trim 或在 WASI adapter 复制策略 | | 改 AgentId/TurnId 身份类型 | `src/identity.rs` + `src/session.rs` | `AgentId`(identity.rs:18,UUID v7:`new` :22、`from_uuid` :27、`TryFrom` :42);`TurnId`(session.rs:58,`new` :61、`as_uuid` :65);`SessionEpoch`(:60,initial=1);`AttemptId`(:90);`SessionSeq`(:173);`EventEnvelope`(:215) | 全部基于 uuid v7(时间有序);身份构造必须经显式构造器(`SessionSeq` 不实现 `Default`,缺失用 `Option`);`EventEnvelope` 身份字段(turn_id/agent_id)由事件源填充、session_id 由 Runtime 聚合补打(:216 注释),mapper 不得临时补齐 | | 加 v2 事件变体(全链路) | `src/event_v2.rs`(定义 + 映射)→ `peri-agent`(EventBus emit)→ `peri-acp/src/event/`(forwarder :106/118/142 消费)→ TUI | 枚举变体 + 对应 `*_event_to_executor` 分支 + `turn_id()`/`agent_id()` 提取 impl(如 StateEvent :251、ObserveEvent :446) | 新变体必须:强制携带 turn_id+agent_id、显式映射结果或过滤理由(穷尽匹配编译期强制)、覆盖 ACP 转发与客户端消费;终止事件必须使客户端离开 loading(ARC-EVENT-001) | | 改 cancel 判定 / AgentRuntime 注册表 | `src/session.rs` | `AgentRuntime`(:552,thread_id + cancel_token + policy + status);`cancel_cascade_agents`(:573,仅 Cascade 取消);`cancel_all_agents`(:582,全部取消);`cancel_cascade_in`/`cancel_all_in`(:589/:598,HashMap 注册表版) | 判定是纯函数、无层依赖;Independent(bg)子 agent 不随父取消,仅随 session 根取消;`AgentStatus` 事实源 thread/types.rs:56 | diff --git a/docs/code-index/peri-agent.md b/docs/code-index/peri-agent.md index 412391cc..6fdd3295 100644 --- a/docs/code-index/peri-agent.md +++ b/docs/code-index/peri-agent.md @@ -1,20 +1,20 @@ # peri-agent 代码索引 -> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-08-25(完整 frozen snapshot 贯穿 production stage) +> 速查表:把「我想做什么」映射到文件。细节以代码为准。更新:2026-08-29(compact selector 共享 policy) > 依据:peri-agent/CLAUDE.md、docs/standards/architecture-contracts.md、源码 ## 架构速览 - 数据流:`MessageQueue → Receive → Compact → Reason → Act → MessageQueue` - 循环入口:`src/agent/stages/mod.rs:612` 的 `run_react_loop(StageContext, max_iterations) -> LoopResult`;Receive 是唯一退出口 + keepgoing 判定点 -- 稳定不变量:`FrozenContext` 会话内不可漂移(ARC-FROZEN-001);`BaseTool::is_direct()` 是工具可见性事实源(ARC-TOOLS-001);`CompactConfig` 是 compact 阈值唯一事实源;中间件链序蓝本 `production_blueprint`(ARC-MIDDLEWARE-001) +- 稳定不变量:`FrozenContext` 会话内不可漂移(ARC-FROZEN-001);`BaseTool::is_direct()` 是工具可见性事实源(ARC-TOOLS-001);`CompactConfig` 是 compact 阈值唯一事实源,纯 Skip/Micro/Smart selector 委托 `peri-turn-policy`;中间件链序蓝本 `production_blueprint`(ARC-MIDDLEWARE-001) ## 速查表 | 我想做什么 | 主文件 | 入口/关键函数 | 关键逻辑 | | --- | --- | --- | --- | | 改 compact 触发阈值 | `peri-acp-types/src/compact.rs`(`CompactConfig` 事实源,`apply_env_overrides` 实现在 :325;`peri-agent/src/agent/compact_v2/config.rs` 仅 re-export;`peri-acp/src/host/compact_config.rs` 是配置加载调用方) | `CompactConfig` 字段:`auto_compact_threshold`(默认 0.95)、`micro_compact_threshold`(默认 0.75)、`smart_compact_enabled`(废弃恒 false) | budget < 0.75 跳过;≥ 0.75 走 Micro;Micro 收益不足且 budget ≥ auto_compact_threshold 时升级 Full;force=true 直接 Full。注意:调低 Full 阈值时 micro_compact_threshold 必须更低,否则先走 Skip | -| 改 compact 策略选择 | `src/agent/compact_v2/mod.rs` + `src/agent/stages/compact.rs` | `determine_compact_action(budget, config)`(mod.rs:102,Skip/Micro/Smart 选择);`run_compact`(mod.rs:125 编排,Full 升级判定在 :233);阶段入口 `stages/compact.rs::run_compact` | Full 升级判定以代码为准:`budget >= config.auto_compact_threshold` + `llm.is_none()` 守卫 + cache-aware 跳过;`planner.rs::CompactPolicy::force_full_threshold` 无消费点(遗留) | +| 改 compact 策略选择 | `src/agent/compact_v2/mod.rs` + `peri-turn-policy/src/compact.rs` + `src/agent/stages/compact.rs` | native 入口 `determine_compact_action(budget, config)` 委托 `select_compact_action`;`CompactAction` 从 shared crate re-export;`run_compact` 负责编排和 Full 升级;阶段入口 `stages/compact.rs::run_compact` | shared selector 保留原生未校验的浮点比较与 Smart 开关语义;`CompactConfig` 仍是阈值事实源。Full 升级判定仍在 Agent:`budget >= config.auto_compact_threshold` + `llm.is_none()` 守卫 + cache-aware 跳过;WASI 的 `[0,1]` 校验和 Smart 禁用不属于 native 路径 | | 改 Micro/Full 执行细节 | `src/agent/compact_v2/micro.rs` / `full.rs` | `micro_compact`;`re_inject_v2`、`extract_file_info`、`extract_skill_names` | Micro 按 round 截断(`micro_excluded_tools` 黑名单);Full 走 `peri_model::Model` 摘要 + re-inject | | 改循环退出 / keepgoing 判定 | `src/session/exec/executor.rs` + `src/agent/stages/mod.rs`(Receive 分支) | `executor.rs:130 is_keepgoing(&MessageContent)`;`run_session_loop`(executor.rs:221);`run_react_loop` 退出判断(stages/mod.rs:647 `consumed_count == 0 && !has_tool_calls`);判空底层 `peri-acp-types/src/messages/content.rs::is_empty`(:399) | 空白 prompt 须用 `MessageContent::is_empty()` 判空(禁止 trim 替代);空历史 + 空白 prompt 时短路 `push_done`;keepgoing 不注入 recall;契约 ARC-KEEPGOING-001 | | 改 turn fatal failure 分类/传递 | `src/session/exec/executor_helpers/v2_execute.rs` + `executor_helpers.rs` + `executor_helpers/collect.rs`;契约 DTO 在 `peri-acp-types/src/session.rs` | `classify_loop_terminal`;`ExecOutcome.failure` → `PromptResult.failure` | transcript flush 后只采样一次 cancel;单一纯分类器同时决定 Prompt stop reason、`TurnEnded`、fatal failure 与 cascade。Completed 为已提交成功;其他非成功结果中 cancel 优先;契约 ARC-EVENT-001 / ARC-CANCEL-001 | @@ -43,7 +43,7 @@ | 功能 | 文件 | 入口/关键点 | | --- | --- | --- | -| 策略选择 + 触发编排 | compact_v2/mod.rs | `determine_compact_action`(:102);`run_compact`(:125);`CompactResult` | +| 策略选择 + 触发编排 | compact_v2/mod.rs + `peri-turn-policy/src/compact.rs` | `determine_compact_action`(保留 native 入口并委托 shared selector);`CompactAction` re-export;`run_compact`;`CompactResult` | | 压力计算与计划 | compact_v2/planner.rs | `plan_micro`、`ContextPressure`、`CompactPolicy`(force_full_threshold 无消费点) | | Micro 执行(按 round 截断) | compact_v2/micro.rs | `micro_compact` | | Smart 执行(废弃中,恒 false) | compact_v2/smart.rs | `smart_compact` | @@ -84,3 +84,4 @@ - ARC-TOOLS-001:`is_direct()` 自声明可见性 - ARC-KEEPGOING-001:空白 prompt = 继续跑 loop - ARC-MIDDLEWARE-001:中间件链序是行为契约,链序蓝本 `production_blueprint` +- WASI policy seam:native compact 的函数路径、Smart 与未校验浮点比较语义保持不变;`peri-wasi` 只消费 shared selector 的受约束 Skip/Micro 子集,不能反向定义 Agent 行为 diff --git a/docs/code-index/peri-turn-policy.md b/docs/code-index/peri-turn-policy.md new file mode 100644 index 00000000..a53f58ba --- /dev/null +++ b/docs/code-index/peri-turn-policy.md @@ -0,0 +1,28 @@ +# peri-turn-policy 代码索引 + +> 速查表:把「我想做什么」映射到文件。细节以代码和契约测试为准。更新:2026-08-29 +> 依据:`docs/design/wasi.md`、`spec/issues/2026-08-28-wasi-p2-node-validation.md`、源码(本 crate 无 CLAUDE.md) + +## 架构速览 + +- 定位:native Peri 与 WASI Component 共用的 deterministic turn-policy kernel;`#![no_std]`、无第三方依赖、`publish = false`。 +- 数据流:native `MessageContent::is_empty` / `determine_compact_action` 与 `peri-wasi` WIT adapter → 本 crate 纯函数 → 调用方映射回各自类型。 +- 稳定不变量:text 判空不 trim;compact selector 不校验、不 clamp 浮点输入,保留 native Rust 比较语义和 Smart 分支;WIT 边界的输入校验、错误类型与 Smart 收窄不属于本 crate。 +- 非目标:不包含 Agent loop、ACP 类型、runtime、I/O、配置默认值或 capability;不是独立产品 API。 + +## 速查表 + +| 我想做什么 | 主文件 | 入口/关键函数 | 关键逻辑 | +| --- | --- | --- | --- | +| 改消息 content 判空策略 | `src/content.rs` | `MessageContentShape`;`is_message_content_empty` | `Text(text)` 仅以 `str::is_empty` 判空,纯空白不是空;`Blocks(len)` / `Raw(len)` 只看长度是否为 0。native 投影在 `peri-acp-types/src/messages/content.rs`,WIT 投影在 `peri-wasi/src/lib.rs` | +| 改 compact action 选择 | `src/compact.rs` | `CompactAction`;`select_compact_action(budget, micro_threshold, smart_enabled)` | `budget >= micro_threshold` 时按 Smart 开关返回 Smart/Micro,否则 Skip;函数刻意不做 finite/range 校验,以保持 native NaN/±Infinity 比较语义。WIT adapter 在调用前执行自己的 `[0,1]` 验证 | +| 改共享 kernel 的回归矩阵 | `src/lib_test.rs` | content shape tests;threshold/native non-finite tests | 覆盖空 text、空白 text、blocks/raw 长度、阈值下方/相等、Smart 和 NaN/±Infinity;当前共 8 项单测 | +| 改公共导出面 | `src/lib.rs` | `pub use compact::{...}`;`pub use content::{...}` | crate root 只 re-export 两组纯类型/函数,并保持 `#![no_std]`;新增依赖或 I/O 会扩大 WASI closure,必须先核对 probe 边界 | +| 改 crate 注册或消费方 | `Cargo.toml` + workspace 根 `Cargo.toml`;消费方 `peri-acp-types/Cargo.toml`、`peri-agent/Cargo.toml`、`peri-wasi/Cargo.toml` | workspace member/path dependency | crate 不发布;三类消费方必须共享同一实现,禁止在 WIT adapter 复制一份 policy | + +## 跨模块契约 + +- Content:`peri-acp-types::MessageContent::is_empty` 负责把完整消息类型投影成 `MessageContentShape`;本 crate 不依赖 ACP 类型。`peri-agent` 的 keepgoing 继续通过 `MessageContent::is_empty` 消费该语义(ARC-KEEPGOING-001)。 +- Compact:`peri-agent::agent::compact_v2::determine_compact_action` 保留原函数路径与 `CompactAction` 可见路径,只把纯选择委托给本 crate;`CompactConfig` 仍是 `peri-acp-types` 的配置事实源。 +- WASI:`peri-wasi` 负责把 WIT DTO 映射到本 crate,并在 Component boundary 验证有限值/范围、禁用 Smart;kernel 不感知 WIT 或 Node。 +- Capability:最终 path closure 应为 `peri-wasi → peri-turn-policy`;任何 native runtime、网络、存储、进程或文件系统依赖都违反本探针边界。 diff --git a/docs/code-index/peri-wasi.md b/docs/code-index/peri-wasi.md new file mode 100644 index 00000000..eca835e5 --- /dev/null +++ b/docs/code-index/peri-wasi.md @@ -0,0 +1,41 @@ +# peri-wasi 代码索引 + +> 速查表:把「我想做什么」映射到文件。细节以代码、产物 WIT 与 Node gate 为准。更新:2026-08-29 +> 依据:`docs/design/wasi.md`、`spec/issues/2026-08-28-wasi-p2-node-validation.md`、源码(本 crate 无 CLAUDE.md) + +## 架构速览 + +- 定位:内部、`publish = false` 的 WASI Preview 2 `cdylib` adapter,把 `peri-turn-policy` 的确定性策略暴露为 Component Model export。 +- 数据流:WIT caller → wit-bindgen guest bindings → `Component` adapter → `peri-turn-policy` → WIT result/variant;业务 exports 之外没有 imports。 +- 构建:Rust 1.96.1 `wasm32-wasip2` 由 `wasm-component-ld` 直接产出 `target/wasm32-wasip2/release/peri_wasi.wasm`,不使用 `cargo-component`。 +- 稳定边界:WIT package `peri:turn-policy@0.1.0`、world `turn-policy`、sole business export `policy`;它不是 Agent loop、ACP protocol 或 Headless ACP Host port。 + +## 速查表 + +| 我想做什么 | 主文件 | 入口/关键函数 | 关键逻辑 | +| --- | --- | --- | --- | +| 改 Component WIT 契约 | `wit/world.wit` | package `peri:turn-policy@0.1.0`;interface `policy`;world `turn-policy` | `classify-content` 导出 content 判空;`select-compact` 导出 `result`。当前 world 只 export `policy` 且没有 imports;改名或增 capability 必须同步 adapter、Jco gate 与文档 | +| 改 WIT/Rust adapter | `src/lib.rs` | `wit_bindgen::generate!`;`impl Guest for Component`;`export!(Component)` | 把 WIT `content-shape` 映射到共享 shape;把共享 Skip/Micro 映射回 WIT。Smart 在边界恒禁用,匹配分支仅作不可达防护 | +| 改 compact 输入错误契约 | `src/lib.rs::select_compact` + `wit/world.wit::policy-error` | `BudgetNotFinite`、`BudgetOutOfRange`、`MicroThresholdNotFinite`、`MicroThresholdOutOfRange` | 验证顺序固定:budget 非有限 → budget 不在 `[0,1]` → threshold 非有限 → threshold 不在 `[0,1]`;通过后才调用共享 selector | +| 改构建闭包/绑定版本 | `Cargo.toml` + workspace 根 `Cargo.toml` / `Cargo.lock` | `crate-type = ["cdylib"]`;path dependency `peri-turn-policy`;`wit-bindgen = 0.57.1` | local path closure 只能是 `peri-wasi → peri-turn-policy`;当前 `Cargo.lock` 的 `wit-parser` 为 0.247.0。禁止引入 Agent/ACP/model/middleware 或 native capability closure | +| 改 Node/Jco 验证 | `wasi-e2e/harness.mjs`、`wasi-e2e/exercise.mjs`、`wasi-e2e/wasi-p2.test.mjs` | `buildComponent`;`assertComponentInterface`;isolated child 的 28 项断言 | gate 验证 toolchain/Cargo tree、Component header、WIT sole export/empty imports、Jco 转译 shape 与实际执行;负例拒绝 missing/empty/core/corrupt artifact、Jco failure、metadata 篡改和 vendor symlink | +| 准备隔离 Cargo 供应链 | `wasi-e2e/acquire-cargo.mjs` + `wasi-e2e/package.json` | `npm --prefix wasi-e2e run acquire:cargo` | 比较 standalone 与 root registry closure,从已有 parent cache vendor 当前 34 个 registry packages,在 `wasi-e2e/target` 生成 credential-free Cargo home;执行 gate 不回退到 parent Cargo home | +| 运行跨运行时门禁 | `wasi-e2e/package.json` | `npm --prefix wasi-e2e test` | 验证基线 Node 22.20.0/npm 10.9.3、Jco 1.32.1、Preview 2 shim 0.22.0;当前 TAP 8/8,isolated child 28 assertions;依赖 acquisition 必须先单独运行 | + +## 产物契约 + +| 项目 | 事实 | +| --- | --- | +| release artifact | `target/wasm32-wasip2/release/peri_wasi.wasm` | +| Component header | `00 61 73 6d 0d 00 01 00`;不得退化为 core Wasm `01 00 00 00` | +| WIT export | `peri:turn-policy/policy@0.1.0` | +| imports | 空 | +| generated Node output | `wasi-e2e/target/wasi-p2-node/`,ignored | +| acquisition/vendor/Cargo home | `wasi-e2e/target/` 下固定 owned 目录,ignored | + +## 跨模块契约 + +- Shared policy:adapter 必须调用 `peri-turn-policy`,不得复制 native policy;native wiring 见 `docs/code-index/peri-turn-policy.md`。 +- Content:WIT `classify-content` 只投影 `MessageContent::is_empty`,不等于 `peri-agent` continuation-aware keepgoing(ARC-KEEPGOING-001)。 +- Compact:WIT 只承诺 Skip/Micro 和显式输入错误;native Smart 与未校验浮点比较仍留在共享 kernel/native caller,不扩展为 WIT ABI。 +- Host boundary:完整 ACP Host 仍依赖 HTTP、SQLite、filesystem、process、MCP/LSP/PTC 等 native 能力;本 crate 的成功不能作为 Agent/ACP 已可移植的证据,完整边界见 `docs/design/wasi.md`。 diff --git a/docs/design/wasi.md b/docs/design/wasi.md index 14c2e613..250f63d7 100644 --- a/docs/design/wasi.md +++ b/docs/design/wasi.md @@ -1,31 +1,35 @@ -# WASI Preview 2 静态可行性探查 +# WASI Preview 2 能力边界与 policy Component 探针 -> 状态:Exploratory / No implementation planned +> 状态:窄切片已验证;完整 Headless ACP Host 仍为 Exploratory > -> 探查日期:2026-08-27 +> 静态探查日期:2026-08-27;探针验证日期:2026-08-29 > -> 目标基线:`wasm32-wasip2`、Headless ACP 服务 +> 目标基线:Rust 1.96.1 `wasm32-wasip2`、Node.js 22.20.0、Headless ACP 服务 > -> 证据范围:仓库源码、Cargo manifests 与 lockfile 的静态核查;本次未安装 target、未运行 `cargo check --target wasm32-wasip2`,因此本文不声称记录真实 compiler 首错。 +> 证据范围:完整 Host 结论来自仓库源码、Cargo manifests 与 lockfile 的静态核查;实际构建和 Node 运行证据仅覆盖内部 deterministic turn-policy Component probe。 ## 1. 目的与边界 -本文记录 Perihelion 面向 WASI Preview 2 的初步可行性,供未来讨论部署形态或依赖边界时参考。当前没有 WASI 开发计划,本文不是 active implementation spec、迁移承诺或架构决策记录。 +本文记录 Perihelion 面向 WASI Preview 2 的能力边界,并补充一个已实际构建和跨运行时执行的窄探针。完整 ACP Host 的分析仍用于未来讨论部署形态或依赖边界;探针的 active implementation 事实与验收进度见 `spec/issues/2026-08-28-wasi-p2-node-validation.md`。 探查目标是:在不包含 TUI 的前提下,评估 ACP session/event、Agent loop 与相关运行能力构建为 `wasm32-wasip2` Component 时可能遇到的问题。 +本次实际实现仅把 native 已使用的两项确定性策略抽到 dependency-free `peri-turn-policy`:`MessageContent` 判空和 compact action 选择;`peri-wasi` 再通过 WIT 暴露其受约束子集。它是内部、`publish = false` 的 Component probe,不是 Agent loop、ACP protocol 或 Headless ACP Host 的 WASI port,也不改变下文对完整 native 闭包的判断。 + 本次不评估: - 将 `peri-tui` 或终端 UI 移植到 WASI; - `wasm32-wasip1` 的兼容性; - 浏览器 target; - 通过特定 WASI runtime 私有扩展复刻完整 Unix 环境; -- 实际性能、产物体积或 runtime 兼容性; -- 具体 WIT schema、版本计划或任务拆分。 +- 完整 Agent/ACP 的实际性能、产物体积或 runtime 兼容性; +- 完整 Agent/ACP 的 WIT schema、版本计划或任务拆分。 ## 2. 摘要结论 -当前 Headless ACP 服务不能被视为可直接、完整地构建为标准 `wasm32-wasip2` 产物。主要原因不是单个 Rust API,而是 ACP Host 当前同时装配了 HTTP、SQLite、文件系统、子进程、插件、MCP、LSP、PTC、OAuth callback、系统指标和文件日志等 native host 能力。 +当前 Headless ACP 服务仍不能被视为可直接、完整地构建为标准 `wasm32-wasip2` 产物。主要原因不是单个 Rust API,而是 ACP Host 当前同时装配了 HTTP、SQLite、文件系统、子进程、插件、MCP、LSP、PTC、OAuth callback、系统指标和文件日志等 native host 能力。 + +已验证的结论更窄:Rust 1.96.1 能把不依赖这些 native capability 的 `peri-wasi` 直接链接为 WASI Preview 2 Component;Node.js 22.20.0 可经固定版本 Jco 和 Preview 2 shim 调用它的唯一业务 export。这个结果验证了工具链和纯策略 seam,不证明完整 Agent/ACP 构建闭包已经可移植。 较可行的远期形态是: @@ -39,7 +43,7 @@ WASI Agent/ACP Core Component 其中 ACP 数据模型、session/router、RCRA Agent loop、prompt/message projection、tool schema/dispatch、cancel/event mapping 大体属于可保留的逻辑核心;网络、持久化和进程型工具更适合作为 Component imports 或宿主服务,而不是继续由 Component 直接调用 native API。 -这不意味着现在应拆分或修改代码。当前结论仅表明:若未来立项,第一步应定义 WASI capability boundary,而不是先逐个替换 native 依赖并尝试原样编译完整 Host。 +这不意味着现在应继续拆分或修改完整 Host。当前结论仅表明:若未来立项,第一步应定义 WASI capability boundary,而不是先逐个替换 native 依赖并尝试原样编译完整 Host。 ## 3. 当前 Headless ACP 构建闭包 @@ -218,9 +222,62 @@ Component Model 更自然的形态是导出 session、prompt、event stream 和 不建议优先尝试把 SQLite、reqwest/TLS、MCP child process、LSP、Node/npm、plugin installer 和 OAuth callback 全部搬入 Component。这样容易依赖特定 runtime 的私有扩展,且削弱采用 Preview 2 capability model 的价值。 -## 8. 若未来重新开启探查 +## 8. 已验证的 deterministic policy Component probe + +### 8.1 共享策略与 WIT 边界 + +`peri-turn-policy` 是 `#![no_std]`、无第三方依赖、`publish = false` 的共享 kernel。native 调用路径仍保持原入口,但把两项纯决策委托给该 crate: + +- `peri-acp-types::MessageContent::is_empty` 将 `Text`、`Blocks`、`Raw` 投影为 `MessageContentShape`;纯空白 text 仍为非空。 +- `peri-agent::agent::compact_v2::determine_compact_action` 把 budget、micro threshold 与 native Smart 开关交给 `select_compact_action`;原有 `CompactAction` 路径通过 re-export 保持不变。 + +`peri-wasi` 是 `cdylib`、`publish = false` 的边界 adapter。WIT package 为 `peri:turn-policy@0.1.0`,world 为 `turn-policy`,唯一业务 export 是命名 interface `policy`: + +```wit +classify-content: func(content: content-shape) -> content-classification; +select-compact: func( + budget: f64, + micro-threshold: f64, +) -> result; +``` + +Component 没有 imports。`classify-content` 只表达 content 判空,不包含 continuation-aware keepgoing;`select-compact` 只导出 `skip`/`micro`,不把 native legacy Smart 固化为跨运行时契约。边界在调用共享 selector 前拒绝非有限值和 `[0,1]` 外输入,错误优先级依次为 budget 非有限、budget 越界、threshold 非有限、threshold 越界。 + +### 8.2 构建与产物 + +Rust 1.96.1 的 `wasm32-wasip2` target 通过 `wasm-component-ld` 直接生成 Component,不需要 `cargo-component`。固定依赖与实际 lockfile 解析结果为: + +| 项目 | 固定/解析版本 | +| --- | --- | +| Rust toolchain | 1.96.1 | +| target | `wasm32-wasip2` | +| `wit-bindgen` | 0.57.1 | +| `wit-parser`(`Cargo.lock`) | 0.247.0 | +| Node.js | 22.20.0 | +| npm | 10.9.3 | +| `@bytecodealliance/jco` | 1.32.1 | +| `@bytecodealliance/preview2-shim` | 0.22.0 | + +release artifact 为 `target/wasm32-wasip2/release/peri_wasi.wasm`。验证过的文件头为 `00 61 73 6d 0d 00 01 00`:Wasm magic 后是 Component encoding/version,而不是 core Wasm 的 `01 00 00 00`。产物 WIT 检查确认 sole business export 为 `peri:turn-policy/policy@0.1.0`,imports 为空;Cargo target closure 的本地路径只有 `peri-wasi → peri-turn-policy`,没有 Agent/ACP、Tokio、reqwest、SQLx 或 process 能力闭包。 + +### 8.3 可复现 Node gate 与隔离 + +Node gate 把 dependency acquisition 与执行分开: + +```bash +npm --prefix wasi-e2e run acquire:cargo +npm --prefix wasi-e2e test +``` + +`acquire:cargo` 只从已有 parent Cargo cache 获取源代码,在 `wasi-e2e/target` 下生成独立 acquisition workspace、versioned vendor tree 和 credential-free Cargo home;它先比较 standalone manifest 与根 `peri-wasi` 的 registry closure,再固定供应该闭包。当前验证输出为 `CARGO_VENDOR_REGISTRY_PACKAGES=34`。执行 gate 只使用这个隔离 Cargo home,启用 offline/frozen 构建,并校验 acquisition fingerprint、路径 containment、symlink、环境变量 allowlist、toolchain、Cargo tree、Component header、WIT/import 与 Jco 产物。 + +`npm --prefix wasi-e2e test` 当前包含 8 项 TAP 测试:缺失、空、core Wasm、损坏 Component、Jco 非零退出、acquisition fingerprint 篡改、vendor symlink,以及完整 build/inspect/transpile/execute 链路。隔离 Node child 对真实 WIT export 执行 28 个确定性断言,覆盖 content empty/non-empty、0/1/equality、NaN/±Infinity、两侧越界和多错误 precedence。已验证结果为 `tests 8 / pass 8 / fail 0 / skipped 0`。 + +`wasi-e2e/target`、Cargo `target`、`node_modules`、最终 wasm 和 Jco 转译文件都是 ignored 生成物,不进入版本控制。Node 自带的 `node:wasi` 是 Preview 1 路径;本探针使用 pinned Jco + Preview 2 shim,不把二者混同。 + +## 9. 若未来继续完整 Host 探查 -建议按以下验证顺序推进,但当前不执行: +窄探针已经完成工具链与纯策略 seam 的第一步。若未来继续完整 Agent/ACP 探查,建议按以下顺序推进: 1. 用最小 feature 对 `peri-acp-types`、`peri-model` 纯类型层和 `peri-agent` core 执行 `cargo check --target wasm32-wasip2`,记录真实首错。 2. 构造无外部工具、mock model、in-memory ThreadStore 的最小 Agent session,验证 loop、event 和 cancel。 @@ -231,9 +288,10 @@ Component Model 更自然的形态是导出 session、prompt、event stream 和 高概率首先暴露问题的依赖类别包括 Tokio native backend、`mio`/`socket2`、`sysinfo`、`sqlx`/SQLite binding、reqwest/TLS、`peri-js-runtime`、RMCP child-process transport 和 file appender。该顺序只是静态推断,必须由真实 target build 修正。 -## 9. 当前决定 +## 10. 当前决定 -- 当前不开发 WASI 版本。 -- 当前不添加 target、CI job、feature flag、WIT 文件或 runtime 依赖。 +- 保留内部 deterministic turn-policy Component probe 与 Node 验证门禁;不把它发布为稳定产品接口。 +- 不宣称完整 Headless ACP Host、Agent loop 或 ACP protocol 已完成 WASI port。 +- 当前不为完整 Host 添加 CI job、feature flag 或 runtime 私有扩展。 - 当前不为了推测性兼容而修改 production middleware 顺序或工具行为。 -- 本文只保留静态探查结论;未来若立项,应新建 active spec/issue,并以实际构建日志和选定 runtime 的契约替代本文中的推断。 +- 完整 Host 未来若立项,应新建独立 active spec/issue,并以该闭包的实际构建日志和选定 runtime 契约替代本文中的静态推断;本次 probe 的通过不能作为该结论的替代证据。 diff --git a/peri-acp-types/Cargo.toml b/peri-acp-types/Cargo.toml index 78b01971..19180b77 100644 --- a/peri-acp-types/Cargo.toml +++ b/peri-acp-types/Cargo.toml @@ -10,6 +10,7 @@ async-trait.workspace = true chrono.workspace = true parking_lot.workspace = true peri-model = { path = "../peri-model" } +peri-turn-policy = { path = "../peri-turn-policy" } serde.workspace = true serde_json.workspace = true sha2.workspace = true diff --git a/peri-acp-types/src/messages/content.rs b/peri-acp-types/src/messages/content.rs index 5345d9fa..e7765ee6 100644 --- a/peri-acp-types/src/messages/content.rs +++ b/peri-acp-types/src/messages/content.rs @@ -1,3 +1,4 @@ +use peri_turn_policy::{is_message_content_empty, MessageContentShape}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; // ─── ImageSource ────────────────────────────────────────────────────────────── @@ -397,11 +398,12 @@ impl MessageContent { /// 是否为空内容 pub fn is_empty(&self) -> bool { - match self { - Self::Text(s) => s.is_empty(), - Self::Blocks(b) => b.is_empty(), - Self::Raw(v) => v.is_empty(), - } + let shape = match self { + Self::Text(text) => MessageContentShape::Text(text), + Self::Blocks(blocks) => MessageContentShape::Blocks(blocks.len()), + Self::Raw(values) => MessageContentShape::Raw(values.len()), + }; + is_message_content_empty(shape) } /// 是否包含工具调用 block diff --git a/peri-acp-types/src/messages/content_test.rs b/peri-acp-types/src/messages/content_test.rs index add85f23..1563b3ff 100644 --- a/peri-acp-types/src/messages/content_test.rs +++ b/peri-acp-types/src/messages/content_test.rs @@ -70,6 +70,7 @@ fn test_tool_use_blocks_consistency_with_has_tool_use() { fn test_is_empty_variants() { assert!(MessageContent::text("").is_empty()); assert!(!MessageContent::text("x").is_empty()); + assert!(!MessageContent::text(" \n").is_empty()); assert!(MessageContent::Blocks(vec![]).is_empty()); assert!(!MessageContent::Blocks(vec![ContentBlock::text("x")]).is_empty()); assert!(MessageContent::Raw(vec![]).is_empty()); diff --git a/peri-agent/Cargo.toml b/peri-agent/Cargo.toml index 62b0ba3c..e0088946 100644 --- a/peri-agent/Cargo.toml +++ b/peri-agent/Cargo.toml @@ -25,6 +25,7 @@ parking_lot.workspace = true peri-model = { path = "../peri-model" } peri-acp-types = { path = "../peri-acp-types" } peri-resources = { path = "../peri-resources" } +peri-turn-policy = { path = "../peri-turn-policy" } sysinfo.workspace = true fuzzy-matcher = "0.3" diff --git a/peri-agent/src/agent/compact_v2/mod.rs b/peri-agent/src/agent/compact_v2/mod.rs index 76f1e462..e475ddd8 100644 --- a/peri-agent/src/agent/compact_v2/mod.rs +++ b/peri-agent/src/agent/compact_v2/mod.rs @@ -31,6 +31,7 @@ pub mod smart; pub use config::{CompactConfig, CONTINUATION_HINT}; pub use full::{extract_file_info, extract_skill_names, re_inject_v2, ReInjectResult}; pub use micro::micro_compact; +pub use peri_turn_policy::CompactAction; pub use planner::{plan_micro, ApplyReport, CompactPolicy, ContextPressure, FullEscalationReason}; pub use projection::{ plan_from_persisted_directives, render_llm_view, MessageProjectionDirective, MicroCompactPlan, @@ -80,17 +81,6 @@ impl CompactResult { // ─── 顶层入口 ─────────────────────────────────────────────────────────────────── -/// Compact 阶段动作 -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum CompactAction { - /// 跳过 compact(预算充足) - Skip, - /// 执行 Micro Compact - Micro, - /// 执行 Smart Compact(规则驱动,保留关键消息) - Smart, -} - /// 根据 budget 和配置决定 Compact 动作。 /// /// 返回 `Skip` 表示预算未到 75%,跳过 compact。 @@ -100,15 +90,11 @@ pub enum CompactAction { /// Full Compact 的触发不在本函数内判定——由 run_compact 在执行后 /// 根据 affected_count 和 budget 动态决策。 pub fn determine_compact_action(budget: f64, config: &CompactConfig) -> CompactAction { - if budget >= config.micro_compact_threshold { - if config.smart_compact_enabled { - CompactAction::Smart - } else { - CompactAction::Micro - } - } else { - CompactAction::Skip - } + peri_turn_policy::select_compact_action( + budget, + config.micro_compact_threshold, + config.smart_compact_enabled, + ) } /// 根据 ContextPressure 选择策略并执行 Compact diff --git a/peri-agent/src/agent/compact_v2/trigger_test.rs b/peri-agent/src/agent/compact_v2/trigger_test.rs index 4c588463..70f70b5f 100644 --- a/peri-agent/src/agent/compact_v2/trigger_test.rs +++ b/peri-agent/src/agent/compact_v2/trigger_test.rs @@ -105,6 +105,16 @@ fn test_determine_compact_action_above_threshold() { ); } +#[test] +fn test_determine_compact_action_at_threshold() { + let config = CompactConfig::default(); + assert_eq!( + determine_compact_action(config.micro_compact_threshold, &config), + CompactAction::Micro, + "等于阈值时必须执行 Micro Compact" + ); +} + #[test] fn test_determine_compact_action_smart_enabled() { let config = CompactConfig { @@ -117,6 +127,79 @@ fn test_determine_compact_action_smart_enabled() { ); } +#[test] +fn test_determine_compact_action_preserves_non_finite_comparisons() { + let config = CompactConfig::default(); + assert_eq!( + determine_compact_action(f64::NAN, &config), + CompactAction::Skip, + "NaN 与阈值比较为 false,应保持 Skip" + ); + assert_eq!( + determine_compact_action(f64::INFINITY, &config), + CompactAction::Micro, + "正无穷大应保持 Micro" + ); + assert_eq!( + determine_compact_action(f64::NEG_INFINITY, &config), + CompactAction::Skip, + "负无穷大应保持 Skip" + ); + + let nan_threshold = CompactConfig { + micro_compact_threshold: f64::NAN, + ..Default::default() + }; + assert_eq!( + determine_compact_action(0.75, &nan_threshold), + CompactAction::Skip, + "NaN 阈值使大于等于比较为 false,应保持 Skip" + ); + assert_eq!( + determine_compact_action( + 0.75, + &CompactConfig { + smart_compact_enabled: true, + ..nan_threshold.clone() + } + ), + CompactAction::Skip, + "NaN 阈值使大于等于比较为 false,开启 Smart 也应保持 Skip" + ); + + let positive_infinity_threshold = CompactConfig { + micro_compact_threshold: f64::INFINITY, + smart_compact_enabled: true, + ..Default::default() + }; + assert_eq!( + determine_compact_action(0.75, &positive_infinity_threshold), + CompactAction::Skip, + "有限 budget 小于正无穷阈值,应保持 Skip" + ); + + let negative_infinity_threshold = CompactConfig { + micro_compact_threshold: f64::NEG_INFINITY, + ..Default::default() + }; + assert_eq!( + determine_compact_action(0.75, &negative_infinity_threshold), + CompactAction::Micro, + "有限 budget 不小于负无穷阈值,应保持 Micro" + ); + assert_eq!( + determine_compact_action( + 0.75, + &CompactConfig { + smart_compact_enabled: true, + ..negative_infinity_threshold + } + ), + CompactAction::Smart, + "负无穷阈值且开启 Smart 时应保持 Smart" + ); +} + // ── run_compact 触发流程测试(无 LLM) ───────────────────────────────── #[tokio::test] diff --git a/peri-turn-policy/Cargo.toml b/peri-turn-policy/Cargo.toml new file mode 100644 index 00000000..b01e3eb7 --- /dev/null +++ b/peri-turn-policy/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "peri-turn-policy" +version.workspace = true +edition.workspace = true +description = "Portable deterministic turn policy shared by native Peri and WASI components" +publish = false + +[dependencies] diff --git a/peri-turn-policy/src/compact.rs b/peri-turn-policy/src/compact.rs new file mode 100644 index 00000000..d49a518a --- /dev/null +++ b/peri-turn-policy/src/compact.rs @@ -0,0 +1,24 @@ +/// The portable compact action selected for the current context budget. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompactAction { + Skip, + Micro, + Smart, +} + +/// Selects the native compact action without validating or clamping inputs. +pub fn select_compact_action( + budget: f64, + micro_threshold: f64, + smart_enabled: bool, +) -> CompactAction { + if budget >= micro_threshold { + if smart_enabled { + CompactAction::Smart + } else { + CompactAction::Micro + } + } else { + CompactAction::Skip + } +} diff --git a/peri-turn-policy/src/content.rs b/peri-turn-policy/src/content.rs new file mode 100644 index 00000000..40e2d501 --- /dev/null +++ b/peri-turn-policy/src/content.rs @@ -0,0 +1,15 @@ +/// The parts of message content needed by the portable emptiness policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageContentShape<'a> { + Text(&'a str), + Blocks(usize), + Raw(usize), +} + +/// Returns whether a message content shape has no content. +pub const fn is_message_content_empty(content: MessageContentShape<'_>) -> bool { + match content { + MessageContentShape::Text(text) => text.is_empty(), + MessageContentShape::Blocks(len) | MessageContentShape::Raw(len) => len == 0, + } +} diff --git a/peri-turn-policy/src/lib.rs b/peri-turn-policy/src/lib.rs new file mode 100644 index 00000000..efb029e9 --- /dev/null +++ b/peri-turn-policy/src/lib.rs @@ -0,0 +1,11 @@ +#![no_std] + +mod compact; +mod content; + +pub use compact::{select_compact_action, CompactAction}; +pub use content::{is_message_content_empty, MessageContentShape}; + +#[cfg(test)] +#[path = "lib_test.rs"] +mod tests; diff --git a/peri-turn-policy/src/lib_test.rs b/peri-turn-policy/src/lib_test.rs new file mode 100644 index 00000000..3672fc93 --- /dev/null +++ b/peri-turn-policy/src/lib_test.rs @@ -0,0 +1,88 @@ +use super::*; + +#[test] +fn test_message_content_empty_text() { + assert!(is_message_content_empty(MessageContentShape::Text(""))); +} + +#[test] +fn test_message_content_whitespace_text_is_not_empty() { + assert!(!is_message_content_empty(MessageContentShape::Text(" \n"))); +} + +#[test] +fn test_message_content_block_shapes() { + assert!(is_message_content_empty(MessageContentShape::Blocks(0))); + assert!(!is_message_content_empty(MessageContentShape::Blocks(1))); +} + +#[test] +fn test_message_content_raw_shapes() { + assert!(is_message_content_empty(MessageContentShape::Raw(0))); + assert!(!is_message_content_empty(MessageContentShape::Raw(1))); +} + +#[test] +fn test_select_compact_action_below_threshold() { + assert_eq!( + select_compact_action(0.74, 0.75, false), + CompactAction::Skip + ); +} + +#[test] +fn test_select_compact_action_at_threshold() { + assert_eq!( + select_compact_action(0.75, 0.75, false), + CompactAction::Micro + ); +} + +#[test] +fn test_select_compact_action_above_threshold_with_smart_enabled() { + assert_eq!( + select_compact_action(0.80, 0.75, true), + CompactAction::Smart + ); +} + +#[test] +fn test_select_compact_action_preserves_non_finite_comparison_behavior() { + assert_eq!( + select_compact_action(f64::NAN, 0.75, false), + CompactAction::Skip + ); + assert_eq!( + select_compact_action(f64::INFINITY, 0.75, false), + CompactAction::Micro + ); + assert_eq!( + select_compact_action(f64::NEG_INFINITY, 0.75, false), + CompactAction::Skip + ); + + assert_eq!( + select_compact_action(0.75, f64::NAN, false), + CompactAction::Skip + ); + assert_eq!( + select_compact_action(0.75, f64::NAN, true), + CompactAction::Skip + ); + assert_eq!( + select_compact_action(0.75, f64::INFINITY, false), + CompactAction::Skip + ); + assert_eq!( + select_compact_action(0.75, f64::INFINITY, true), + CompactAction::Skip + ); + assert_eq!( + select_compact_action(0.75, f64::NEG_INFINITY, false), + CompactAction::Micro + ); + assert_eq!( + select_compact_action(0.75, f64::NEG_INFINITY, true), + CompactAction::Smart + ); +} diff --git a/peri-wasi/Cargo.toml b/peri-wasi/Cargo.toml new file mode 100644 index 00000000..095f6954 --- /dev/null +++ b/peri-wasi/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "peri-wasi" +version.workspace = true +edition.workspace = true +description = "WASI Preview 2 component for portable Peri turn policy" +publish = false + +[lib] +crate-type = ["cdylib"] + +[dependencies] +peri-turn-policy = { path = "../peri-turn-policy" } +wit-bindgen.workspace = true diff --git a/peri-wasi/src/lib.rs b/peri-wasi/src/lib.rs new file mode 100644 index 00000000..018df024 --- /dev/null +++ b/peri-wasi/src/lib.rs @@ -0,0 +1,53 @@ +wit_bindgen::generate!({ + world: "turn-policy", + path: "wit", +}); + +use exports::peri::turn_policy::policy::{ + CompactAction, ContentClassification, ContentShape, Guest, PolicyError, +}; +use peri_turn_policy::{ + is_message_content_empty, select_compact_action, CompactAction as SharedCompactAction, + MessageContentShape, +}; + +struct Component; + +impl Guest for Component { + fn classify_content(content: ContentShape) -> ContentClassification { + let shape = match &content { + ContentShape::Text(text) => MessageContentShape::Text(text), + ContentShape::Blocks(len) => MessageContentShape::Blocks(*len as usize), + ContentShape::Raw(len) => MessageContentShape::Raw(*len as usize), + }; + + if is_message_content_empty(shape) { + ContentClassification::Empty + } else { + ContentClassification::NonEmpty + } + } + + fn select_compact(budget: f64, micro_threshold: f64) -> Result { + if !budget.is_finite() { + return Err(PolicyError::BudgetNotFinite); + } + if !(0.0..=1.0).contains(&budget) { + return Err(PolicyError::BudgetOutOfRange); + } + if !micro_threshold.is_finite() { + return Err(PolicyError::MicroThresholdNotFinite); + } + if !(0.0..=1.0).contains(µ_threshold) { + return Err(PolicyError::MicroThresholdOutOfRange); + } + + match select_compact_action(budget, micro_threshold, false) { + SharedCompactAction::Skip => Ok(CompactAction::Skip), + SharedCompactAction::Micro => Ok(CompactAction::Micro), + SharedCompactAction::Smart => unreachable!("Smart is disabled at the WIT boundary"), + } + } +} + +export!(Component); diff --git a/peri-wasi/wit/world.wit b/peri-wasi/wit/world.wit new file mode 100644 index 00000000..b61edc68 --- /dev/null +++ b/peri-wasi/wit/world.wit @@ -0,0 +1,36 @@ +package peri:turn-policy@0.1.0; + +interface policy { + variant content-shape { + text(string), + blocks(u32), + raw(u32), + } + + enum content-classification { + empty, + non-empty, + } + + enum compact-action { + skip, + micro, + } + + enum policy-error { + budget-not-finite, + budget-out-of-range, + micro-threshold-not-finite, + micro-threshold-out-of-range, + } + + classify-content: func(content: content-shape) -> content-classification; + select-compact: func( + budget: f64, + micro-threshold: f64, + ) -> result; +} + +world turn-policy { + export policy; +} diff --git a/spec/issues/2026-08-28-wasi-p2-node-validation.md b/spec/issues/2026-08-28-wasi-p2-node-validation.md new file mode 100644 index 00000000..8c7cd7e2 --- /dev/null +++ b/spec/issues/2026-08-28-wasi-p2-node-validation.md @@ -0,0 +1,96 @@ +# WASI Preview 2 policy Component 与 Node 验证 + +**状态**:Verified(2026-08-29) +**优先级**:高 +**类型**:构建 / 跨运行时兼容 +**创建日期**:2026-08-28 +**来源**:用户请求 + `docs/design/wasi.md` 可行性探查 + +## 目标 + +为仓库提供一个可由 Rust 1.96.1 `wasm32-wasip2` 直接链接的 Component Model 产物,并用 Node.js 22、固定版本 Jco 和 Preview 2 shim 对其真实 WIT export 执行确定性断言。 + +本切片只承载 native Peri 已使用的纯 turn policy:消息 content 判空与 compact 阈值选择。native 与 Component 必须共享同一个 dependency-free policy kernel,不能用孤立 hello-world/probe 复制逻辑冒充完成。 + +## 边界 + +### In scope + +- 内部、不发布的 `peri-turn-policy` portable kernel。 +- 现有 `MessageContent::is_empty` 和 `determine_compact_action` 保持原路径/语义的委托接线。 +- 内部、不发布的 `peri-wasi` WIT Component。 +- 独立且锁版本的 `wasi-e2e` Node/Jco 验证包。 +- Cargo 依赖闭包与最终 Component imports/exports 两层能力检查。 + +### Out of scope + +- 完整 ACP Host、Agent loop、TUI 的 WASI 移植。 +- HTTP、SQLite、进程、MCP、LSP、PTC、插件、任意文件系统或 secret capability。 +- ACP wire、中间件顺序、CompactConfig 默认值或 native Smart 行为变更。 +- `cargo-component` 或 runtime 私有 WASI 扩展。 + +## 固定 WIT 契约 + +package 为 `peri:turn-policy@0.1.0`,world 为 `turn-policy`,导出命名 interface `policy`: + +- `classify-content(content: content-shape) -> content-classification` +- `select-compact(budget: f64, micro-threshold: f64) -> result` + +content classification 只表达 `MessageContent::is_empty`:空 text/blocks/raw 为 `empty`,whitespace text 与非空 blocks/raw 为 `non-empty`;它不声称包含 continuation-aware keepgoing 判定。 + +Component 仅导出 `skip`/`micro`,不把 legacy Smart 固化为新跨运行时契约。浮点验证顺序固定为 budget 非有限、budget 越界、threshold 非有限、threshold 越界;范围为闭区间 `[0,1]`。`wit-bindgen` 固定为 0.57.1,当前 `Cargo.lock` 实际解析到 `wit-parser 0.247.0`,WIT 使用 `f64` 语法。 + +## 构建与运行约束 + +- Rust toolchain 固定为 1.96.1,目标为 `wasm32-wasip2`,release artifact 为 `target/wasm32-wasip2/release/peri_wasi.wasm`。 +- Component repository path closure 只能是 `peri-wasi -> peri-turn-policy`;禁止 Agent/ACP/model/middleware、Tokio、reqwest、SQLx、sysinfo 等 native 闭包。 +- 最终 Component WIT/import 必须由 pinned 工具抽取并使用明确 allowlist;拒绝不需要的 HTTP、socket、filesystem、CLI environment/arguments 能力。 +- Node 验证基线为 Node.js 22.20.0;依赖固定 `@bytecodealliance/jco@1.32.1`、`@bytecodealliance/preview2-shim@0.22.0`,npm 为 10.9.3。 +- dependency acquisition 与 execution gate 分离:先运行 `npm --prefix wasi-e2e run acquire:cargo`,从已有 parent cache 生成并核对独立 closure、versioned vendor tree 与 credential-free Cargo home;再运行 `npm --prefix wasi-e2e test`,以 isolated Cargo home 执行 frozen/offline build → artifact capability check → Jco transpile → isolated Node child。 +- acquisition 和 gate 只接受固定 owned、canonical、无 symlink 的路径;子进程环境使用 allowlist,执行阶段不得回退到 parent Cargo home 或读取其 credentials。 +- 生成物只写入被忽略的 Cargo/`wasi-e2e/target` 目录,不提交 wasm、转译 JS 或 `node_modules`。 + +## 验收标准 + +- [x] `peri-turn-policy` 单测覆盖 content shape、空白差异、threshold 边界和 native Smart/非有限比较语义。 +- [x] 现有 `peri-acp-types` content、Agent keepgoing/stage、compact 测试保持通过,公共 Rust 路径不变。 +- [x] Rust 1.96.1 release build 生成合法 WASI Preview 2 Component,而不是 core Wasm/Preview 1。 +- [x] Cargo tree 不包含 forbidden native closure,Component WIT/import 通过产物级 allowlist。 +- [x] locked Node gate 精确调用 WIT 导出并覆盖 empty/non-empty、0/1/equality、NaN/±Infinity、双侧越界和多错误 precedence。 +- [x] harness 对缺失/空/core-Wasm/损坏 artifact、工具非零退出、acquisition metadata 篡改与 vendor symlink 均确定失败。 +- [x] `git diff --check`、目标 Rust tests/clippy/fmt 与适用 workspace 回归通过。 +- [x] `docs/design/wasi.md` 明确这只是 deterministic policy Component probe,不宣称完整 Agent/ACP 已可移植。 + +## 已有验证证据 + +截至 2026-08-29,以下项目已在当前工作树实际通过: + +- `npm --prefix wasi-e2e run acquire:cargo`:exit 0,`CARGO_VENDOR_REGISTRY_PACKAGES=34`;standalone 与 root registry closure 相同。acquisition 初始化 isolated Cargo home 时只写入固定 config;执行后允许 Cargo 创建无凭据的 cache marker,但 gate 会递归拒绝 credential/token 文件与 symlink。 +- `npm --prefix wasi-e2e test`:exit 0,TAP `tests 8 / pass 8 / fail 0 / skipped 0`;完整链路中的 isolated child 输出 `WASI_P2_ASSERTIONS=28`。 +- `npm --prefix wasi-e2e ci --ignore-scripts --no-audit --no-fund --offline`:exit 0,离线安装 114 packages;`package-lock.json` SHA-256 前后均为 `2ce2079557c31bcb1b7508fb2773128d0e9860d9355a1e55f614055459926248`。lockfile v3 含 219 个 package entries,218 个 registry entry 均有 `resolved` 与 `integrity`,非官方 registry URL 为 0。 +- 产物:`target/wasm32-wasip2/release/peri_wasi.wasm`,header `00 61 73 6d 0d 00 01 00`;Jco 抽取的 world 仅 export `peri:turn-policy/policy@0.1.0`,imports 为空。 +- `cargo test -p peri-turn-policy --lib`:8/8;`cargo test -p peri-acp-types --lib`:291/291;`cargo test -p peri-agent --lib`:696/696。 +- `cargo +1.96.1 check -p peri-turn-policy --target wasm32-wasip2 --locked --offline` 与 `cargo +1.96.1 build -p peri-wasi --target wasm32-wasip2 --release --frozen`:exit 0;产物 16,897 bytes;有效 cfg 包含 `target_os="wasi"`、`target_env="p2"`、`target_feature="crt-static"`。 +- `cargo clippy -p peri-turn-policy -p peri-wasi -p peri-acp-types -p peri-agent --all-targets --offline -- -D warnings` 与 `cargo clippy --workspace --all-targets --offline -- -D warnings`:exit 0。 +- `cargo fmt --all -- --check`、`cargo test --workspace --doc --offline`、`git diff --check`、全部新增文件的 `git diff --no-index --check`、最终 status/ignored-artifact 审计:exit 0 或无问题输出。workspace doc tests 对仅 `cdylib` 的 `peri-wasi` 给出“不支持 doc tests”提示并跳过该 target,其余适用 doc tests 通过。 + +独立代码复审与最终 verifier 均给出通过结论。残余覆盖边界是:当前实测平台为 macOS aarch64、Node.js 22.20.0/npm 10.9.3;没有覆盖其他 OS/runtime,也不包含完整 Agent/ACP WASI port。acquisition 仍要求 parent Cargo cache 已有锁定 crate source,execution gate 本身保持 isolated/offline/credential-free。 + +## Stop conditions + +- 解决编译/运行需要引入 native Host closure、改 ACP wire/中间件顺序、暴露凭据/preopen/network 或安装 `cargo-component`。 +- `wit-bindgen`/Jco 无法无损投影固定 WIT,或最终 Component 出现非 allowlist capability。 +- native keepgoing、whitespace、Compact Smart 行为变化。 +- owned 文件出现非本任务的重叠改动。 + +## 相关入口 + +- `docs/design/wasi.md` +- `peri-acp-types/src/messages/content.rs` +- `peri-agent/src/session/exec/executor.rs` +- `peri-agent/src/agent/compact_v2/mod.rs` +- `peri-turn-policy/src/lib.rs` +- `peri-wasi/wit/world.wit` +- `wasi-e2e/acquire-cargo.mjs` +- `wasi-e2e/harness.mjs` +- `.cargo/config.toml` diff --git a/wasi-e2e/acquire-cargo.mjs b/wasi-e2e/acquire-cargo.mjs new file mode 100644 index 00000000..dcb1787a --- /dev/null +++ b/wasi-e2e/acquire-cargo.mjs @@ -0,0 +1,263 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstatSync, realpathSync } from 'node:fs'; +import { lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +function trustedDirectory(directory, label) { + const declared = path.resolve(directory); + const metadata = lstatSync(declared); + assert.equal(metadata.isSymbolicLink(), false, `${label} must not be a symlink`); + assert.equal(metadata.isDirectory(), true, `${label} must be a directory`); + const canonical = realpathSync(declared); + assert.equal(canonical, declared, `${label} must already be canonical`); + return canonical; +} + +const PACKAGE_DIR = trustedDirectory(fileURLToPath(new URL('.', import.meta.url)), 'package root'); +const ROOT_DIR = trustedDirectory(path.dirname(PACKAGE_DIR), 'repository root'); +const PACKAGE_TARGET_DIR = path.join(PACKAGE_DIR, 'target'); +const ACQUISITION_DIR = path.join(PACKAGE_TARGET_DIR, 'cargo-acquisition'); +const VENDOR_DIR = path.join(PACKAGE_TARGET_DIR, 'cargo-vendor'); +const ISOLATED_CARGO_HOME = path.join(PACKAGE_TARGET_DIR, 'cargo-home'); +const ACQUISITION_MANIFEST = path.join(ACQUISITION_DIR, 'Cargo.toml'); +const ACQUISITION_METADATA = path.join(ACQUISITION_DIR, 'metadata.json'); +const ROOT_MANIFEST = path.join(ROOT_DIR, 'Cargo.toml'); +const POLICY_DIR = path.join(ROOT_DIR, 'peri-turn-policy'); +const WASI_DIR = path.join(ROOT_DIR, 'peri-wasi'); +const FIXED_GENERATED_PATHS = [ACQUISITION_DIR, VENDOR_DIR, ISOLATED_CARGO_HOME]; +const FINGERPRINT_INPUTS = [ + ['root-cargo-lock', path.join(ROOT_DIR, 'Cargo.lock')], + ['root-cargo-manifest', ROOT_MANIFEST], + ['peri-wasi-manifest', path.join(WASI_DIR, 'Cargo.toml')], + ['peri-turn-policy-manifest', path.join(POLICY_DIR, 'Cargo.toml')], +]; + +function isContainedPath(candidate, root) { + const relative = path.relative(root, candidate); + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +async function assertSafeDescendant(target, options = {}) { + const resolved = path.resolve(target); + assert.equal(isContainedPath(resolved, PACKAGE_DIR), true, `generated path escaped package root: ${target}`); + const relative = path.relative(PACKAGE_DIR, resolved); + assert.notEqual(relative, '', 'generated path must be below package root'); + let current = PACKAGE_DIR; + let missing = false; + for (const [index, component] of relative.split(path.sep).entries()) { + current = path.join(current, component); + if (missing) continue; + let metadata; + try { + metadata = await lstat(current); + } catch (error) { + if (error?.code !== 'ENOENT') throw error; + assert.equal(options.allowMissing, true, `generated path is missing: ${current}`); + missing = true; + continue; + } + assert.equal(metadata.isSymbolicLink(), false, `symlink rejected in generated path: ${current}`); + if (index < relative.split(path.sep).length - 1) { + assert.equal(metadata.isDirectory(), true, `generated path ancestor is not a directory: ${current}`); + } + assert.equal(isContainedPath(await realpath(current), PACKAGE_DIR), true, `real path escaped package root: ${current}`); + } +} + +async function assertSafeTree(directory) { + await assertSafeDescendant(directory); + for (const entry of await readdir(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + const metadata = await lstat(entryPath); + assert.equal(metadata.isSymbolicLink(), false, `symlink rejected in generated tree: ${entryPath}`); + assert.equal(isContainedPath(await realpath(entryPath), PACKAGE_DIR), true, `generated entry escaped package root`); + if (metadata.isDirectory()) await assertSafeTree(entryPath); + } +} + +function parentToolHome(name, fallback) { + const parentHome = process.env.HOME ?? process.env.USERPROFILE; + const declared = process.env[name] ?? (parentHome ? path.join(parentHome, fallback) : undefined); + assert.equal(typeof declared, 'string', `${name} is required for Cargo acquisition`); + return trustedDirectory(declared, name); +} + +function acquisitionEnvironment() { + const env = {}; + for (const key of ['PATH', 'TMPDIR', 'NIX_SSL_CERT_FILE', 'SSL_CERT_DIR', 'SSL_CERT_FILE']) { + if (typeof process.env[key] === 'string') env[key] = process.env[key]; + } + if (process.platform === 'win32') { + for (const key of ['SystemRoot', 'TEMP', 'TMP', 'WINDIR']) { + if (typeof process.env[key] === 'string') env[key] = process.env[key]; + } + } + env.CARGO_HOME = parentToolHome('CARGO_HOME', '.cargo'); + env.RUSTUP_HOME = parentToolHome('RUSTUP_HOME', '.rustup'); + env.CARGO_NET_OFFLINE = 'true'; + env.CARGO_TERM_COLOR = 'never'; + env.NO_COLOR = '1'; + env.TERM = 'dumb'; + return env; +} + +function runCargo(args, cwd = ROOT_DIR) { + const result = spawnSync('cargo', ['+1.96.1', ...args], { + cwd, + encoding: 'utf8', + env: acquisitionEnvironment(), + maxBuffer: 16 * 1024 * 1024, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 120_000, + }); + if (result.error || result.status !== 0 || result.signal !== null) { + const detail = [result.stderr, result.stdout].filter(Boolean).join('\n').trim(); + throw new Error( + `offline Cargo acquisition command failed: cargo +1.96.1 ${args.join(' ')}${detail ? `\n${detail}` : ''}\n` + + 'Required crate sources must already exist in the parent Cargo cache; no network fallback is permitted.', + { cause: result.error }, + ); + } + return result.stdout; +} + +function registrySet(tree, localPackages) { + const packages = new Set(); + for (const line of tree.split(/\r?\n/u)) { + const match = /(?:^|[│├└─ ]+)([a-zA-Z0-9_-]+) v([^\s]+)/u.exec(line); + if (match && !localPackages.has(match[1])) packages.add(`${match[1]}@${match[2]}`); + } + return [...packages].sort(); +} + +function cargoConfig() { + return `[source.crates-io] +replace-with = "vendored-sources" + +[source.vendored-sources] +directory = ${JSON.stringify(VENDOR_DIR)} + +[net] +offline = true +`; +} + +async function fingerprintMetadata() { + const combined = createHash('sha256'); + const inputs = []; + for (const [label, file] of FINGERPRINT_INPUTS) { + const labelBytes = Buffer.from(label, 'utf8'); + const contents = await readFile(file); + const lengths = Buffer.alloc(16); + lengths.writeBigUInt64BE(BigInt(labelBytes.length), 0); + lengths.writeBigUInt64BE(BigInt(contents.length), 8); + combined.update(lengths.subarray(0, 8)); + combined.update(labelBytes); + combined.update(lengths.subarray(8)); + combined.update(contents); + inputs.push({ + label, + length: contents.length, + sha256: createHash('sha256').update(contents).digest('hex'), + }); + } + return { + schema: 1, + algorithm: 'sha256-framed-label-length-content', + inputs, + fingerprint: combined.digest('hex'), + }; +} + +async function main() { + await assertSafeDescendant(PACKAGE_TARGET_DIR, { allowMissing: true }); + await mkdir(PACKAGE_TARGET_DIR, { recursive: true }); + await assertSafeDescendant(PACKAGE_TARGET_DIR); + for (const generated of FIXED_GENERATED_PATHS) { + await assertSafeDescendant(generated, { allowMissing: true }); + await rm(generated, { force: true, recursive: true }); + } + + await mkdir(path.join(ACQUISITION_DIR, 'wit'), { recursive: true }); + await assertSafeDescendant(ACQUISITION_DIR); + const manifest = `[package] +name = "peri-wasi" +version = "0.2.0" +edition = "2021" +description = "WASI Preview 2 component for portable Peri turn policy" +publish = false + +[lib] +path = ${JSON.stringify(path.join(WASI_DIR, 'src', 'lib.rs'))} +crate-type = ["cdylib"] + +[dependencies] +peri-turn-policy = { path = ${JSON.stringify(POLICY_DIR)} } +wit-bindgen = { version = "=0.57.1", default-features = false, features = ["macros"] } + +[workspace] +`; + await writeFile(ACQUISITION_MANIFEST, manifest, { encoding: 'utf8', flag: 'wx' }); + await writeFile( + path.join(ACQUISITION_DIR, 'wit', 'world.wit'), + await readFile(path.join(WASI_DIR, 'wit', 'world.wit')), + { flag: 'wx' }, + ); + + runCargo(['generate-lockfile', '--manifest-path', ACQUISITION_MANIFEST, '--offline']); + const rootTree = runCargo([ + 'tree', '--manifest-path', ROOT_MANIFEST, '-p', 'peri-wasi', '--target', 'wasm32-wasip2', + '--edges', 'normal', '--frozen', + ]); + const acquisitionTree = runCargo([ + 'tree', '--manifest-path', ACQUISITION_MANIFEST, '--target', 'wasm32-wasip2', + '--edges', 'normal', '--locked', '--offline', + ]); + const rootRegistry = registrySet(rootTree, new Set(['peri-turn-policy', 'peri-wasi'])); + const acquisitionRegistry = registrySet(acquisitionTree, new Set(['peri-turn-policy', 'peri-wasi'])); + if (JSON.stringify(rootRegistry) !== JSON.stringify(acquisitionRegistry)) { + throw new Error( + `standalone Cargo registry closure drifted\nroot=${JSON.stringify(rootRegistry)}\nacquisition=${JSON.stringify(acquisitionRegistry)}`, + ); + } + + runCargo([ + 'vendor', '--manifest-path', ACQUISITION_MANIFEST, '--locked', '--offline', + '--respect-source-config', '--versioned-dirs', VENDOR_DIR, + ]); + await assertSafeTree(VENDOR_DIR); + const vendorEntries = (await readdir(VENDOR_DIR, { withFileTypes: true })) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort(); + const expectedVendorEntries = rootRegistry.map((entry) => entry.replace('@', '-')).sort(); + assert.deepEqual(vendorEntries, expectedVendorEntries, 'versioned vendor directory set drifted'); + + await mkdir(ISOLATED_CARGO_HOME, { recursive: true }); + await assertSafeDescendant(ISOLATED_CARGO_HOME); + await writeFile(path.join(ISOLATED_CARGO_HOME, 'config.toml'), cargoConfig(), { encoding: 'utf8', flag: 'wx' }); + await assertSafeTree(ISOLATED_CARGO_HOME); + const cargoHomeEntries = await readdir(ISOLATED_CARGO_HOME); + assert.deepEqual(cargoHomeEntries, ['config.toml']); + assert.doesNotMatch(cargoConfig(), /credential|password|token/iu); + + await writeFile( + ACQUISITION_METADATA, + `${JSON.stringify(await fingerprintMetadata(), null, 2)}\n`, + { encoding: 'utf8', flag: 'wx' }, + ); + await assertSafeDescendant(ACQUISITION_METADATA); + + process.stdout.write(`CARGO_VENDOR_REGISTRY_PACKAGES=${rootRegistry.length}\n`); +} + +try { + await main(); +} catch (error) { + process.stderr.write(`Cargo acquisition failed: ${error.message}\n`); + process.exitCode = 1; +} diff --git a/wasi-e2e/exercise.mjs b/wasi-e2e/exercise.mjs new file mode 100644 index 00000000..61eb88dc --- /dev/null +++ b/wasi-e2e/exercise.mjs @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const INTERFACE_NAME = 'peri:turn-policy/policy@0.1.0'; +const EXPECTED_ASSERTIONS = 28; +const PACKAGE_DIR = fileURLToPath(new URL('.', import.meta.url)); +const EXPECTED_MODULE_PATH = path.join(PACKAGE_DIR, 'target', 'wasi-p2-node', 'turn-policy.js'); +const EXPECTED_CWD = path.dirname(EXPECTED_MODULE_PATH); +const ALLOWED_ENV_KEYS = new Set(['PATH', 'SystemRoot', 'TEMP', 'TMP', 'WINDIR']); +if (process.platform === 'darwin') { + ALLOWED_ENV_KEYS.add('__CF_USER_TEXT_ENCODING'); + assert.match(process.env.__CF_USER_TEXT_ENCODING, /^0x[0-9A-Fa-f]+:0x[0-9A-Fa-f]+:0x[0-9A-Fa-f]+$/u); +} + +assert.deepEqual(Object.keys(process.env).sort(), [...Object.keys(process.env)].filter((key) => ALLOWED_ENV_KEYS.has(key)).sort()); +assert.equal(path.resolve(process.cwd()), EXPECTED_CWD); +assert.equal(process.argv.length, 3); + +const moduleUrl = new URL(process.argv[2]); +assert.equal(moduleUrl.protocol, 'file:'); +assert.equal(fileURLToPath(moduleUrl), EXPECTED_MODULE_PATH); +assert.equal(moduleUrl.href, pathToFileURL(EXPECTED_MODULE_PATH).href); + +const generated = await import(moduleUrl.href); +let assertionCount = 0; + +function check(actual, expected) { + assert.deepEqual(actual, expected); + assertionCount += 1; +} + +function checkError(call, expectedPayload) { + let thrown; + try { + call(); + } catch (error) { + thrown = error; + } + assert.notEqual(thrown, undefined, `expected ComponentError(${expectedPayload})`); + assert.equal(thrown.constructor.name, 'ComponentError'); + assert.equal(thrown.name, 'Error'); + assert.equal(thrown.message, expectedPayload); + assert.equal(thrown.payload, expectedPayload); + assert.deepEqual(Object.keys(thrown), []); + assert.equal(Object.getOwnPropertyDescriptor(thrown, 'payload').enumerable, false); + assertionCount += 1; +} + +check(Object.keys(generated), ['_util', INTERFACE_NAME, 'policy']); +const policy = generated[INTERFACE_NAME]; +check(policy === generated.policy, true); +check(Object.keys(policy), ['classifyContent', 'selectCompact']); + +check(policy.classifyContent({ tag: 'text', val: '' }), 'empty'); +check(policy.classifyContent({ tag: 'text', val: ' ' }), 'non-empty'); +check(policy.classifyContent({ tag: 'blocks', val: 0 }), 'empty'); +check(policy.classifyContent({ tag: 'blocks', val: 1 }), 'non-empty'); +check(policy.classifyContent({ tag: 'raw', val: 0 }), 'empty'); +check(policy.classifyContent({ tag: 'raw', val: 1 }), 'non-empty'); + +check(policy.selectCompact(0, 1), 'skip'); +check(policy.selectCompact(1, 0), 'micro'); +check(policy.selectCompact(0, 0), 'micro'); +check(policy.selectCompact(1, 1), 'micro'); +check(policy.selectCompact(0.5, 0.5), 'micro'); +check(policy.selectCompact(0.499, 0.5), 'skip'); +check(policy.selectCompact(0.501, 0.5), 'micro'); + +checkError(() => policy.selectCompact(Number.NaN, 0.5), 'budget-not-finite'); +checkError(() => policy.selectCompact(Number.POSITIVE_INFINITY, 0.5), 'budget-not-finite'); +checkError(() => policy.selectCompact(Number.NEGATIVE_INFINITY, 0.5), 'budget-not-finite'); +checkError(() => policy.selectCompact(-Number.EPSILON, 0.5), 'budget-out-of-range'); +checkError(() => policy.selectCompact(1 + Number.EPSILON, 0.5), 'budget-out-of-range'); +checkError(() => policy.selectCompact(0.5, Number.NaN), 'micro-threshold-not-finite'); +checkError(() => policy.selectCompact(0.5, Number.POSITIVE_INFINITY), 'micro-threshold-not-finite'); +checkError(() => policy.selectCompact(0.5, Number.NEGATIVE_INFINITY), 'micro-threshold-not-finite'); +checkError(() => policy.selectCompact(0.5, -Number.EPSILON), 'micro-threshold-out-of-range'); +checkError(() => policy.selectCompact(0.5, 1 + Number.EPSILON), 'micro-threshold-out-of-range'); +checkError(() => policy.selectCompact(Number.NaN, 2), 'budget-not-finite'); +checkError(() => policy.selectCompact(-Number.EPSILON, Number.NaN), 'budget-out-of-range'); + +assert.equal(assertionCount, EXPECTED_ASSERTIONS); +process.stdout.write(`WASI_P2_ASSERTIONS=${assertionCount}\n`); diff --git a/wasi-e2e/harness.mjs b/wasi-e2e/harness.mjs new file mode 100644 index 00000000..4a6f2565 --- /dev/null +++ b/wasi-e2e/harness.mjs @@ -0,0 +1,713 @@ +import assert from 'node:assert/strict'; +import { spawnSync } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { lstatSync, readFileSync, readdirSync, realpathSync } from 'node:fs'; +import { lstat, mkdir, readFile, readdir, realpath, rm, stat } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const require = createRequire(import.meta.url); + +function trustedDirectory(directory, label) { + const declared = path.resolve(directory); + const metadata = lstatSync(declared); + assert.equal(metadata.isSymbolicLink(), false, `${label} must not be a symlink`); + assert.equal(metadata.isDirectory(), true, `${label} must be a directory`); + const canonical = realpathSync(declared); + assert.equal(canonical, declared, `${label} must already be a canonical path`); + return canonical; +} + +export const PACKAGE_DIR = trustedDirectory(fileURLToPath(new URL('.', import.meta.url)), 'package root'); +export const ROOT_DIR = trustedDirectory(path.dirname(PACKAGE_DIR), 'repository root'); +assert.equal(path.dirname(PACKAGE_DIR), ROOT_DIR, 'wasi-e2e must be a direct repository child'); +assert.equal(path.basename(PACKAGE_DIR), 'wasi-e2e', 'unexpected package root'); + +const TARGET_DIR = path.join(ROOT_DIR, 'target'); +const PACKAGE_TARGET_DIR = path.join(PACKAGE_DIR, 'target'); +const ACQUISITION_DIR = path.join(PACKAGE_TARGET_DIR, 'cargo-acquisition'); +const ACQUISITION_MANIFEST = path.join(ACQUISITION_DIR, 'Cargo.toml'); +const ACQUISITION_LOCK = path.join(ACQUISITION_DIR, 'Cargo.lock'); +export const ACQUISITION_METADATA = path.join(ACQUISITION_DIR, 'metadata.json'); +const VENDOR_DIR = path.join(PACKAGE_TARGET_DIR, 'cargo-vendor'); +const CARGO_HOME = path.join(PACKAGE_TARGET_DIR, 'cargo-home'); +const CARGO_CONFIG = path.join(CARGO_HOME, 'config.toml'); +export const COMPONENT_PATH = path.join( + TARGET_DIR, + 'wasm32-wasip2', + 'release', + 'peri_wasi.wasm', +); +export const OUTPUT_DIR = path.join(PACKAGE_DIR, 'target', 'wasi-p2-node'); +export const GENERATED_JS = path.join(OUTPUT_DIR, 'turn-policy.js'); +export const EXERCISE_PATH = path.join(PACKAGE_DIR, 'exercise.mjs'); +export const INTERFACE_NAME = 'peri:turn-policy/policy@0.1.0'; +export const EXERCISE_ASSERTION_COUNT = 28; + +const PACKAGE_MANIFEST_PATH = path.join(PACKAGE_DIR, 'package.json'); +const PACKAGE_LOCK_PATH = path.join(PACKAGE_DIR, 'package-lock.json'); +const COMPONENT_HEADER = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x0d, 0x00, 0x01, 0x00]); +const CORE_WASM_HEADER = Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00]); +const EXPECTED_GENERATED_FILES = [ + 'interfaces/peri-turn-policy-policy.d.ts', + 'turn-policy.core.wasm', + 'turn-policy.d.ts', + 'turn-policy.js', +]; +const EXPECTED_COMPONENT_IMPORTS = []; +const EXPECTED_COMPONENT_EXPORTS = [INTERFACE_NAME]; +const EXPECTED_WIT = `package root:component; + +world root { + export ${INTERFACE_NAME}; +} +`; +const FORBIDDEN_DEPENDENCIES = new Set([ + 'peri-acp', + 'peri-acp-types', + 'peri-agent', + 'peri-js', + 'peri-lsp', + 'peri-middlewares', + 'peri-model', + 'peri-ptc', + 'peri-resources', + 'peri-workflow', + 'reqwest', + 'rmcp', + 'sqlx', + 'sysinfo', + 'tokio', + 'wasmtime', +]); +const ALLOWED_REPOSITORY_CRATES = new Set(['peri-turn-policy', 'peri-wasi']); +const WINDOWS_RUNTIME_ENV_KEYS = ['SystemRoot', 'TEMP', 'TMP', 'WINDIR']; +const CARGO_CERT_ENV_KEYS = ['NIX_SSL_CERT_FILE', 'SSL_CERT_DIR', 'SSL_CERT_FILE']; +const EXPECTED_CARGO_CONFIG = `[source.crates-io] +replace-with = "vendored-sources" + +[source.vendored-sources] +directory = ${JSON.stringify(VENDOR_DIR)} + +[net] +offline = true +`; +const EXPECTED_ACQUISITION_MANIFEST = `[package] +name = "peri-wasi" +version = "0.2.0" +edition = "2021" +description = "WASI Preview 2 component for portable Peri turn policy" +publish = false + +[lib] +path = ${JSON.stringify(path.join(ROOT_DIR, 'peri-wasi', 'src', 'lib.rs'))} +crate-type = ["cdylib"] + +[dependencies] +peri-turn-policy = { path = ${JSON.stringify(path.join(ROOT_DIR, 'peri-turn-policy'))} } +wit-bindgen = { version = "=0.57.1", default-features = false, features = ["macros"] } + +[workspace] +`; +const FINGERPRINT_INPUTS = [ + ['root-cargo-lock', path.join(ROOT_DIR, 'Cargo.lock')], + ['root-cargo-manifest', path.join(ROOT_DIR, 'Cargo.toml')], + ['peri-wasi-manifest', path.join(ROOT_DIR, 'peri-wasi', 'Cargo.toml')], + ['peri-turn-policy-manifest', path.join(ROOT_DIR, 'peri-turn-policy', 'Cargo.toml')], +]; +const DISALLOWED_ENV_KEYS = [ + 'AWS_ACCESS_KEY_ID', + 'AZURE_CLIENT_SECRET', + 'GITHUB_TOKEN', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'HOME', + 'HTTPS_PROXY', + 'NODE_OPTIONS', + 'NODE_PATH', + 'NPM_TOKEN', + 'OPENAI_API_KEY', + 'USERPROFILE', + 'npm_config_user_agent', +]; + +function trustedRustupHome() { + const configured = process.env.RUSTUP_HOME; + const parentHome = process.env.HOME ?? process.env.USERPROFILE; + const declared = configured ?? (parentHome ? path.join(parentHome, '.rustup') : undefined); + assert.equal(typeof declared, 'string', 'RUSTUP_HOME must be configured or derivable by the parent'); + return trustedDirectory(declared, 'RUSTUP_HOME'); +} + +const RUSTUP_HOME = trustedRustupHome(); + +export class ToolExecutionError extends Error { + constructor(command, result) { + const outcome = result.error + ? result.error.message + : result.signal + ? `signal ${result.signal}` + : `status ${result.status}`; + super(`${command} failed with ${outcome}`); + this.name = 'ToolExecutionError'; + this.status = result.status; + this.signal = result.signal; + this.stdout = result.stdout ?? ''; + this.stderr = result.stderr ?? ''; + this.cause = result.error; + } +} + +function readJson(file) { + return JSON.parse(readFileSync(file, 'utf8')); +} + +function assertExactOwnedPath(actual, expected, label) { + assert.equal(path.resolve(actual), expected, `${label} escaped its owned path`); +} + +function isContainedPath(candidate, root) { + const relative = path.relative(root, candidate); + return relative === '' || (relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +export async function assertSafeDescendant(target, trustedRoot, options = {}) { + const canonicalRoot = await realpath(trustedRoot); + assert.equal(canonicalRoot, trustedRoot, 'trusted root changed after initialization'); + const rootMetadata = await lstat(trustedRoot); + assert.equal(rootMetadata.isSymbolicLink(), false, 'trusted root must not be a symlink'); + assert.equal(rootMetadata.isDirectory(), true, 'trusted root must be a directory'); + + const resolvedTarget = path.resolve(target); + const relative = path.relative(trustedRoot, resolvedTarget); + assert.equal(relative !== '', true, 'target must be below its trusted root'); + assert.equal(isContainedPath(resolvedTarget, trustedRoot), true, 'target escaped its trusted root'); + + const components = relative.split(path.sep); + let current = trustedRoot; + let missing = false; + let verifiedParent = trustedRoot; + for (const [index, component] of components.entries()) { + current = path.join(current, component); + if (missing) { + continue; + } + + let metadata; + try { + metadata = await lstat(current); + } catch (error) { + if (error?.code !== 'ENOENT') { + throw error; + } + assert.equal(options.allowMissing, true, `required path is missing: ${current}`); + missing = true; + continue; + } + + assert.equal(metadata.isSymbolicLink(), false, `symlink rejected in owned path: ${current}`); + const isLeaf = index === components.length - 1; + if (!isLeaf) { + assert.equal(metadata.isDirectory(), true, `path ancestor is not a directory: ${current}`); + } else if (options.leafKind === 'directory') { + assert.equal(metadata.isDirectory(), true, `owned path must be a directory: ${current}`); + } else if (options.leafKind === 'file') { + assert.equal(metadata.isFile(), true, `owned path must be a file: ${current}`); + } + + const canonicalCurrent = await realpath(current); + assert.equal(isContainedPath(canonicalCurrent, trustedRoot), true, `real path escaped trusted root: ${current}`); + verifiedParent = current; + } + + return { exists: !missing, verifiedParent }; +} + +function copyAllowed(source, keys, destination) { + for (const key of keys) { + if (typeof source[key] === 'string') { + destination[key] = source[key]; + } + } +} + +function assertSafeDescendantSync(target, trustedRoot, label) { + const resolvedTarget = path.resolve(target); + assert.equal(isContainedPath(resolvedTarget, trustedRoot), true, `${label} escaped its trusted root`); + const relative = path.relative(trustedRoot, resolvedTarget); + assert.equal(relative !== '', true, `${label} must be below its trusted root`); + let current = trustedRoot; + for (const component of relative.split(path.sep)) { + current = path.join(current, component); + let metadata; + try { + metadata = lstatSync(current); + } catch (error) { + if (error?.code === 'ENOENT') { + throw new Error( + `isolated Cargo acquisition is missing (${current}); run \`npm --prefix wasi-e2e run acquire:cargo\` first`, + { cause: error }, + ); + } + throw error; + } + assert.equal(metadata.isSymbolicLink(), false, `symlink rejected in ${label}: ${current}`); + const canonical = realpathSync(current); + assert.equal(isContainedPath(canonical, trustedRoot), true, `${label} real path escaped trusted root`); + } +} + +function assertCredentialFreeTree(directory) { + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const entryPath = path.join(directory, entry.name); + const metadata = lstatSync(entryPath); + assert.equal(metadata.isSymbolicLink(), false, `symlink rejected in isolated Cargo home: ${entryPath}`); + assert.doesNotMatch(entry.name, /credential|token/iu, `credential-like file rejected: ${entryPath}`); + if (metadata.isDirectory()) { + assertCredentialFreeTree(entryPath); + } + } +} + +function assertRegularFile(file, label) { + const metadata = lstatSync(file); + assert.equal(metadata.isSymbolicLink(), false, `${label} must not be a symlink`); + assert.equal(metadata.isFile(), true, `${label} must be a regular file`); +} + +export function validateSafeTreeSync(directory, trustedRoot) { + assertSafeDescendantSync(directory, trustedRoot, 'validated tree'); + const visit = (current) => { + for (const entry of readdirSync(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name); + const metadata = lstatSync(entryPath); + assert.equal(metadata.isSymbolicLink(), false, `symlink rejected in validated tree: ${entryPath}`); + const canonical = realpathSync(entryPath); + assert.equal(isContainedPath(canonical, trustedRoot), true, `validated tree entry escaped trusted root: ${entryPath}`); + if (metadata.isDirectory()) visit(entryPath); + } + }; + visit(directory); +} + +export function computeAcquisitionMetadata(inputs = FINGERPRINT_INPUTS) { + const combined = createHash('sha256'); + const metadataInputs = []; + for (const [label, file] of inputs) { + const labelBytes = Buffer.from(label, 'utf8'); + const contents = readFileSync(file); + const lengths = Buffer.alloc(16); + lengths.writeBigUInt64BE(BigInt(labelBytes.length), 0); + lengths.writeBigUInt64BE(BigInt(contents.length), 8); + combined.update(lengths.subarray(0, 8)); + combined.update(labelBytes); + combined.update(lengths.subarray(8)); + combined.update(contents); + metadataInputs.push({ + label, + length: contents.length, + sha256: createHash('sha256').update(contents).digest('hex'), + }); + } + return { + schema: 1, + algorithm: 'sha256-framed-label-length-content', + inputs: metadataInputs, + fingerprint: combined.digest('hex'), + }; +} + +export function validateFingerprintMetadata(metadataPath = ACQUISITION_METADATA, inputs = FINGERPRINT_INPUTS) { + assertRegularFile(metadataPath, 'acquisition metadata'); + assert.deepEqual(JSON.parse(readFileSync(metadataPath, 'utf8')), computeAcquisitionMetadata(inputs)); +} + +export function validateIsolatedCargoHome() { + assert.equal(isContainedPath(CARGO_HOME, PACKAGE_TARGET_DIR), true, 'CARGO_HOME must be below wasi-e2e/target'); + assertSafeDescendantSync(CARGO_HOME, PACKAGE_DIR, 'isolated Cargo home'); + assertSafeDescendantSync(VENDOR_DIR, PACKAGE_DIR, 'Cargo vendor directory'); + assertSafeDescendantSync(ACQUISITION_DIR, PACKAGE_DIR, 'Cargo acquisition workspace'); + assertSafeDescendantSync(ACQUISITION_MANIFEST, PACKAGE_DIR, 'Cargo acquisition manifest'); + assertSafeDescendantSync(ACQUISITION_LOCK, PACKAGE_DIR, 'Cargo acquisition lock'); + assertSafeDescendantSync(ACQUISITION_METADATA, PACKAGE_DIR, 'Cargo acquisition metadata'); + assertRegularFile(ACQUISITION_LOCK, 'acquisition Cargo.lock'); + assertRegularFile(ACQUISITION_METADATA, 'acquisition metadata'); + validateFingerprintMetadata(); + assert.equal(readFileSync(ACQUISITION_MANIFEST, 'utf8'), EXPECTED_ACQUISITION_MANIFEST, 'acquisition manifest drifted'); + const acquisitionWit = path.join(ACQUISITION_DIR, 'wit', 'world.wit'); + assertSafeDescendantSync(acquisitionWit, PACKAGE_DIR, 'Cargo acquisition WIT'); + assert.equal( + readFileSync(acquisitionWit, 'utf8'), + readFileSync(path.join(ROOT_DIR, 'peri-wasi', 'wit', 'world.wit'), 'utf8'), + 'acquisition WIT is stale; rerun acquire:cargo', + ); + assertSafeDescendantSync(CARGO_CONFIG, PACKAGE_DIR, 'isolated Cargo config'); + assert.equal(readFileSync(CARGO_CONFIG, 'utf8'), EXPECTED_CARGO_CONFIG, 'isolated Cargo config drifted'); + assert.doesNotMatch(readFileSync(CARGO_CONFIG, 'utf8'), /credential|password|token/iu); + assertCredentialFreeTree(CARGO_HOME); + validateSafeTreeSync(VENDOR_DIR, PACKAGE_DIR); +} + +export function jcoEnvironment(source = process.env) { + const env = {}; + copyAllowed(source, ['PATH'], env); + if (process.platform === 'win32') { + copyAllowed(source, WINDOWS_RUNTIME_ENV_KEYS, env); + } + env.NO_COLOR = '1'; + return env; +} + +export function cargoEnvironment(source = process.env) { + validateIsolatedCargoHome(); + const env = {}; + copyAllowed(source, ['PATH'], env); + if (process.platform === 'win32') { + copyAllowed(source, WINDOWS_RUNTIME_ENV_KEYS, env); + } else { + copyAllowed(source, ['TMPDIR'], env); + } + copyAllowed(source, CARGO_CERT_ENV_KEYS, env); + env.CARGO_HOME = CARGO_HOME; + env.RUSTUP_HOME = RUSTUP_HOME; + env.CARGO_NET_OFFLINE = 'true'; + env.CARGO_TERM_COLOR = 'never'; + env.NO_COLOR = '1'; + env.TERM = 'dumb'; + return env; +} + +export function assertSanitizedToolEnvironments() { + const sentinelSource = Object.fromEntries(DISALLOWED_ENV_KEYS.map((key) => [key, `sentinel-${key}`])); + sentinelSource.PATH = process.env.PATH ?? ''; + sentinelSource.TMPDIR = process.env.TMPDIR ?? '/tmp'; + const environments = [jcoEnvironment(sentinelSource), cargoEnvironment(sentinelSource)]; + for (const env of environments) { + for (const disallowed of DISALLOWED_ENV_KEYS) { + assert.equal(Object.hasOwn(env, disallowed), false, `${disallowed} leaked into a tool environment`); + } + } + assert.deepEqual(Object.keys(jcoEnvironment(sentinelSource)).sort(), ['NO_COLOR', 'PATH']); + assert.equal(environments[1].CARGO_NET_OFFLINE, 'true'); + assert.equal(environments[1].CARGO_HOME, CARGO_HOME); + assert.equal(environments[1].RUSTUP_HOME, RUSTUP_HOME); +} + +function run(command, args, options = {}) { + assert.notEqual(options.env, undefined, `explicit environment required for ${command}`); + const result = spawnSync(command, args, { + cwd: options.cwd ?? ROOT_DIR, + encoding: 'utf8', + env: options.env, + input: options.input, + maxBuffer: 16 * 1024 * 1024, + shell: false, + stdio: options.stdio ?? ['ignore', 'pipe', 'pipe'], + timeout: options.timeout ?? 30_000, + }); + if (result.error || result.status !== 0 || result.signal !== null) { + throw new ToolExecutionError([command, ...args].join(' '), result); + } + return result; +} + +export function runJco(jcoEntry, args, options = {}) { + return run(process.execPath, [jcoEntry, ...args], { + ...options, + env: options.env ?? jcoEnvironment(), + }); +} + +export function resolvePinnedPackages() { + const jcoPackagePath = path.resolve(path.dirname(require.resolve('@bytecodealliance/jco')), '..', 'package.json'); + const shimPackagePath = path.resolve( + path.dirname(require.resolve('@bytecodealliance/preview2-shim')), + '..', + '..', + 'package.json', + ); + const jcoPackage = readJson(jcoPackagePath); + const shimPackage = readJson(shimPackagePath); + + assert.equal(jcoPackage.name, '@bytecodealliance/jco'); + assert.equal(jcoPackage.version, '1.32.1'); + assert.deepEqual(jcoPackage.bin, { jco: 'dist/jco.js' }); + assert.equal(shimPackage.name, '@bytecodealliance/preview2-shim'); + assert.equal(shimPackage.version, '0.22.0'); + + const jcoPackageDir = path.dirname(jcoPackagePath); + const jcoEntry = path.resolve(jcoPackageDir, jcoPackage.bin.jco); + assert.equal( + path.relative(jcoPackageDir, jcoEntry), + path.join('dist', 'jco.js'), + 'Jco bin must resolve inside the pinned package', + ); + assert.equal(readFileSync(jcoEntry, 'utf8').length > 0, true, 'Jco bin must be nonempty'); + + return { jcoEntry }; +} + +export function validateHostToolchain() { + const nodeParts = process.versions.node.split('.').map(Number); + assert.equal(nodeParts.length, 3, 'Node must report a three-part version'); + assert.equal(nodeParts[0], 22, 'Node major version must be 22'); + assert.equal(nodeParts[1] >= 20, true, 'Node must be at least v22.20.0'); + + const manifest = readJson(PACKAGE_MANIFEST_PATH); + const lock = readJson(PACKAGE_LOCK_PATH); + assert.equal(manifest.name, 'peri-wasi-e2e'); + assert.equal(manifest.version, '0.0.0'); + assert.equal(manifest.private, true); + assert.equal(manifest.type, 'module'); + assert.equal(manifest.packageManager, 'npm@10.9.3'); + assert.equal(manifest.engines.node, '>=22.20.0 <23'); + assert.deepEqual(manifest.devDependencies, { + '@bytecodealliance/jco': '1.32.1', + '@bytecodealliance/preview2-shim': '0.22.0', + }); + assert.equal(lock.lockfileVersion, 3); + assert.equal(lock.name, manifest.name); + assert.equal(lock.version, manifest.version); + assert.deepEqual(lock.packages[''].devDependencies, manifest.devDependencies); + + const npmUserAgent = process.env.npm_config_user_agent ?? ''; + const npmVersion = /(?:^|\s)npm\/([^\s]+)/u.exec(npmUserAgent)?.[1]; + assert.equal(npmVersion, '10.9.3', 'run the gate through pinned npm 10.9.3'); + + const pinned = resolvePinnedPackages(); + assert.equal(runJco(pinned.jcoEntry, ['--version']).stdout.trim(), '1.32.1'); + assert.match(runJco(pinned.jcoEntry, ['wit', '--help']).stdout, /extract the WIT/u); + const transpileHelp = runJco(pinned.jcoEntry, ['transpile', '--help']).stdout; + assert.match(transpileHelp, /--name /u); + assert.match(transpileHelp, /--base64-cutoff /u); + return pinned; +} + +export async function cleanOwnedArtifacts() { + assertExactOwnedPath(COMPONENT_PATH, path.join(ROOT_DIR, 'target', 'wasm32-wasip2', 'release', 'peri_wasi.wasm'), 'Component'); + assertExactOwnedPath(OUTPUT_DIR, path.join(PACKAGE_DIR, 'target', 'wasi-p2-node'), 'transpile output'); + await assertSafeDescendant(COMPONENT_PATH, ROOT_DIR, { allowMissing: true, leafKind: 'file' }); + await assertSafeDescendant(OUTPUT_DIR, PACKAGE_DIR, { allowMissing: true, leafKind: 'directory' }); + await rm(COMPONENT_PATH, { force: true }); + await rm(OUTPUT_DIR, { force: true, recursive: true }); +} + +export function assertStableRust() { + const env = cargoEnvironment(); + const rustc = run('rustc', ['+1.96.1', '--version', '--verbose'], { env }); + assert.match(rustc.stdout, /^rustc 1\.96\.1 \(/u); + assert.match(rustc.stdout, /^release: 1\.96\.1$/mu); + const installedTargets = run( + 'rustup', + ['target', 'list', '--installed', '--toolchain', '1.96.1'], + { env }, + ).stdout.trim().split(/\r?\n/u); + assert.equal(installedTargets.includes('wasm32-wasip2'), true, 'wasm32-wasip2 must be installed for 1.96.1'); +} + +export async function buildComponent() { + assertExactOwnedPath(TARGET_DIR, path.join(ROOT_DIR, 'target'), 'Cargo target directory'); + await assertSafeDescendant(TARGET_DIR, ROOT_DIR, { allowMissing: true, leafKind: 'directory' }); + await assertSafeDescendant(COMPONENT_PATH, ROOT_DIR, { allowMissing: true, leafKind: 'file' }); + await mkdir(TARGET_DIR, { recursive: true }); + await assertSafeDescendant(TARGET_DIR, ROOT_DIR, { leafKind: 'directory' }); + await assertSafeDescendant(COMPONENT_PATH, ROOT_DIR, { allowMissing: true, leafKind: 'file' }); + + const result = run( + 'cargo', + [ + '+1.96.1', + '--config', + CARGO_CONFIG, + 'build', + '--manifest-path', + ACQUISITION_MANIFEST, + '--target-dir', + TARGET_DIR, + '--target', + 'wasm32-wasip2', + '--release', + '--frozen', + '-p', + 'peri-wasi', + ], + { env: cargoEnvironment(), timeout: 120_000 }, + ); + await assertSafeDescendant(COMPONENT_PATH, ROOT_DIR, { leafKind: 'file' }); + return result; +} + +export function captureAndValidateCargoTree() { + const result = run( + 'cargo', + [ + '+1.96.1', + '--config', + CARGO_CONFIG, + 'tree', + '--manifest-path', + ACQUISITION_MANIFEST, + '--target', + 'wasm32-wasip2', + '--edges', + 'normal', + '--frozen', + '-p', + 'peri-wasi', + ], + { env: cargoEnvironment(), timeout: 60_000 }, + ); + const tree = result.stdout; + assert.equal(tree.trim().length > 0, true, 'Cargo tree must be nonempty'); + + const packageNames = new Set( + tree + .split('\n') + .map((line) => /(?:^|[│├└─ ]+)([a-zA-Z0-9_-]+) v\d/u.exec(line)?.[1]) + .filter(Boolean), + ); + for (const forbidden of FORBIDDEN_DEPENDENCIES) { + assert.equal(packageNames.has(forbidden), false, `forbidden dependency in WASI closure: ${forbidden}`); + } + + const repositoryCrates = new Set(); + for (const match of tree.matchAll(/([a-zA-Z0-9_-]+) v[^\s]+ \((\/[\S][^)]*|[a-zA-Z]:\\[^)]*)\)/gu)) { + const sourcePath = path.resolve(match[2]); + const relative = path.relative(ROOT_DIR, sourcePath); + if (relative !== '' && !relative.startsWith(`..${path.sep}`) && relative !== '..') { + repositoryCrates.add(match[1]); + } + } + assert.deepEqual([...repositoryCrates].sort(), [...ALLOWED_REPOSITORY_CRATES].sort()); + return tree; +} + +export async function validateComponentHeader(componentPath) { + let bytes; + try { + bytes = await readFile(componentPath); + } catch (error) { + if (error?.code === 'ENOENT') { + throw new Error(`Component does not exist: ${componentPath}`, { cause: error }); + } + throw error; + } + if (bytes.length === 0) { + throw new Error(`Component is empty: ${componentPath}`); + } + if (bytes.subarray(0, CORE_WASM_HEADER.length).equals(CORE_WASM_HEADER)) { + throw new Error(`Expected a Component, received a core Wasm module: ${componentPath}`); + } + if (bytes.length < COMPONENT_HEADER.length || !bytes.subarray(0, COMPONENT_HEADER.length).equals(COMPONENT_HEADER)) { + throw new Error(`Invalid WASI Preview 2 Component header: ${componentPath}`); + } + return bytes; +} + +export async function extractComponentWit(componentPath, jcoEntry) { + if (path.resolve(componentPath) === COMPONENT_PATH) { + await assertSafeDescendant(COMPONENT_PATH, ROOT_DIR, { leafKind: 'file' }); + } + await validateComponentHeader(componentPath); + const result = runJco(jcoEntry, ['wit', componentPath]); + return `${result.stdout.trimEnd()}\n`; +} + +export function assertComponentInterface(wit) { + assert.equal(wit, EXPECTED_WIT, 'Jco WIT projection drifted'); + const imports = [...wit.matchAll(/^\s*import\s+([^;]+);$/gmu)].map((match) => match[1]); + const exports = [...wit.matchAll(/^\s*export\s+([^;]+);$/gmu)].map((match) => match[1]); + assert.deepEqual(imports, EXPECTED_COMPONENT_IMPORTS, 'Component import allowlist drifted'); + assert.deepEqual(exports, EXPECTED_COMPONENT_EXPORTS, 'Component business exports drifted'); + assert.doesNotMatch(wit, /wasi:(?:cli\/(?:arguments|environment)|filesystem|http|sockets)/u); +} + +export async function transpileComponent(jcoEntry) { + assertExactOwnedPath(OUTPUT_DIR, path.join(PACKAGE_DIR, 'target', 'wasi-p2-node'), 'transpile output'); + await assertSafeDescendant(COMPONENT_PATH, ROOT_DIR, { leafKind: 'file' }); + await assertSafeDescendant(OUTPUT_DIR, PACKAGE_DIR, { allowMissing: true, leafKind: 'directory' }); + await mkdir(OUTPUT_DIR, { recursive: true }); + await assertSafeDescendant(OUTPUT_DIR, PACKAGE_DIR, { leafKind: 'directory' }); + const result = runJco( + jcoEntry, + [ + 'transpile', + COMPONENT_PATH, + '--out-dir', + OUTPUT_DIR, + '--name', + 'turn-policy', + '--base64-cutoff', + '0', + ], + { timeout: 60_000 }, + ); + await assertSafeDescendant(OUTPUT_DIR, PACKAGE_DIR, { leafKind: 'directory' }); + return result; +} + +async function listGeneratedFiles(directory, relative = '') { + const entries = await readdir(path.join(directory, relative), { withFileTypes: true }); + const files = []; + for (const entry of entries) { + const entryRelative = path.join(relative, entry.name); + if (entry.isDirectory()) { + files.push(...await listGeneratedFiles(directory, entryRelative)); + } else { + assert.equal(entry.isFile(), true, `unexpected generated entry: ${entryRelative}`); + files.push(entryRelative.split(path.sep).join('/')); + } + } + return files; +} + +export async function validateGeneratedOutput() { + await assertSafeDescendant(OUTPUT_DIR, PACKAGE_DIR, { leafKind: 'directory' }); + assert.deepEqual((await listGeneratedFiles(OUTPUT_DIR)).sort(), [...EXPECTED_GENERATED_FILES].sort()); + assert.equal(await realpath(OUTPUT_DIR), OUTPUT_DIR); + + for (const relative of EXPECTED_GENERATED_FILES) { + const generatedPath = path.resolve(OUTPUT_DIR, relative); + assert.equal(path.relative(OUTPUT_DIR, generatedPath).startsWith('..'), false); + await assertSafeDescendant(generatedPath, PACKAGE_DIR, { leafKind: 'file' }); + assert.equal((await stat(generatedPath)).size > 0, true, `${relative} must be nonempty`); + } + + const coreWasm = await readFile(path.join(OUTPUT_DIR, 'turn-policy.core.wasm')); + assert.equal(coreWasm.length > CORE_WASM_HEADER.length, true, 'generated core Wasm must be nonempty'); + assert.equal(coreWasm.subarray(0, CORE_WASM_HEADER.length).equals(CORE_WASM_HEADER), true); + + const worldTypes = await readFile(path.join(OUTPUT_DIR, 'turn-policy.d.ts'), 'utf8'); + assert.equal( + worldTypes, + `// world root:component/root\nexport * as policy from './interfaces/peri-turn-policy-policy.js'; // export ${INTERFACE_NAME}\n`, + ); +} + +function exerciseEnvironment() { + const env = {}; + copyAllowed(process.env, ['PATH'], env); + if (process.platform === 'win32') { + copyAllowed(process.env, WINDOWS_RUNTIME_ENV_KEYS, env); + } + return env; +} + +export async function runExercise() { + assertExactOwnedPath(GENERATED_JS, path.join(OUTPUT_DIR, 'turn-policy.js'), 'generated module'); + await assertSafeDescendant(GENERATED_JS, PACKAGE_DIR, { leafKind: 'file' }); + const result = run( + process.execPath, + [EXERCISE_PATH, pathToFileURL(GENERATED_JS).href], + { + cwd: OUTPUT_DIR, + env: exerciseEnvironment(), + stdio: ['ignore', 'pipe', 'pipe'], + timeout: 10_000, + }, + ); + assert.equal(result.status, 0); + assert.equal(result.signal, null); + assert.equal(result.stderr, ''); + assert.equal(result.stdout, `WASI_P2_ASSERTIONS=${EXERCISE_ASSERTION_COUNT}\n`); +} diff --git a/wasi-e2e/package-lock.json b/wasi-e2e/package-lock.json new file mode 100644 index 00000000..34b5dcf8 --- /dev/null +++ b/wasi-e2e/package-lock.json @@ -0,0 +1,3382 @@ +{ + "name": "peri-wasi-e2e", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "peri-wasi-e2e", + "version": "0.0.0", + "devDependencies": { + "@bytecodealliance/jco": "1.32.1", + "@bytecodealliance/preview2-shim": "0.22.0" + }, + "engines": { + "node": ">=22.20.0 <23" + } + }, + "node_modules/@andreiltd/componentize-qjs-binding-darwin-arm64": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@andreiltd/componentize-qjs-binding-darwin-arm64/-/componentize-qjs-binding-darwin-arm64-0.4.3.tgz", + "integrity": "sha512-bCqUlC2gkOSHEn/qaFMzwRegwv7zTgSxH1kbFJ1riHArhXEQjOdMPb3sAGd4euH81CnnDbug6Bqh4SBNBXXrXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@andreiltd/componentize-qjs-binding-darwin-x64": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@andreiltd/componentize-qjs-binding-darwin-x64/-/componentize-qjs-binding-darwin-x64-0.4.3.tgz", + "integrity": "sha512-fP4Ekzn7pR8KqRIoH41zbOTl1i3nEYCog8r/gy2nDzg86EYl51kdrCP+jN4B9tPmYOH8erae7i88Mb50EshyLA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@andreiltd/componentize-qjs-binding-linux-arm64-gnu": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@andreiltd/componentize-qjs-binding-linux-arm64-gnu/-/componentize-qjs-binding-linux-arm64-gnu-0.4.3.tgz", + "integrity": "sha512-3+Oz1cQRq2KklYdGjhNiI8kNXjCO3ZxBVxf/ZjeGg8MC6Z9A/fO2Hx3T65xSnA5voc2RlWAs0eSL9veoH3CqWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@andreiltd/componentize-qjs-binding-linux-arm64-musl": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@andreiltd/componentize-qjs-binding-linux-arm64-musl/-/componentize-qjs-binding-linux-arm64-musl-0.4.3.tgz", + "integrity": "sha512-quZj5Z81UC5ufUXDAS3knfpO3x4KQCADqjH42FgeliBEZWN6imgjDF8DZKTwtzMH8vH6I2pxB9vu/D3ufVA+xA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@andreiltd/componentize-qjs-binding-linux-x64-gnu": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@andreiltd/componentize-qjs-binding-linux-x64-gnu/-/componentize-qjs-binding-linux-x64-gnu-0.4.3.tgz", + "integrity": "sha512-JqknHtOQF5ah6eN7t82f9fPDkbwVhHC5JMS+7UE0iPaJcDss6BqFYMiFBQ8v/iFxZdtrum3PF2xViTBAEdS2EQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@andreiltd/componentize-qjs-binding-linux-x64-musl": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@andreiltd/componentize-qjs-binding-linux-x64-musl/-/componentize-qjs-binding-linux-x64-musl-0.4.3.tgz", + "integrity": "sha512-5J/HSTeWKz49PTkRPS+856L6un8ONGPhs1JvNTAyDXW4zgcCOGQ5x7CBmWStLdIthiIUPKgW53wIAVp2aU5HAQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@andreiltd/componentize-qjs-binding-win32-arm64-msvc": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@andreiltd/componentize-qjs-binding-win32-arm64-msvc/-/componentize-qjs-binding-win32-arm64-msvc-0.4.3.tgz", + "integrity": "sha512-lZ7XQzbXsIRpCKqMnof0YSPiV01JFpcw3LOpR4q6kbR8+J8qqCwxD5hrJM0kCeml1YkxP9VxDpLSBRN40c+GVQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@andreiltd/componentize-qjs-binding-win32-x64-msvc": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@andreiltd/componentize-qjs-binding-win32-x64-msvc/-/componentize-qjs-binding-win32-x64-msvc-0.4.3.tgz", + "integrity": "sha512-0kaaM7C9MALnOGKrsJOL2uUJJV6njrfgqJNz7e5aT4LDQbBxjPK0XUVimr3QSyfNBwIySgcZIzufgc+5BVcBMg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@bytecodealliance/componentize-js": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/componentize-js/-/componentize-js-0.22.0.tgz", + "integrity": "sha512-sscwKDazGgMWZkS6azvY66tY3X09nzl58jn9j6X0E9/V4B5hCXTI+v7PFUyLowKYgu8MG4FJsSvCpq64lSXsIA==", + "dev": true, + "workspaces": [ + "." + ], + "dependencies": { + "@bytecodealliance/jco": "^1.15.1", + "@bytecodealliance/weval": "^0.4.1", + "@bytecodealliance/wizer": "^10.0.0", + "es-module-lexer": "^1.6.0", + "oxc-parser": "^0.76.0" + }, + "bin": { + "componentize-js": "src/cli.js" + } + }, + "node_modules/@bytecodealliance/componentize-js-0-19-3": { + "name": "@bytecodealliance/componentize-js", + "version": "0.19.3", + "resolved": "https://registry.npmjs.org/@bytecodealliance/componentize-js/-/componentize-js-0.19.3.tgz", + "integrity": "sha512-ju7Y4WeF0B9uMkSPHJgmT6ouEfSwbe9M1uR/YOnYZjBpxJjH9qzxIkJg/kf8NycVDyFJ2/lscmJ1E1uPiDQVRQ==", + "dev": true, + "workspaces": [ + "." + ], + "dependencies": { + "@bytecodealliance/jco": "^1.15.1", + "@bytecodealliance/wizer": "^10.0.0", + "es-module-lexer": "^1.6.0", + "oxc-parser": "^0.76.0" + }, + "bin": { + "componentize-js": "src/cli.js" + } + }, + "node_modules/@bytecodealliance/jco": { + "version": "1.32.1", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco/-/jco-1.32.1.tgz", + "integrity": "sha512-9w/AZsF3Kax39KnJso/Y7Xrql4SpBJBs5KnE8m0+N3nrqmq12vvQs2kiNbfeGTMAFsbKo5xgYbO40ldPo933AQ==", + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "dependencies": { + "@bytecodealliance/componentize-js": "^0.22.0", + "@bytecodealliance/componentize-js-0-19-3": "npm:@bytecodealliance/componentize-js@^0.19.3", + "@bytecodealliance/jco-transpile": "^0.12.1", + "@bytecodealliance/preview2-shim": "^0.22.0", + "binaryen": "^130.0.0", + "commander": "^14", + "componentize-qjs": "^0.4.2", + "mkdirp": "^3", + "rolldown": "^1.2.4", + "typescript": "7.0.2", + "typescript-compiler-api": "npm:typescript@6.0.3" + }, + "bin": { + "jco": "dist/jco.js" + } + }, + "node_modules/@bytecodealliance/jco-node-fs": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs/-/jco-node-fs-0.2.0.tgz", + "integrity": "sha512-AnEbgoANBlFYtrLtiV3Ht6N+uc/8oMA5WFRxPm54tZ161F8FYP00cTqnbB/FQaN6oU8G27zMElpxcQqv8lXuKA==", + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "@bytecodealliance/jco-node-fs-darwin-arm64": "0.2.0", + "@bytecodealliance/jco-node-fs-darwin-x64": "0.2.0", + "@bytecodealliance/jco-node-fs-linux-arm64-gnu": "0.2.0", + "@bytecodealliance/jco-node-fs-linux-arm64-musl": "0.2.0", + "@bytecodealliance/jco-node-fs-linux-x64-gnu": "0.2.0", + "@bytecodealliance/jco-node-fs-linux-x64-musl": "0.2.0", + "@bytecodealliance/jco-node-fs-win32-arm64-msvc": "0.2.0", + "@bytecodealliance/jco-node-fs-win32-x64-msvc": "0.2.0" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-darwin-arm64": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-darwin-arm64/-/jco-node-fs-darwin-arm64-0.2.0.tgz", + "integrity": "sha512-+Nu0mjp6TiR+QcY1TRbJeG70u4370eM6Kk9BkzeUuAKn920IAJJ8wFU05OKRsCXwiCSHm/A0DDcZM/TVy7gXhw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-darwin-x64": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-darwin-x64/-/jco-node-fs-darwin-x64-0.2.0.tgz", + "integrity": "sha512-HUF+dPyBBAnIbYuL7EdmdfA7kj0F2ceQ5szNTF0HCPrPCoTtP1oWYtNEx4N6zLC5hZ2hJc01BeRgBfWJ1i6JRw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-linux-arm64-gnu": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-linux-arm64-gnu/-/jco-node-fs-linux-arm64-gnu-0.2.0.tgz", + "integrity": "sha512-OOPNHz8T6gJJjuteRMVpgHuNKDADgTafaelqy2RUj5hSqRHV+18Rf+mvHdL5Ci0fqG3DCwCmlQIqIEC6yJwOcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-linux-arm64-musl": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-linux-arm64-musl/-/jco-node-fs-linux-arm64-musl-0.2.0.tgz", + "integrity": "sha512-jPxTJtd5CZkBbSrPLdZMeiAfGgSpI1E5bnJRgEQN7TZ/Ivwt3Ci6cOa+R1szb9owtQ9sqbTFapLJx3iggOEFKQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-linux-x64-gnu": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-linux-x64-gnu/-/jco-node-fs-linux-x64-gnu-0.2.0.tgz", + "integrity": "sha512-IZ6oWJrQc37HOyf0GUJ21g3b1ArHfLEclQQiuTQ23f/Iy4OMVNhWwLV45R6HKQh5Mxqkr4t3k5lGRCcIFhC80g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-linux-x64-musl": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-linux-x64-musl/-/jco-node-fs-linux-x64-musl-0.2.0.tgz", + "integrity": "sha512-ozWEq/79+yp7Anj0qt936DXJUmAS/p/Bf8OJj9Uj5zMOoEnFzyCdvta5u86HbLA8oE4FuU64vf6QjLfRpCvzxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-win32-arm64-msvc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-win32-arm64-msvc/-/jco-node-fs-win32-arm64-msvc-0.2.0.tgz", + "integrity": "sha512-9NxrGFRmCqRav82PJ0gm9y4KMFtCRti9BykufdEA2GH+R5pAjaBPbCFmlZvaLWfLVover38xcOhn/KL7ZN+xpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-node-fs-win32-x64-msvc": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-node-fs-win32-x64-msvc/-/jco-node-fs-win32-x64-msvc-0.2.0.tgz", + "integrity": "sha512-kp1pcPZEvgIeoQX9rCkwqrAhfH6ZE6sEvgFwlW0BagNHVzWcCnA2PEFM7UaeycasF6YPhjuc+wK6PsAe0QR1pg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20" + } + }, + "node_modules/@bytecodealliance/jco-transpile": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/@bytecodealliance/jco-transpile/-/jco-transpile-0.12.1.tgz", + "integrity": "sha512-8bAYnPAkuHf8OVDeXfpDyEwymPPN6KUwzAk//cO7ixT8dFgYN1pKxBJRX4SXIKlfCjEYepJe4BVLGtL9v4hsOw==", + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "dependencies": { + "@bytecodealliance/preview2-shim": "^0.22.0", + "@bytecodealliance/preview3-shim": "^0.5.0", + "binaryen": "^130.0.0", + "oxc-minify": "^0.136.0" + } + }, + "node_modules/@bytecodealliance/preview2-shim": { + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/preview2-shim/-/preview2-shim-0.22.0.tgz", + "integrity": "sha512-xQclicSPicWpEkCJ7f4JiI1JuxZcktmVExh++fDxerPfP+MknUd7Yx3jjk5R9uYhTD9bdefYHw8J0zehXGQL1A==", + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "dependencies": { + "@bytecodealliance/jco-node-fs": "0.2.0" + } + }, + "node_modules/@bytecodealliance/preview3-shim": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/preview3-shim/-/preview3-shim-0.5.0.tgz", + "integrity": "sha512-Tse27SdCca9cNU/GAXyP4cTxjiO681DOCCIrkcPXMu0D0H+VsXk4KtwTMHnbbT4lqmoVsunLZiDUUvyUI0PQBg==", + "dev": true, + "license": "(Apache-2.0 WITH LLVM-exception)", + "dependencies": { + "@bytecodealliance/jco-node-fs": "0.2.0", + "@bytecodealliance/preview2-shim": "^0.22.0" + } + }, + "node_modules/@bytecodealliance/weval": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@bytecodealliance/weval/-/weval-0.4.1.tgz", + "integrity": "sha512-vJegSAkNjENhJcMUod76KUGAgQLdACDDCwB3JwyR14zDhyHVPAvArvtDDYEEi+c+ELzls62H6wxTvzRmaYTaqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@napi-rs/lzma": "^1.1.2", + "decompress": "^4.2.1", + "decompress-tar": "^4.1.1", + "decompress-unzip": "^4.0.1" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/@bytecodealliance/wizer": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/wizer/-/wizer-10.0.0.tgz", + "integrity": "sha512-ziWmovyu1jQl9TsKlfC2bwuUZwxVPFHlX4fOqTzxhgS76jITIo45nzODEwPgU+jjmOr8F3YX2V2wAChC5NKujg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "wizer": "wizer.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@bytecodealliance/wizer-darwin-arm64": "10.0.0", + "@bytecodealliance/wizer-darwin-x64": "10.0.0", + "@bytecodealliance/wizer-linux-arm64": "10.0.0", + "@bytecodealliance/wizer-linux-s390x": "10.0.0", + "@bytecodealliance/wizer-linux-x64": "10.0.0", + "@bytecodealliance/wizer-win32-x64": "10.0.0" + } + }, + "node_modules/@bytecodealliance/wizer-darwin-arm64": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/wizer-darwin-arm64/-/wizer-darwin-arm64-10.0.0.tgz", + "integrity": "sha512-dhZTWel+xccGTKSJtI9A7oM4yyP20FWflsT+AoqkOqkCY7kCNrj4tmMtZ6GXZFRDkrPY5+EnOh62sfShEibAMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "wizer-darwin-arm64": "wizer" + } + }, + "node_modules/@bytecodealliance/wizer-darwin-x64": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/wizer-darwin-x64/-/wizer-darwin-x64-10.0.0.tgz", + "integrity": "sha512-r/LUIZw6Q3Hf4htd46mD+EBxfwjBkxVIrTM1r+B2pTCddoBYQnKVdVsI4UFyy7NoBxzEg8F8BwmTNoSLmFRjpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "bin": { + "wizer-darwin-x64": "wizer" + } + }, + "node_modules/@bytecodealliance/wizer-linux-arm64": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/wizer-linux-arm64/-/wizer-linux-arm64-10.0.0.tgz", + "integrity": "sha512-pGSfFWXzeTqHm6z1PtVaEn+7Fm3QGC8YnHrzBV4sQDVS3N1NwmuHZAc8kslmlFPNdu61ycEvdOsSgCny8JPQvg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "wizer-linux-arm64": "wizer" + } + }, + "node_modules/@bytecodealliance/wizer-linux-s390x": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/wizer-linux-s390x/-/wizer-linux-s390x-10.0.0.tgz", + "integrity": "sha512-O8vHxRTAdb1lUnVXMIMTcp/9q4pq1D4iIKigJCipg2JN15taV9uFAWh0fO88wylXwuSlO7dOE1AwQl54fMKXQg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "wizer-linux-s390x": "wizer" + } + }, + "node_modules/@bytecodealliance/wizer-linux-x64": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/wizer-linux-x64/-/wizer-linux-x64-10.0.0.tgz", + "integrity": "sha512-fJtM1sy43FBMnp+xpapFX6U1YdTBKA/1T4CYfG/qeE8jn0SXk2EuiYoY/EnC2uyNy9hjTrvfdYO5n4MXW0EIdQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "bin": { + "wizer-linux-x64": "wizer" + } + }, + "node_modules/@bytecodealliance/wizer-win32-x64": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@bytecodealliance/wizer-win32-x64/-/wizer-win32-x64-10.0.0.tgz", + "integrity": "sha512-55BPLfGT7iT7gH5M69NpTM16QknJZ7OxJ0z73VOEoeGA9CT8QPKMRzFKsPIvLs+W8G28fdudFA94nElrdkp3Kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "bin": { + "wizer-win32-x64": "wizer" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.2.tgz", + "integrity": "sha512-TC8MkTuZUtcTSiFeuC0ksCh9QIJ5+F21MvZ4Wn4ORfYaFJ/0dsiudv5tVkejgwZlwQ39jL9WWDe2lz8x0WglOA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@napi-rs/lzma": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma/-/lzma-1.5.1.tgz", + "integrity": "sha512-sgOZ89+y8cDbY+3WbzR8CtIhCuFRWotZ9/2PjPVDJHz6np5KFTAev0DrwiyTJTgFsCRDhfGlbmhMgyhHbWdZ6g==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^22.20 || ^24.12 || >=25" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/lzma-android-arm-eabi": "1.5.1", + "@napi-rs/lzma-android-arm64": "1.5.1", + "@napi-rs/lzma-darwin-arm64": "1.5.1", + "@napi-rs/lzma-darwin-x64": "1.5.1", + "@napi-rs/lzma-freebsd-x64": "1.5.1", + "@napi-rs/lzma-linux-arm-gnueabihf": "1.5.1", + "@napi-rs/lzma-linux-arm64-gnu": "1.5.1", + "@napi-rs/lzma-linux-arm64-musl": "1.5.1", + "@napi-rs/lzma-linux-ppc64-gnu": "1.5.1", + "@napi-rs/lzma-linux-riscv64-gnu": "1.5.1", + "@napi-rs/lzma-linux-s390x-gnu": "1.5.1", + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@napi-rs/lzma-linux-x64-musl": "1.5.1", + "@napi-rs/lzma-wasm32-wasi": "1.5.1", + "@napi-rs/lzma-win32-arm64-msvc": "1.5.1", + "@napi-rs/lzma-win32-ia32-msvc": "1.5.1", + "@napi-rs/lzma-win32-x64-msvc": "1.5.1" + } + }, + "node_modules/@napi-rs/lzma-android-arm-eabi": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm-eabi/-/lzma-android-arm-eabi-1.5.1.tgz", + "integrity": "sha512-sahBe4ko2Z69NPTddaX6ZgbQZu9SDoITxw1S3dWl1gAGynZG34qHHCT8UaUMFxf3h3zMhCJjEzz4basaBxiTuQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-android-arm64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-android-arm64/-/lzma-android-arm64-1.5.1.tgz", + "integrity": "sha512-7tkQAJJuBHxAxiEBNFgSTpvrtGpbwZYYJUSOmGEK3OfbdbNeoT2rdBxpM/gY1s+itEVbtOSlpaRPPG19MnwOzA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-darwin-arm64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-arm64/-/lzma-darwin-arm64-1.5.1.tgz", + "integrity": "sha512-XWX8gtF+GHGk3nH3Wm3QUZNcxw9QHsFVZz3MzVLhWWHhceede1J4/vD+3dj3E1iKB9G6mualaZxOoD08R3E+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-darwin-x64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-darwin-x64/-/lzma-darwin-x64-1.5.1.tgz", + "integrity": "sha512-CfsqUpMTI1z8enrA/b+GcHM6YDI8D0kqCiqPYEnst4rbOABQ9KZ92ybTTNnlnZ7A017WoMZKUEWc36KXDwi0xg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-freebsd-x64": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-freebsd-x64/-/lzma-freebsd-x64-1.5.1.tgz", + "integrity": "sha512-bTyNfg90FXIgE61U7l14aMmVOqRQ6AyP5JMT3jmCStaZI18apLNPdzZ8i7yqxZfKvRMVfPjE2brXIw27c+RRgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-arm-gnueabihf": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm-gnueabihf/-/lzma-linux-arm-gnueabihf-1.5.1.tgz", + "integrity": "sha512-vNE+D8nrw+eOkBsdKCsmDhowDV3pIMKXEhedvXfbgrWbrO7GlZJH+RXL+X+RYLxGwi8Ym61ZMt15sIOnNmh9Sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-gnu/-/lzma-linux-arm64-gnu-1.5.1.tgz", + "integrity": "sha512-csUem4WgoKGTprv/pOPm9UIWbb+hrfUwYXefpTHPAEGVFLl5behEFabisJ7FtihCa3yG2Efcl+yw25rlhhrIYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-arm64-musl": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-arm64-musl/-/lzma-linux-arm64-musl-1.5.1.tgz", + "integrity": "sha512-kB/xhlVN1eLvVmDJSKZEjp5Gg2xDYexNrB5jwpSMbOkeGS6N9AasByPBg5VqCpMYC+zZi7DM458DRhtWYhqXTQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-ppc64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-ppc64-gnu/-/lzma-linux-ppc64-gnu-1.5.1.tgz", + "integrity": "sha512-s28RW0W1yBWQc1nbPdF7tp14koqslY3ZWLVI8uaanX292Dc6ezd4NPVwxEoCNBVON/oD7BmUbWGtyFvmm7dQ5A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-riscv64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-riscv64-gnu/-/lzma-linux-riscv64-gnu-1.5.1.tgz", + "integrity": "sha512-+lGNwYlIN14YPMTNvYtIJJqHFevDTd6Juw/1NmXbWx/iRd/LLrjhlM/yluMX6pxs6NkOGsuuEXJJrbbEUS59OQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-s390x-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-s390x-gnu/-/lzma-linux-s390x-gnu-1.5.1.tgz", + "integrity": "sha512-PB44FFWWFrLeQowhcep1hPD1YcLqKlnnY60RMU74qrxTlr4YGEyzeMItJqh2uivBfv9kQScOF/B0J9+Vab/oyw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-musl": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-musl/-/lzma-linux-x64-musl-1.5.1.tgz", + "integrity": "sha512-I3nsYrWtrW9JpeCr+mkJIVDt0HY3m6qVUBs5vTtoIvJQxwqf1PBXSy5IS7T53ksQFH2kd2UX8rLxJ7B4WISpZg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-wasm32-wasi": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-wasm32-wasi/-/lzma-wasm32-wasi-1.5.1.tgz", + "integrity": "sha512-gy3wwPBa6+XEyA4fUzq6CClrXA1ajXjuVf5zbnHytJRgoHznj+mvpU3+co2fxXwqTCmIpn6KrzqH5bRDztBPhA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.2", + "@emnapi/runtime": "1.11.2", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-win32-arm64-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-arm64-msvc/-/lzma-win32-arm64-msvc-1.5.1.tgz", + "integrity": "sha512-dK+huOsHiyH6oJjij+cnjqFCakk2HgWmpI12Xm4pLUyPphe4ebYoJBgehaNAxprmjFqBQ7nL95YPVz9BHyqmPg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-win32-ia32-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-ia32-msvc/-/lzma-win32-ia32-msvc-1.5.1.tgz", + "integrity": "sha512-dGE8L+0EQ+GyU9ap9InqB/t/PmPG/bLj918q7OsJ29FuTdn8fK4OX3U4IQZhylHIA+/dQ/SXJk5n4yfah2XVvA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/lzma-win32-x64-msvc": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-win32-x64-msvc/-/lzma-win32-x64-msvc-1.5.1.tgz", + "integrity": "sha512-EKW4t/iqdCT/xnd5t9oXLvVER/PMNAWXKqUAl3fgvUcOILeZIIht77/dVnfFcc9htA/DCBXC/6YQWdW+LusjFA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.2.3.tgz", + "integrity": "sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=23.5.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1 || ^2.0.0-alpha.4", + "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" + } + }, + "node_modules/@oxc-minify/binding-android-arm-eabi": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm-eabi/-/binding-android-arm-eabi-0.136.0.tgz", + "integrity": "sha512-gk1jzky1vLeOcK7w9Ib2RT/UooZ7O21s8YSeXCsy4tzsmguKb3Cbsv/Ra2fKhMp4mKeKCzvC1Xa41OfSBWfWdQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-android-arm64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm64/-/binding-android-arm64-0.136.0.tgz", + "integrity": "sha512-5GIYanqud/ohXEcg2dsdn0+eSKXqclw5qlRUkvSM1sKEa4ZQ7hqOk8XPJBNPh3LXXw+Qw85B6jkmOFLKsCHp5w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-darwin-arm64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-darwin-arm64/-/binding-darwin-arm64-0.136.0.tgz", + "integrity": "sha512-QAMpHKXWOoMCTenSZjQjVT5YmMooujlCKAf1T3PHFl2LuaA6ukQFXba0bgSLgAxRrAeX/itkwi4lzVQXtYmnRg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-darwin-x64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-darwin-x64/-/binding-darwin-x64-0.136.0.tgz", + "integrity": "sha512-rDnoPj8hztiU8C1VNKuzujh7SgTJlgiUvUCiWQjTnVSLsABsLW9ZWd1l1Ji0VN3UW39wGpptgvahIWTrHnGH/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-freebsd-x64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-freebsd-x64/-/binding-freebsd-x64-0.136.0.tgz", + "integrity": "sha512-9fGSgP7uyPLCTjMx9z3AUV4UjnfOVoGTH5JzamLegPwGLJ97Gr1jht1437CRk8ZNabGmxQR6qNn3e+Tox2lswg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-arm-gnueabihf": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.136.0.tgz", + "integrity": "sha512-k0QvZS+x6ROI4O5Ikqn9dDU2dmkEJ46Yh6baZJI/eUk/a1k1GOs9/vyXItLZrKlwYrSgdEx2BFXbKgyd1NyRPw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-arm-musleabihf": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.136.0.tgz", + "integrity": "sha512-iRFxUc8ubotVfuyq3e4LPGbHrjvWd1/B3499a55GueMLc1EDJ0fHzNXJiCx2eMQGfzHtHodjmvrJ2BSvSXBNhQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-arm64-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.136.0.tgz", + "integrity": "sha512-hbKxNCXJsULrb6EyG5SumzJKuPfP8Y0UQ/TElRgXVUhyyPf6cBPlF8HMq+MAylF98ACBzV8jBp2Mm62lJyTo+w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-arm64-musl": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.136.0.tgz", + "integrity": "sha512-T+u/CGKW0UXemJeVux4ix6rK+ug3bQCUEbu5hFlX9K61KQNd44rOcrM15mDK99L7USaXwmiwHeIkEDvmmyDMSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-ppc64-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.136.0.tgz", + "integrity": "sha512-WNPTN2F448tb49BoqjV9IMRzZWa0WB8GHzrB+bnxKQPOyzz07T6rmDKrr4Hjl81ADGjDYSsr1fgXcjyLQFdMdw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-riscv64-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.136.0.tgz", + "integrity": "sha512-MvcCfu8OeabrmNN9wqhh8Gqizg68RQ3iOvXBJmlLMy5p1PC6az9bAwqOk98lsCmKI4I5LpULekOlfxRYh3o5Kw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-riscv64-musl": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.136.0.tgz", + "integrity": "sha512-rwJXFUFFA0egAULtOOgfW8YhhLhtWrr2qFxftvZ7/U6Xtxiev8i+eCwSNW1RpHiX74QMsJVpRE3ykoefqb1MtQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-s390x-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.136.0.tgz", + "integrity": "sha512-qizAxHmj7VHm5/D5qiIlvzxAS2tlmadDOJ5XFI7zx/wfnk/+fdk5iVaHOv+C4tDE/4wLQyMoBYVIs6R1LeN91w==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-x64-gnu": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.136.0.tgz", + "integrity": "sha512-gcyLAad0UblkEhGjVfx5Mwq63OSx8r04IbcfwzpZYpHisO/vioMsrs4GkDsQZ2Ejc8cdUk6YMIrf9FwNBzh+Dw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-linux-x64-musl": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-linux-x64-musl/-/binding-linux-x64-musl-0.136.0.tgz", + "integrity": "sha512-QAK6kwBjljX6luYzeppVdRP3gTaFNFfp9iS/V0cCvU43wUZay5xFXBgmzBHYrzZV2MhzURNnXfEZRo9poAxyvA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-openharmony-arm64": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-openharmony-arm64/-/binding-openharmony-arm64-0.136.0.tgz", + "integrity": "sha512-nIGxkTOnN9GwslmooxbZ3Ti2RgbX9QLHy0X9uO0pslOT+4dsNVUawBU/6r4M5cWM+ZdRD2tOF4V+n0l8tS59nQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-wasm32-wasi": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-wasm32-wasi/-/binding-wasm32-wasi-0.136.0.tgz", + "integrity": "sha512-YB3G8AHT6PpDS8/DfjlJrQjywj+/6gCkNBcAJdYzcYbQRIZE6SaTNowiGuZPR1m1jOl1WjcjefniKSmVxzhvRA==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.5" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-minify/binding-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@oxc-minify/binding-win32-arm64-msvc": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.136.0.tgz", + "integrity": "sha512-UVRH3BlNc2rmla+sbSd5xTF/u+esCqZS6VSWPolZybcBh3TlVe1Sf5MOuHQWUaFoyv5uFOvee4hOfS/14nPvZw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-win32-ia32-msvc": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.136.0.tgz", + "integrity": "sha512-zFGkUd82jci0E+isc2O36lVmmXVUDXyEMN2RYIyez+MBNqhdjPLIGx62FCsrJtLKeuiPf/8XkFv977aUtndUng==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-minify/binding-win32-x64-msvc": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/@oxc-minify/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.136.0.tgz", + "integrity": "sha512-VHgPwd5xibC9UYxBeq7nURTjY+wxhdumllij6JaCIUVfD9IRcrA66SVlJ5fPNoUXcRp8ZXhJfoIZwVz9wAEGJw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxc-parser/binding-android-arm64": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.76.0.tgz", + "integrity": "sha512-1XJW/16CDmF5bHE7LAyPPmEEVnxSadDgdJz+xiLqBrmC4lfAeuAfRw3HlOygcPGr+AJsbD4Z5sFJMkwjbSZlQg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-arm64": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.76.0.tgz", + "integrity": "sha512-yoQwSom8xsB+JdGsPUU0xxmxLKiF2kdlrK7I56WtGKZilixuBf/TmOwNYJYLRWkBoW5l2/pDZOhBm2luwmLiLw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-darwin-x64": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.76.0.tgz", + "integrity": "sha512-uRIopPLvr3pf2Xj7f5LKyCuqzIU6zOS+zEIR8UDYhcgJyZHnvBkfrYnfcztyIcrGdQehrFUi3uplmI09E7RdiQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-freebsd-x64": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.76.0.tgz", + "integrity": "sha512-a0EOFvnOd2FqmDSvH6uWLROSlU6KV/JDKbsYDA/zRLyKcG6HCsmFnPsp8iV7/xr9WMbNgyJi6R5IMpePQlUq7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.76.0.tgz", + "integrity": "sha512-ikRYDHL3fOdZwfJKmcdqjlLgkeNZ3Ez0qM8wAev5zlHZ+lY/Ig7qG5SCqPlvuTu+nNQ6zrFFaKvvt69EBKXU/g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm-musleabihf": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.76.0.tgz", + "integrity": "sha512-dtRv5J5MRCLR7x39K8ufIIW4svIc7gYFUaI0YFXmmeOBhK/K2t/CkguPnDroKtsmXIPHDRtmJ1JJYzNcgJl6Wg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-gnu": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.76.0.tgz", + "integrity": "sha512-IE4iiiggFH2snagQxHrY5bv6dDpRMMat+vdlMN/ibonA65eOmRLp8VLTXnDiNrcla/itJ1L9qGABHNKU+SnE8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-linux-arm64-musl": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.76.0.tgz", + "integrity": "sha512-wi9zQPMDHrBuRuT7Iurfidc9qlZh7cKa5vfYzOWNBCaqJdgxmNOFzvYen02wVUxSWGKhpiPHxrPX0jdRyJ8Npg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-linux-riscv64-gnu": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.76.0.tgz", + "integrity": "sha512-0tqqu1pqPee2lLGY8vtYlX1L415fFn89e0a3yp4q5N9f03j1rRs0R31qesTm3bt/UK8HYjECZ+56FCVPs2MEMQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-linux-s390x-gnu": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.76.0.tgz", + "integrity": "sha512-y36Hh1a5TA+oIGtlc8lT7N9vdHXBlhBetQJW0p457KbiVQ7jF7AZkaPWhESkjHWAsTVKD2OjCa9ZqfaqhSI0FQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-gnu": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.76.0.tgz", + "integrity": "sha512-7/acaG9htovp3gp/J0kHgbItQTuHctl+rbqPPqZ9DRBYTz8iV8kv3QN8t8Or8i/hOmOjfZp9McDoSU1duoR4/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-linux-x64-musl": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.76.0.tgz", + "integrity": "sha512-AxFt0reY6Q2rfudABmMTFGR8tFFr58NlH2rRBQgcj+F+iEwgJ+jMwAPhXd2y1I2zaI8GspuahedUYQinqxWqjA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.76.0.tgz", + "integrity": "sha512-wHdkHdhf6AWBoO8vs5cpoR6zEFY1rB+fXWtq6j/xb9j/lu1evlujRVMkh8IM/M/pOUIrNkna3nzST/mRImiveQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@oxc-parser/binding-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@oxc-parser/binding-win32-arm64-msvc": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.76.0.tgz", + "integrity": "sha512-G7ZlEWcb2hNwCK3qalzqJoyB6HaTigQ/GEa7CU8sAJ/WwMdG/NnPqiC9IqpEAEy1ARSo4XMALfKbKNuqbSs5mg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-parser/binding-win32-x64-msvc": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.76.0.tgz", + "integrity": "sha512-0jLzzmnu8/mqNhKBnNS2lFUbPEzRdj5ReiZwHGHpjma0+ullmmwP2AqSEqx3ssHDK9CpcEMdKOK2LsbCfhHKIA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.76.0.tgz", + "integrity": "sha512-CH3THIrSViKal8yV/Wh3FK0pFhp40nzW1MUDCik9fNuid2D/7JJXKJnfFOAvMxInGXDlvmgT6ACAzrl47TqzkQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.6.tgz", + "integrity": "sha512-b+jTcARdTiFLI6jB4a5XjTm0RWd6KcRfQj/I2356fxUZemiho9zQLxo0RtCuMDAyKcLo6cEltkgbQp6d1+sjjQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.6.tgz", + "integrity": "sha512-lkWU8ZJaRk9q3CIEY1Tc7vIFALp3Xw5NfGJo2hQg5oIqNgxWi1zI+IiDEK3r70BF5Dzol1tcXsnzsRc8NLhG+Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.6.tgz", + "integrity": "sha512-dgR56NYnvAszm7Ob1B2/Vn0e8bUQYZH2UjVaMMtMVOCKFSfjhfLmuA/9+O+F+ajUdG6B/bSssrKW6JJYASa8jA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.6.tgz", + "integrity": "sha512-vpVxFvUCFioJqug7OTvqptkc4yb8UX0AwfDmJpaR/0sWz+BUmqSVAf7c8JkUgnN8YLspb4a/N6NhTyMAmdyQ7Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.6.tgz", + "integrity": "sha512-h1wG6Y6K3JlRswxsI64qQJqBAy4vrLuHgRbc8CZMGSWTOFRY6ghMApM1NKzB2I0n5xV1fjkE18SuVl2QpLeNpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.6.tgz", + "integrity": "sha512-tbCiqub0q2MVWJKgF5PoAlNWCtQydiOYSLIkd8sByqK/6MMYLJRcSXSYodqYtd0O+Fw7QaVmKKlS4oL94YRZ0w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.6.tgz", + "integrity": "sha512-oxK9+baEBPhZG5HB4URY+uU04zJWeZlH6Tb9rB5DK4DF9XR1uXNLXt5Q5ZsugTKayNCNLhkcwz/ye74hRI98dg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.6.tgz", + "integrity": "sha512-muWCk27FVBEZtv0MsK8gnfSmgczA8KQ0uRVJbTABKhkRfQc38aUrcb7fhi3BNiyseFmgcRsoMfQsSNJ+DbZdSw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.6.tgz", + "integrity": "sha512-eWDoSfU7Co2qj3vgB3Dt4lj1mG6CoWbcJQkRMP3XJplyCMtuaq3LHvPFjS9QIPvMGWVadJC04Xiy0IdcVPtnwQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.6.tgz", + "integrity": "sha512-2bWNjRSIayvupRKxXUY2tWG9fYdoUlTqWywHRvE8Eq3GvuQ+f2HeIkve697fIt+IQs/PV8yFsdWuhp1aJ1PdnA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.6.tgz", + "integrity": "sha512-KekI0gS0wLxe1UBSQSjenBVwou/JkcQPDzBPICGZjxUv9k3RteHDPBQaiOicZUFKRIH2wKEimGwVpnJsbPzu7w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.6.tgz", + "integrity": "sha512-TvtPnfVr+HtyGiDmPK4VWmlNm7QhNNAcK5Q9A7aOXsI8545yCyaoMaicXrFZ72JzeYjaUVk7yT243zT0jzjFKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.6.tgz", + "integrity": "sha512-iOo0VEay2XFhaCcH0sps5XIimkSuOnNaZrf6+ZkoSOQBJPKNU48RkmJv0/lSpipexu5P+ouFgafe5IGr/DiQfg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.6.tgz", + "integrity": "sha512-y5NTmmasMS455JlOCO4ZM9krIchv3Mvm1crL1iUPGOPgEzSkves9n0SdC5Sjz6+qWDFhd8/JpfWMH8NSWNHe+A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.6.tgz", + "integrity": "sha512-np8iZSLfXlAD4kWhiyq/u0Yt8oZDtRQ8lGhQaCXo2rl37KNjeU0GjJuwr4P3oeZ++ROfofsKNBqR5LTO8aXyWQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/binaryen": { + "version": "130.0.0", + "resolved": "https://registry.npmjs.org/binaryen/-/binaryen-130.0.0.tgz", + "integrity": "sha512-XDrb+zql0RbFPKgj7MuH9zOc78R3Fa/P/VSGnnpdwYsvNZPWjcMYMdAkKCOQEL2A7yqgjSMTDRFp6gfSDW+/QQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "wasm-as": "bin/wasm-as", + "wasm-ctor-eval": "bin/wasm-ctor-eval", + "wasm-dis": "bin/wasm-dis", + "wasm-merge": "bin/wasm-merge", + "wasm-metadce": "bin/wasm-metadce", + "wasm-opt": "bin/wasm-opt", + "wasm-reduce": "bin/wasm-reduce", + "wasm-shell": "bin/wasm-shell", + "wasm2js": "bin/wasm2js" + } + }, + "node_modules/bl": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bl/-/bl-1.2.3.tgz", + "integrity": "sha512-pvcNpa0UU69UT341rO6AYy4FVAIkUHuZXRIWbq+zHnsVcRzDDjIAhGuuYoi0d//cwIwtt4pkpKycWEfjdV+vww==", + "dev": true, + "license": "MIT", + "dependencies": { + "readable-stream": "^2.3.5", + "safe-buffer": "^5.1.1" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-alloc": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/buffer-alloc/-/buffer-alloc-1.2.0.tgz", + "integrity": "sha512-CFsHQgjtW1UChdXgbyJGtnm+O/uLQeZdtbDo8mfUgYXCHSM1wgrVxXm6bSyrUuErEb+4sYVGCzASBRot7zyrow==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-alloc-unsafe": "^1.1.0", + "buffer-fill": "^1.0.0" + } + }, + "node_modules/buffer-alloc-unsafe": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/buffer-alloc-unsafe/-/buffer-alloc-unsafe-1.1.0.tgz", + "integrity": "sha512-TEM2iMIEQdJ2yjPJoSIsldnleVaAk1oW3DBVUykyOLsEsFmEc9kn+SFFPz+gl54KQNxlDnAwCXosOS9Okx2xAg==", + "dev": true, + "license": "MIT" + }, + "node_modules/buffer-crc32": { + "version": "0.2.13", + "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", + "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/buffer-fill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/buffer-fill/-/buffer-fill-1.0.0.tgz", + "integrity": "sha512-T7zexNBwiiaCOGDg9xNX9PBmjrubblRkENuptryuI64URkXDFum9il/JGL8Lm8wYfAXpredVXXZz7eMHilimiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/componentize-qjs": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/componentize-qjs/-/componentize-qjs-0.4.3.tgz", + "integrity": "sha512-fiOm2Yu33XIDxY9zkzpTfR+gx3kXsmH9UPWPxuR484bVZGMfq12/Yh5HDHkEvpqZwZt2FDgo/Cj7VUIDX0LJEw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "componentize-qjs": "bin/componentize-qjs" + }, + "engines": { + "node": ">=18.0.0" + }, + "optionalDependencies": { + "@andreiltd/componentize-qjs-binding-darwin-arm64": "0.4.3", + "@andreiltd/componentize-qjs-binding-darwin-x64": "0.4.3", + "@andreiltd/componentize-qjs-binding-linux-arm64-gnu": "0.4.3", + "@andreiltd/componentize-qjs-binding-linux-arm64-musl": "0.4.3", + "@andreiltd/componentize-qjs-binding-linux-x64-gnu": "0.4.3", + "@andreiltd/componentize-qjs-binding-linux-x64-musl": "0.4.3", + "@andreiltd/componentize-qjs-binding-win32-arm64-msvc": "0.4.3", + "@andreiltd/componentize-qjs-binding-win32-x64-msvc": "0.4.3" + } + }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/decompress": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/decompress/-/decompress-4.2.1.tgz", + "integrity": "sha512-e48kc2IjU+2Zw8cTb6VZcJQ3lgVbS4uuB1TfCHbiZIP/haNXm+SVyhu+87jts5/3ROpd82GSVCoNs/z8l4ZOaQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.0.0", + "decompress-tarbz2": "^4.0.0", + "decompress-targz": "^4.0.0", + "decompress-unzip": "^4.0.1", + "graceful-fs": "^4.1.10", + "make-dir": "^1.0.0", + "pify": "^2.3.0", + "strip-dirs": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tar": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tar/-/decompress-tar-4.1.1.tgz", + "integrity": "sha512-JdJMaCrGpB5fESVyxwpCx4Jdj2AagLmv3y58Qy4GE6HMVjWz1FeVQk1Ct4Kye7PftcdOo/7U7UKzYBJgqnGeUQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^5.2.0", + "is-stream": "^1.1.0", + "tar-stream": "^1.5.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-tarbz2/-/decompress-tarbz2-4.1.1.tgz", + "integrity": "sha512-s88xLzf1r81ICXLAVQVzaN6ZmX4A6U4z2nMbOwobxkLoIIfjVMBg7TeguTUXkKeXni795B6y5rnvDw7rxhAq9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.1.0", + "file-type": "^6.1.0", + "is-stream": "^1.1.0", + "seek-bzip": "^1.0.5", + "unbzip2-stream": "^1.0.9" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-tarbz2/node_modules/file-type": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-6.2.0.tgz", + "integrity": "sha512-YPcTBDV+2Tm0VqjybVd32MHdlEGAtuxS3VAYsumFokDSMG+ROT5wawGlnHDoz7bfMcMDt9hxuXvXwoKUx2fkOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-targz": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/decompress-targz/-/decompress-targz-4.1.1.tgz", + "integrity": "sha512-4z81Znfr6chWnRDNfFNqLwPvm4db3WuZkqV+UgXQzSngG3CEKdBkw5jrv3axjjL96glyiiKjsxJG3X6WBZwX3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "decompress-tar": "^4.1.1", + "file-type": "^5.2.0", + "is-stream": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-unzip": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/decompress-unzip/-/decompress-unzip-4.0.1.tgz", + "integrity": "sha512-1fqeluvxgnn86MOh66u8FjbtJpAFv5wgCT9Iw8rcBqQcCo5tO8eiJw7NNTrvt9n4CRBVq7CstiS922oPgyGLrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "file-type": "^3.8.0", + "get-stream": "^2.2.0", + "pify": "^2.3.0", + "yauzl": "^2.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/decompress-unzip/node_modules/file-type": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-3.9.0.tgz", + "integrity": "sha512-RLoqTXE8/vPmMuTI88DAzhMYC99I8BWv7zYP4A1puo5HIjEJ5EX48ighy4ZyKMG9EDXxBgW6e++cn7d1xuFghA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/fd-slicer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", + "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "pend": "~1.2.0" + } + }, + "node_modules/file-type": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/file-type/-/file-type-5.2.0.tgz", + "integrity": "sha512-Iq1nJ6D2+yIO4c8HHg4fyVb8mAJieo1Oloy1mLLaB2PvezNedhBVm+QU7g0qM42aiMbRXTxKKwGD17rjKNJYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "dev": true, + "license": "MIT" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-stream": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-2.3.1.tgz", + "integrity": "sha512-AUGhbbemXxrZJRD5cDvKtQxLuYaIbNtDTK8YqupCI393Q2KSTreEsLUN3ZxAWFGiKTzL6nKuzfcIvieflUX9qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "object-assign": "^4.0.1", + "pinkie-promise": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "dev": true, + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-natural-number": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/is-natural-number/-/is-natural-number-4.0.1.tgz", + "integrity": "sha512-Y4LTamMe0DDQIIAlaer9eKebAlDSV6huy+TWhJVPlzZh2o4tRP5SQWFlLn5N0To4mDD22/qdOq+veo1cSISLgQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^3.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/make-dir/node_modules/pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha512-C3FsVNH1udSEX48gGX1xfvwTWfsYWj5U+8/uK15BGzIGrKoUpghX8hWZwa/OFnakBiiVNmBvemTJR5mcy7iPcg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mkdirp": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "dev": true, + "license": "MIT", + "bin": { + "mkdirp": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/oxc-minify": { + "version": "0.136.0", + "resolved": "https://registry.npmjs.org/oxc-minify/-/oxc-minify-0.136.0.tgz", + "integrity": "sha512-u9UvPFTYVwQ/i1kpp/c20S/SbvLaLV0Ngzyhgi0YV4B1SlxM7RgnnePl4vY9N3Z9sTRcozL1MRStI3PlUN9RFA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-minify/binding-android-arm-eabi": "0.136.0", + "@oxc-minify/binding-android-arm64": "0.136.0", + "@oxc-minify/binding-darwin-arm64": "0.136.0", + "@oxc-minify/binding-darwin-x64": "0.136.0", + "@oxc-minify/binding-freebsd-x64": "0.136.0", + "@oxc-minify/binding-linux-arm-gnueabihf": "0.136.0", + "@oxc-minify/binding-linux-arm-musleabihf": "0.136.0", + "@oxc-minify/binding-linux-arm64-gnu": "0.136.0", + "@oxc-minify/binding-linux-arm64-musl": "0.136.0", + "@oxc-minify/binding-linux-ppc64-gnu": "0.136.0", + "@oxc-minify/binding-linux-riscv64-gnu": "0.136.0", + "@oxc-minify/binding-linux-riscv64-musl": "0.136.0", + "@oxc-minify/binding-linux-s390x-gnu": "0.136.0", + "@oxc-minify/binding-linux-x64-gnu": "0.136.0", + "@oxc-minify/binding-linux-x64-musl": "0.136.0", + "@oxc-minify/binding-openharmony-arm64": "0.136.0", + "@oxc-minify/binding-wasm32-wasi": "0.136.0", + "@oxc-minify/binding-win32-arm64-msvc": "0.136.0", + "@oxc-minify/binding-win32-ia32-msvc": "0.136.0", + "@oxc-minify/binding-win32-x64-msvc": "0.136.0" + } + }, + "node_modules/oxc-parser": { + "version": "0.76.0", + "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.76.0.tgz", + "integrity": "sha512-l98B2e9evuhES7zN99rb1QGhbzx25829TJFaKi2j0ib3/K/G5z1FdGYz6HZkrU3U8jdH7v2FC8mX1j2l9JrOUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "^0.76.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxc-parser/binding-android-arm64": "0.76.0", + "@oxc-parser/binding-darwin-arm64": "0.76.0", + "@oxc-parser/binding-darwin-x64": "0.76.0", + "@oxc-parser/binding-freebsd-x64": "0.76.0", + "@oxc-parser/binding-linux-arm-gnueabihf": "0.76.0", + "@oxc-parser/binding-linux-arm-musleabihf": "0.76.0", + "@oxc-parser/binding-linux-arm64-gnu": "0.76.0", + "@oxc-parser/binding-linux-arm64-musl": "0.76.0", + "@oxc-parser/binding-linux-riscv64-gnu": "0.76.0", + "@oxc-parser/binding-linux-s390x-gnu": "0.76.0", + "@oxc-parser/binding-linux-x64-gnu": "0.76.0", + "@oxc-parser/binding-linux-x64-musl": "0.76.0", + "@oxc-parser/binding-wasm32-wasi": "0.76.0", + "@oxc-parser/binding-win32-arm64-msvc": "0.76.0", + "@oxc-parser/binding-win32-x64-msvc": "0.76.0" + } + }, + "node_modules/pend": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", + "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", + "dev": true, + "license": "MIT" + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "pinkie": "^2.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "dev": true, + "license": "MIT" + }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "dev": true, + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/rolldown": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.6.tgz", + "integrity": "sha512-vMM4q3aixf46GiF1Kok8jDPFsEpXgFWGjUHXNkNHNm+Y2adXAG2dbX91jkti3i0ZRsOlcmbuzAz1poObSHCmUA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.147.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.6", + "@rolldown/binding-android-arm64": "1.2.6", + "@rolldown/binding-darwin-arm64": "1.2.6", + "@rolldown/binding-darwin-x64": "1.2.6", + "@rolldown/binding-freebsd-x64": "1.2.6", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.6", + "@rolldown/binding-linux-arm64-gnu": "1.2.6", + "@rolldown/binding-linux-arm64-musl": "1.2.6", + "@rolldown/binding-linux-ppc64-gnu": "1.2.6", + "@rolldown/binding-linux-s390x-gnu": "1.2.6", + "@rolldown/binding-linux-x64-gnu": "1.2.6", + "@rolldown/binding-linux-x64-musl": "1.2.6", + "@rolldown/binding-openharmony-arm64": "1.2.6", + "@rolldown/binding-win32-arm64-msvc": "1.2.6", + "@rolldown/binding-win32-x64-msvc": "1.2.6" + } + }, + "node_modules/rolldown/node_modules/@oxc-project/types": { + "version": "0.147.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.147.0.tgz", + "integrity": "sha512-IJ3s6ltHLp45S0bh7phkX+gJO7A1Wuz2EaqpAhb8WjqDwbzMiWKHhyyT42tskaWjEYXtHtVCPpnBJVT9+dcRLg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/seek-bzip": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/seek-bzip/-/seek-bzip-1.0.6.tgz", + "integrity": "sha512-e1QtP3YL5tWww8uKaOCQ18UxIT2laNBXHjV/S2WYCiK4udiv8lkG89KRIoCjUagnAmCBurjF4zEVX2ByBbnCjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^2.8.1" + }, + "bin": { + "seek-bunzip": "bin/seek-bunzip", + "seek-table": "bin/seek-bzip-table" + } + }, + "node_modules/seek-bzip/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-dirs": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/strip-dirs/-/strip-dirs-2.1.0.tgz", + "integrity": "sha512-JOCxOeKLm2CAS73y/U4ZeZPTkE+gNVCzKt7Eox84Iej1LT/2pTWYpZKJuxwQpvX1LiZb1xokNR7RLfuBAa7T3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-natural-number": "^4.0.1" + } + }, + "node_modules/tar-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-1.6.2.tgz", + "integrity": "sha512-rzS0heiNf8Xn7/mpdSVVSMAWAoy9bfb1WOTYC78Z0UQKeKa/CWS8FOq0lKGNa8DWKAn9gxjCvMLYc5PGXYlK2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^1.0.0", + "buffer-alloc": "^1.2.0", + "end-of-stream": "^1.0.0", + "fs-constants": "^1.0.0", + "readable-stream": "^2.3.0", + "to-buffer": "^1.1.1", + "xtend": "^4.0.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/through": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==", + "dev": true, + "license": "MIT" + }, + "node_modules/to-buffer": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", + "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "isarray": "^2.0.5", + "safe-buffer": "^5.2.1", + "typed-array-buffer": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/to-buffer/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "dev": true, + "license": "0BSD", + "optional": true + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/typescript-compiler-api": { + "name": "typescript", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/unbzip2-stream": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", + "integrity": "sha512-mlExGW4w71ebDJviH16lQLtZS32VKqsSfk80GCfUlwT/4/hNRFsoscrF/c++9xinkMzECL1uL9DDwXqFWkruPg==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.2.1", + "through": "^2.3.8" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/which-typed-array": { + "version": "1.1.22", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.22.tgz", + "integrity": "sha512-fvO4ExWMFsqyhG3AiPAObMuY1lxaqgYcxbc49CNdWDDECOJNgQyvsOWVwbZc+qf3rzRtxojBK+CMEv0Ld5CYpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "for-each": "^0.3.5", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yauzl": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", + "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-crc32": "~0.2.3", + "fd-slicer": "~1.1.0" + } + } + } +} diff --git a/wasi-e2e/package.json b/wasi-e2e/package.json new file mode 100644 index 00000000..2dc38e92 --- /dev/null +++ b/wasi-e2e/package.json @@ -0,0 +1,18 @@ +{ + "name": "peri-wasi-e2e", + "version": "0.0.0", + "private": true, + "type": "module", + "packageManager": "npm@10.9.3", + "engines": { + "node": ">=22.20.0 <23" + }, + "scripts": { + "acquire:cargo": "node acquire-cargo.mjs", + "test": "node --test --test-concurrency=1" + }, + "devDependencies": { + "@bytecodealliance/jco": "1.32.1", + "@bytecodealliance/preview2-shim": "0.22.0" + } +} diff --git a/wasi-e2e/wasi-p2.test.mjs b/wasi-e2e/wasi-p2.test.mjs new file mode 100644 index 00000000..a7bb10b8 --- /dev/null +++ b/wasi-e2e/wasi-p2.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, realpath, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { after, before, test } from 'node:test'; + +import { + ToolExecutionError, + ACQUISITION_METADATA, + assertComponentInterface, + assertSanitizedToolEnvironments, + assertStableRust, + buildComponent, + captureAndValidateCargoTree, + cleanOwnedArtifacts, + extractComponentWit, + resolvePinnedPackages, + runExercise, + runJco, + transpileComponent, + validateComponentHeader, + validateGeneratedOutput, + validateHostToolchain, + validateFingerprintMetadata, + validateSafeTreeSync, + COMPONENT_PATH, +} from './harness.mjs'; + +const pinned = resolvePinnedPackages(); +let fixtureDirectory; +let fixtures; + +before(async () => { + await cleanOwnedArtifacts(); + fixtureDirectory = await mkdtemp(path.join(tmpdir(), 'peri-wasi-negative-')); + fixtures = { + missing: path.join(fixtureDirectory, 'missing.wasm'), + empty: path.join(fixtureDirectory, 'empty.wasm'), + core: path.join(fixtureDirectory, 'core.wasm'), + corrupt: path.join(fixtureDirectory, 'corrupt-component.wasm'), + }; + await writeFile(fixtures.empty, Buffer.alloc(0)); + await writeFile(fixtures.core, Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00])); + await writeFile( + fixtures.corrupt, + Buffer.from([0x00, 0x61, 0x73, 0x6d, 0x0d, 0x00, 0x01, 0x00, 0xff]), + ); +}); + +after(async () => { + if (fixtureDirectory) { + await rm(fixtureDirectory, { force: true, recursive: true }); + } +}); + +test('rejects a missing Component before invoking Jco', async () => { + await assert.rejects(validateComponentHeader(fixtures.missing), /Component does not exist/u); +}); + +test('rejects an empty Component before invoking Jco', async () => { + await assert.rejects(validateComponentHeader(fixtures.empty), /Component is empty/u); +}); + +test('rejects a core Wasm module where a Component is required', async () => { + await assert.rejects(validateComponentHeader(fixtures.core), /received a core Wasm module/u); +}); + +test('rejects a corrupt Component through the pinned Jco parser', async () => { + await assert.rejects( + extractComponentWit(fixtures.corrupt, pinned.jcoEntry), + (error) => error instanceof ToolExecutionError && error.status !== 0 && error.signal === null, + ); +}); + +test('reports a nonzero pinned Jco subprocess execution', () => { + assert.throws( + () => runJco(pinned.jcoEntry, ['wit', fixtures.missing]), + (error) => error instanceof ToolExecutionError && error.status !== 0 && error.stderr.length > 0, + ); +}); + +test('rejects tampered Cargo acquisition fingerprint metadata', async () => { + const metadataCopy = path.join(fixtureDirectory, 'tampered-metadata.json'); + const metadata = JSON.parse(await readFile(ACQUISITION_METADATA, 'utf8')); + metadata.fingerprint = '0'.repeat(64); + await writeFile(metadataCopy, `${JSON.stringify(metadata, null, 2)}\n`); + assert.throws( + () => validateFingerprintMetadata(metadataCopy), + (error) => error instanceof assert.AssertionError, + ); +}); + +test('rejects a symlink anywhere in a consumed vendor tree', async (context) => { + const trustedRoot = path.join(fixtureDirectory, 'vendor-symlink-root'); + const vendor = path.join(trustedRoot, 'vendor'); + const outside = path.join(fixtureDirectory, 'outside-vendor-file'); + await mkdir(vendor, { recursive: true }); + await writeFile(outside, 'outside'); + try { + await symlink(outside, path.join(vendor, 'redirect')); + } catch (error) { + if (error?.code === 'EPERM' || error?.code === 'EACCES') { + context.skip(`symlinks unavailable: ${error.code}`); + return; + } + throw error; + } + const canonicalRoot = await realpath(trustedRoot); + assert.throws( + () => validateSafeTreeSync(path.join(canonicalRoot, 'vendor'), canonicalRoot), + /symlink rejected/u, + ); +}); + +test('builds, inspects, transpiles, and executes the WASI P2 Component', { timeout: 180_000 }, async () => { + await cleanOwnedArtifacts(); + assertSanitizedToolEnvironments(); + const tools = validateHostToolchain(); + assertStableRust(); + await buildComponent(); + captureAndValidateCargoTree(); + await validateComponentHeader(COMPONENT_PATH); + const wit = await extractComponentWit(COMPONENT_PATH, tools.jcoEntry); + assertComponentInterface(wit); + await transpileComponent(tools.jcoEntry); + await validateGeneratedOutput(); + await runExercise(); +});