diff --git a/README.md b/README.md index 735debd..5250f38 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,7 @@ The MiniMax Code Agent communicates through the host MCP client. Business reques | [蹬王之王 · Expiry Guardian](plugins/hylouis233/expiry-guardian/) | Track credit and reset-card expiry from a local ledger you maintain, and get a "spend the card or let the credits burn first" recommendation with the reasoning spelled out | [Hylouis](https://github.com/Hylouis233) | | [MiniMax Pet](plugins/minimax-ai/minimax-pet/) | A lightweight companion with appearance, task and settings tabs, visible-session activity, answer-needed reminders and a transparent desktop pet. Requires MiniMax Code 3.1.6 or later | [MiniMax](https://github.com/MiniMax-AI) | | [MCP OAuth Console](plugins/microbiosis/mcp-oauth-console/) | Sign in to MCP servers that require OAuth: device-code or authorization-code + PKCE flow, the `Authorization` header written into the MCP configuration, and an opt-in refresh before expiry | [Microbiosis](https://github.com/Microbiosis) | +| [MiniMax Bot Bridge](plugins/nattiini45/minimax-bot-bridge/) | Pair a phone with the MiniMax Bot team on your desktop: a pairing code, the paired phones, and the relay configuration, with the team's conversations and tool activity carried over a relay you operate. Requires MiniMax Code 3.1.5 or later and the official MiniMax Bot plugin | [nattiini45](https://github.com/nattiini45) |
Preview: Cutline diff --git a/README.zh-CN.md b/README.zh-CN.md index 6ecb50a..e819992 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -86,6 +86,7 @@ MiniMax Code Agent 通过宿主 MCP 客户端与 MiniApp 协作。业务请求 | [蹬王之王](plugins/hylouis233/expiry-guardian/README.md) | 看你自己维护的本地账本,算出「该用卡还是该留积分」,并把理由说清楚 | [Hylouis](https://github.com/Hylouis233) | | [MiniMax Pet](plugins/minimax-ai/minimax-pet/README.zh-CN.md) | 提供外观/任务/设置、可见会话状态、需要回答提醒和透明桌宠;需要 MiniMax Code 3.1.6 或更高版本 | [MiniMax](https://github.com/MiniMax-AI) | | [MCP OAuth 令牌台](plugins/microbiosis/mcp-oauth-console/README.zh-CN.md) | 给需要 OAuth 的 MCP 服务器完成登录:设备码或授权码 + PKCE 流程,把 `Authorization` 头写入 MCP 配置,并可选择在到期前自动续期 | [Microbiosis](https://github.com/Microbiosis) | +| [MiniMax Bot Bridge](plugins/nattiini45/minimax-bot-bridge/README.zh-CN.md) | 把手机和桌面上的 MiniMax Bot 团队配对:申请配对码、查看并撤销已配对的手机,并通过你自己运营的中继转发团队对话与工具活动。需要 MiniMax Code 3.1.5 或更高版本,以及官方 MiniMax Bot 插件 | [nattiini45](https://github.com/nattiini45) |
预览:Cutline diff --git a/plugins/nattiini45/minimax-bot-bridge/.minimax-plugin/plugin.json b/plugins/nattiini45/minimax-bot-bridge/.minimax-plugin/plugin.json new file mode 100644 index 0000000..04af125 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/.minimax-plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "schemaVersion": 1, + "name": "minimax-bot-bridge", + "displayName": "MiniMax Bot Bridge", + "version": "0.1.0", + "description": "Pairs your phone with the MiniMax Bot team on your desktop: a pairing code, the paired phones, and the relay configuration, with the team's conversations and tool activity carried over a relay you operate.", + "author": "nattiini45", + "icon": "icon.png", + "darkIcon": "icon-dark.png", + "category": "Productivity", + "exampleQueries": [ + "Open MiniMax Bot Bridge and pair my phone", + "Show the pairing code for my phone", + "Which phones are linked to this desktop?" + ], + "apps": [], + "mcpServers": [], + "skills": [] +} diff --git a/plugins/nattiini45/minimax-bot-bridge/LICENSE b/plugins/nattiini45/minimax-bot-bridge/LICENSE new file mode 100644 index 0000000..8a4b845 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 nattiini45 and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/nattiini45/minimax-bot-bridge/LICENSE-MINIMAX.txt b/plugins/nattiini45/minimax-bot-bridge/LICENSE-MINIMAX.txt new file mode 100644 index 0000000..132dd20 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/LICENSE-MINIMAX.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 MiniMax + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/nattiini45/minimax-bot-bridge/README.md b/plugins/nattiini45/minimax-bot-bridge/README.md new file mode 100644 index 0000000..be966b6 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/README.md @@ -0,0 +1,121 @@ +# MiniMax Bot Bridge + +English | [简体中文](README.zh-CN.md) + +Pair your phone with the **MiniMax Bot** team on your desktop. The bridge is the Mini App that +runs on the desktop: it shows a pairing code, keeps the list of paired phones, and carries your +team's conversations and tool activity over a relay you control to the mobile client. + +This is an independent project, not an official MiniMax application. It does not modify or +reproduce the official MiniMax Bot plugin — it calls the team tools the Host already exposes and +leaves the desktop in charge. + +## What it does + +- **Pairing surface.** Request a single-use six-digit code, read it on the desktop, and list or + revoke the phones paired with this desktop. +- **Relay configuration.** Set the relay address, the account id, the bridge id, the bridge + credential and the display name. The bridge reconnects when the configuration changes, and it + refuses a relay it may not talk to. +- **Team access.** Read the team's profiles, group membership and conversation history through the + `minimax-bot` tools the Host exposes, and send messages as a Bot. +- **Hosted turns.** A Bot turn can run in a session the bridge owns instead of the team's own + conversation, with streaming output and the desktop's permission prompts. + +The phone is a separate app: [MiniMax Bot Mobile](https://github.com/nattiini45/minimax-bot-mobile), +an Expo / React Native client for Android, iOS and the web. The bridge is useless without it, +and it is useful without nothing else: the relay is the only third piece. + +## Requirements + +- MiniMax Code 3.1.5 or later — see [Tested environment](#tested-environment). +- The official **MiniMax Bot** plugin on the same desktop. The bridge calls that plugin's tools + through the Host's tool API. Without it the bridge still starts, pairs and reports its + diagnostics, but it cannot show or drive your team. +- A relay you operate, and a bridge credential minted for it. See + [Pairing and credentials](docs/PAIRING.md) for the wire flow, and the mobile app's repository + for the relay Worker and the minting command. + +## Install + +Copy the entire plugin directory, including the hidden `.minimax-plugin` directory, into the +client's `plugins` directory: + +```text +Repository: plugins/nattiini45/minimax-bot-bridge/ +Install to: /plugins/minimax-bot-bridge/ +Manifest: /plugins/minimax-bot-bridge/.minimax-plugin/plugin.json +``` + +`dataDir` is the `.minimax` directory in your home folder (`~/.minimax`) by default. The author +directory (`nattiini45/`) only groups the contribution in the registry; the installed plugin is +`minimax-bot-bridge`. Restart MiniMax Code, confirm the plugin is recognised and enabled, and ask +the Agent to open "MiniMax Bot Bridge". + +To update, close the app and exit MiniMax Code, then replace the complete plugin directory. Keep +a backup outside `plugins/`. The bridge's own state lives in the Host's data directory, not in the +plugin directory, so replacing the directory does not lose it. + +## Configure and pair + +1. Open the bridge surface. +2. Enter the relay address (`wss://…` or `https://…`), the account id, the bridge id and the + bridge token minted with the relay secret. +3. Request a pairing code and read the six digits aloud. +4. Enter the same relay address, the same account id and those six digits in the phone app. + +The code is single-use and expires after five minutes. The phone can be revoked from the bridge +surface at any time. + +## Tested environment + +- MiniMax Code 3.1.10 on Windows 11 (x64), with the official MiniMax Bot plugin installed. +- Node.js 24.22.1 and npm 11.19.1. +- The bridge's own suite passes on that machine: `npm test` inside the plugin directory. It needs + no dependencies and no network. + +## Data & access + +| | | +| --- | --- | +| **Files written** | Only inside the Host's private data directory (`context.dataDir`): `bridge-config.json` (relay address, account id, bridge id, display name, per-Bot turn mode), `bridge-token` (the relay credential, written for its owner only) and `ledger.json` (turn, tool and permission records — counters and identifiers, not message text). One `rooms//ledger.json` is added per room. | +| **Files read** | Those same files, on start. Nothing else: no Host files, no other plugins' files, no user documents. | +| **Network** | The relay address you configure, over HTTPS or WSS only. A cleartext `http://` or `ws://` address is accepted only for a loopback relay. `wss://` and `https://` name the same relay, and the credential never crosses between them. | +| **Spawned processes** | None. The bridge is one Node process with no child processes. | +| **Agent turns** | Yes — `agentAccess: ["session.run"]`. The bridge runs a Bot turn through the Host Agent in a session it owns and asks it for a model. It never reads your other conversations: `session.read` is not declared, and the team conversation history it shows comes from the team tools. | +| **Tools called** | Yes — `toolAccess: ["call"]`. The bridge calls the `minimax-bot` team tools the Host exposes (`mcp__minimax-bot__…`) to read the team, its groups and its conversation history, and to send messages. Each call is judged by your permission rules. | +| **Microphone** | Not used. The bridge records no audio and transcribes nothing. | +| **State** | The Host's private data directory, whose location is opaque and owned by the Host. Nothing is written outside it. The relay keeps its own state on the relay operator's account. | + +### Security notes + +- **The desktop is a trust boundary.** The bridge binds loopback and checks the caller's own + headers and a per-process secret before it answers a request that could change its + configuration. That defends the local web surface; it does not sandbox other native processes + on the same machine. +- **A paired phone is desktop access.** A paired device can exercise the bridge's available + functionality. Share pairing codes only with the people you mean to, and revoke devices you no + longer control. +- **Trust your relay.** TLS protects each connection, but the JSON the relay forwards is not + end-to-end encrypted. The relay operator can read conversations and operation payloads. +- **Capabilities are granted, not reproduced.** The bridge works through the capabilities the + Host and the team tools offer on that desktop. It holds no MiniMax account credential of its + own; the desktop's existing account and grants are what it uses. + +## Development + +```sh +npm test +``` + +The suite runs on Node's built-in test runner and needs no dependencies and no network. The +runtime payload is self-contained: it uses Node's standard library only, and the shared protocol +is vendored into `miniapp/node/protocol/`. + +## License + +The bridge is MIT licensed — see [LICENSE](LICENSE). The icon uses MiniMax's mascot artwork, +which MiniMax licenses under the MIT terms in [LICENSE-MINIMAX.txt](LICENSE-MINIMAX.txt). + +MiniMax names and marks identify the products this project connects to. The project license does +not grant trademark rights and does not imply MiniMax's endorsement. diff --git a/plugins/nattiini45/minimax-bot-bridge/README.zh-CN.md b/plugins/nattiini45/minimax-bot-bridge/README.zh-CN.md new file mode 100644 index 0000000..2401350 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/README.zh-CN.md @@ -0,0 +1,105 @@ +# MiniMax Bot Bridge + +[English](README.md) | 简体中文 + +把你的手机和桌面上的 **MiniMax Bot** 团队配对起来。Bridge 是运行在桌面端的 Mini App:它显示配对码、 +保存已配对的手机,并通过你自己运营的中继,把团队的对话和工具活动转发给手机客户端。 + +这是一个独立项目,不是 MiniMax 官方应用。它不修改、也不复刻官方 MiniMax Bot 插件——它调用 Host 已经 +暴露的团队工具,桌面端始终掌握主导权。 + +## 它能做什么 + +- **配对界面。** 申请一个一次性的六位配对码,在桌面上读出来,并查看或撤销与这台桌面配对的手机。 +- **中继配置。** 设置中继地址、账号 ID、桥接 ID、桥接凭据和显示名称。配置变化后桥接会自动重连; + 它拒绝使用自己不该连接的中继地址。 +- **团队访问。** 通过 Host 暴露的 `minimax-bot` 工具读取团队成员资料、群组成员和对话历史,并以某个 Bot + 的身份发送消息。 +- **托管回合。** Bot 的回合可以运行在桥接自己拥有的会话里,而不是团队自己的对话中,并带有流式输出和 + 桌面的权限提示。 + +手机端是另一个应用:[MiniMax Bot Mobile](https://github.com/nattiini45/minimax-bot-mobile), +一个面向 Android、iOS 和网页的 Expo / React Native 客户端。没有它,桥接没有意义;而除了中继之外, +它也不需要别的组件。 + +## 运行要求 + +- MiniMax Code 3.1.5 或更高版本——见[环境与兼容](#环境与兼容)。 +- 同一台桌面上装有官方 **MiniMax Bot** 插件。桥接通过 Host 的工具 API 调用该插件的工具。没有它, + 桥接仍然可以启动、配对并报告诊断信息,但无法展示或驱动你的团队。 +- 你自己运营的中继,以及为它签发的桥接凭据。线路流程见[配对与凭据](docs/PAIRING.md), + 中继 Worker 和签发命令见手机端仓库。 + +## 安装 + +把整个插件目录(包含隐藏的 `.minimax-plugin` 目录)复制到客户端的 `plugins` 目录: + +```text +仓库路径:plugins/nattiini45/minimax-bot-bridge/ +安装到: /plugins/minimax-bot-bridge/ +清单文件:/plugins/minimax-bot-bridge/.minimax-plugin/plugin.json +``` + +`dataDir` 默认是用户主目录下的 `.minimax` 目录(`~/.minimax`)。作者目录(`nattiini45/`)只是在仓库里 +归类贡献者,安装后的插件 ID 是 `minimax-bot-bridge`。重启 MiniMax Code,确认插件已被识别并启用,然后 +让 Agent 打开「MiniMax Bot Bridge」。 + +更新时,先关闭应用并退出 MiniMax Code,再整体替换插件目录;备份请放在 `plugins/` 之外。桥接自己的状态 +保存在 Host 的数据目录里,不在插件目录里,所以替换目录不会丢状态。 + +## 配置与配对 + +1. 打开桥接界面。 +2. 填入中继地址(`wss://…` 或 `https://…`)、账号 ID、桥接 ID,以及用中继密钥签发的桥接令牌。 +3. 申请配对码,把这六位数字念出来。 +4. 在手机端填入同样的中继地址、同样的账号 ID 和这六位数字。 + +配对码一次性使用,五分钟后过期。手机随时可以在桥接界面上撤销。 + +界面目前为英文。 + +## 环境与兼容 + +- MiniMax Code 3.1.10,Windows 11(x64),并安装了官方 MiniMax Bot 插件。 +- Node.js 24.22.1 与 npm 11.19.1。 +- 桥接自带测试在该机器上全部通过:在插件目录里执行 `npm test`。不需要安装依赖,也不需要网络。 + +## 数据与能力边界 + +| | | +| --- | --- | +| **写入的文件** | 仅限 Host 的私有数据目录(`context.dataDir`):`bridge-config.json`(中继地址、账号 ID、桥接 ID、显示名称、每个 Bot 的回合模式)、`bridge-token`(中继凭据,仅所有者可读)和 `ledger.json`(回合、工具与权限记录——计数与标识,不含消息正文)。每个房间另有 `rooms//ledger.json`。 | +| **读取的文件** | 启动时只读上面这几个文件。没有别的:不读 Host 文件,不读其他插件的文件,不读用户文档。 | +| **网络** | 只访问你配置的中继地址,且只走 HTTPS 或 WSS。明文的 `http://`/`ws://` 地址只接受回环地址。`wss://` 和 `https://` 指的是同一个中继,凭据不会在两者之间传递。 | +| **启动的进程** | 没有。桥接是一个 Node 进程,不派生任何子进程。 | +| **Agent 回合** | 有——`agentAccess: ["session.run"]`。桥接通过 Host Agent 在自己拥有的会话里运行 Bot 回合,并向它索取模型。它从不读取你的其他对话:没有声明 `session.read`,它展示的团队对话历史来自团队工具。 | +| **调用的工具** | 有——`toolAccess: ["call"]`。桥接调用 Host 暴露的 `minimax-bot` 团队工具(`mcp__minimax-bot__…`),用来读取团队、群组和对话历史,并发送消息。每次调用都按你的权限规则判定。 | +| **麦克风** | 不使用。桥接不录音,也不做语音转写。 | +| **状态存放位置** | Host 的私有数据目录,位置由 Host 管理且不透明。除此之外不写任何文件。中继的状态保存在中继运营方那边。 | + +### 安全说明 + +- **桌面端是信任边界。** 桥接只监听回环地址,并在回答可能改变配置的请求前校验调用方自身的请求头和一个 + 每进程生成的密钥。这保护的是本地网页界面,并不能隔离同一台机器上的其他原生进程。 +- **配对成功就等于授予了桌面访问权。** 已配对的设备可以行使桥接提供的能力。只把配对码告诉你要授权的人, + 并撤销不再使用的设备。 +- **信任你的中继。** TLS 保护的是每一段连接,但中继转发的 JSON 没有端到端加密。中继运营方可以读到对话和 + 操作负载。 +- **能力是被授予的,不是被复刻的。** 桥接只使用那台桌面上的 Host 与团队工具所提供的能力。它自己不持有 + MiniMax 账号凭据,用的是桌面端已有的账号和授权。 + +## 开发 + +```sh +npm test +``` + +测试基于 Node 内置的测试运行器,不需要依赖,也不需要网络。运行时负载是自包含的:只用 Node 标准库, +共享协议已经内置在 `miniapp/node/protocol/`。 + +## 许可 + +桥接采用 MIT 许可——见 [LICENSE](LICENSE)。图标使用 MiniMax 的吉祥物美术作品,MiniMax 以 MIT 条款授权, +见 [LICENSE-MINIMAX.txt](LICENSE-MINIMAX.txt)。 + +MiniMax 的名称与标识用于指代本项目所连接的产品。本项目的许可不授予商标权,也不代表 MiniMax 的背书。 diff --git a/plugins/nattiini45/minimax-bot-bridge/docs/PAIRING.md b/plugins/nattiini45/minimax-bot-bridge/docs/PAIRING.md new file mode 100644 index 0000000..3d00b3e --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/docs/PAIRING.md @@ -0,0 +1,235 @@ +# Pairing + +The phone pairs with the relay using a device-scoped token. Pairing does not +transfer MiniMax account credentials from the desktop. + +## The shape of it + +```text +DESKTOP (bridge) RELAY PHONE + │ │ │ + │ POST /r/:acct/pair/code │ │ + │ Authorization: Bearer │ │ + ├───────────────────────────────────>│ │ + │ { code: "418902", expiresAt } │ │ + │<───────────────────────────────────┤ │ + │ │ │ + │ (the user reads the code) │ POST /r/:acct/pair │ + │ │ { code, deviceId, … } │ + │ │<───────────────────────┤ + │ │ { deviceToken, expiresAt } + │ ├───────────────────────>│ +``` + +## Step by step + +### 1. The desktop asks for a code + +The bridge calls `POST /r/:accountId/pair/code` with its bearer token and +`{ bridgeId, bridgeName, ttlMs? }`. The relay checks the token's role, subject, +account and revocation state, then returns a single-use six-digit code. + +The default lifetime is **five minutes**. A requested `ttlMs` is clamped to +**one to fifteen minutes**. The bridge displays the code on its desktop surface; +the relay persists outstanding codes until they are consumed or pruned. + +### 2. The phone types the code + +Open the pairing screen (`https://github.com/nattiini45/minimax-bot-mobile/blob/main/apps/app/app/pair.tsx`), enter the relay URL and account +id, then type the six digits. The client checks the code's shape locally before +sending it. + +### 3. The relay exchanges the code + +The phone calls `POST /r/:accountId/pair` with +`{ code, deviceId, deviceName, clientVersion }`. + +An expired code returns `PAIRING_CODE_EXPIRED`; an unknown or already-used code +returns `PAIRING_CODE_UNKNOWN`. A successful exchange consumes the code and issues +a device token with a default **30-day** lifetime. Tokens use +`base64url(payload).base64url(signature)`, signed with HMAC-SHA-256. + +### 4. The phone connects + +The normal connection flow is: + +1. `POST /r/:accountId/ws-ticket` with `Authorization: Bearer `. +2. Open `GET /r/:accountId/ws?role=device&subject=&ticket=…&lastSeq=…`. + +The ticket expires after **30 seconds** and is checked for single use by the +running relay session. Authentication happens during the upgrade; `hello.ok` +reports resume metadata (`seq`, `bridgeOnline`, `truncated`), not authentication. +The relay replays available events after `lastSeq`. + +Both the phone and bridge can fall back to `token=…` in the WebSocket URL when a +ticket cannot be obtained or the ticket connection fails. This can expose the +longer-lived token to URL logs or proxies. Do not log connection URLs; tickets +reduce this exposure but do not eliminate it. + +### 5. Revoking a device + +The desktop lists paired devices with `GET /r/:accountId/devices`. Its preferred +owner-revocation interface is `POST /r/:accountId/devices/revoke`, authenticated +with a current bridge bearer credential and a JSON body: + +```json +{ "deviceId": "the-exact-registered-id" } +``` + +The ID is an exact string: do not trim or URI-decode it. A body preserves IDs such +as `.`, `..`, Unicode and literal percent sequences that cannot all be addressed +losslessly in URL path segments. The relay marks the selected device revoked and +closes its socket with code `4003`; the phone returns to pairing. Sibling devices +are unaffected. + +`DELETE /r/:accountId/devices/:deviceId` remains a legacy owner interface for IDs +that can be represented in a URL, with path decoding exactly once. A device can +release its own credential using `DELETE /r/:accountId/devices/me` with its device +bearer token. Connection-only WebSocket tickets are not management credentials. + +## Credentials + +| Credential | Held by | Lifetime | Revocation | +|---|---|---|---| +| Bridge token | Desktop `bridge-token` file, separate from config | 24 hours by default; minting accepts `--hours` | Account-scoped rotate/revoke endpoints | +| Device token | Phone credential store | 30 days by default | Desktop revocation or device self-release | +| Pairing code | Relay; displayed by desktop | 5 min by default, 1–15 on request | Consumed once or expires | +| MiniMax account credentials | Desktop runtime | Managed by MiniMax Code | Managed by MiniMax Code | + +Native credentials require Expo SecureStore. If the keystore is unavailable, +locked or fails a read/write, credential handling fails closed rather than +downgrading to plaintext. Credential writes and removal are ordered; session +metadata is invalidated before logout cleanup so a leftover secure token cannot +silently restore a removed session. + +Web credentials use **plaintext browser storage** through AsyncStorage. Treat the +browser profile as credential-bearing. Binding checks associate a credential with +its relay origin, account and device; they are not browser-side encryption. See +the [credential store](https://github.com/nattiini45/minimax-bot-mobile/blob/main/apps/app/src/state/credential-store.ts). + +Example bridge token payload: + +```json +{ + "v": 1, + "sub": "bridge_example", + "role": "bridge", + "exp": 1793181000000, + "jti": "unique-token-id", + "iat": 1793094600000, + "acc": "default" +} +``` + +Bridge tokens must carry an `acc` claim matching the requested account. Ordinary +device credentials are also checked against that account's registry and stored +token digest; legacy ordinary device credentials may omit `acc`. Connection-only +tickets for either role are account-bound, single-use and accepted only for a +WebSocket upgrade, never as pairing, renewal or management credentials. + +Keep `bridgeId` stable when re-minting: an account with an owner refuses a different +bridge id. Bridge credentials without the required account claim must be replaced. + +### Bridge token renewal + +`POST /r/:accountId/bridge/token` rotates the bridge token and revokes older tokens +using a durable issuance-time cutoff. `DELETE` on the same route is the bridge +credential kill switch: affected active connections are invalidated and pending +older rotations cannot recreate authority afterward. Both operations require a +current, non-ticket bridge bearer credential; proof is rechecked inside the +Durable Object at the mutation boundary. Relay-issued replacements last 24 hours. + +The bridge checks for expiry **only at startup**, attempting rotation when six +hours or less remain. There is no periodic renewal timer. Rotate through the +desktop surface before expiry, or restart within that six-hour window. If the +token has already expired, the relay operator must mint a replacement. + +Sources: [minting script](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/relay/scripts/mint-bridge-token.mjs), +[bridge lifecycle](miniapp/node/bridge.mjs). + +## Configuration + +The bridge stores `bridge-config.json` in the Mini App's data directory: + +```json +{ + "relayUrl": "wss://relay.example.workers.dev", + "accountId": "default", + "bridgeId": "bridge_example", + "bridgeName": "Desktop" +} +``` + +The credential lives alongside it in `bridge-token`, not in the configuration +JSON. The writer requests mode `0600` on filesystems that support it; this is not +a Windows ACL guarantee. Protect both the data directory and its backups. + +Set the relay's Worker secret: + +```sh +npx wrangler secret put RELAY_SECRET --config packages/relay/wrangler.toml +``` + +With the same secret supplied through `RELAY_SECRET` or `--secret-file`, mint a +token from the repository root: + +```sh +npm run mint:token -w @minimax-bot/relay -- --bridge-id --account --hours 24 +``` + +Enter the relay settings and token in the bridge surface. The bridge generates +and persists an id on first run. Either mint for that id, or set the editable bridge +id to the one supplied to the minting command before saving the credential. + +## Deployed relay + +```sh +cd packages/relay +npx wrangler deploy +``` + +`https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/relay/wrangler.toml` binds the Durable Object class. The Worker +entrypoint exports `Relay` for that binding. + +## Relay trust boundary + +The relay authenticates peers, routes JSON and keeps a bounded **plaintext event +replay buffer in memory** (512 entries / 4 MiB by default). It can read messages +and other payloads: there is **no application-level end-to-end encryption**. +HTTPS/WSS protects the transport, not payloads from the relay operator. + +The relay also persists pairing codes, device metadata and token hashes, account +ownership, sequence state and bridge-token revocation state. It does not execute +team tools or require MiniMax account credentials. See +[relay session](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/relay/src/session.ts). + +## Troubleshooting + +- **Pairing code expired:** request a new code and use it before its displayed expiry. +- **Code unknown:** it may be mistyped, already used, or for another relay/account. +- **Device not authorized:** its token may be expired, replaced or revoked; pair again. +- **Desktop offline:** check MiniMax Code, the bridge plugin, relay URL and token expiry. +- **Connected but team unavailable:** inspect `evt.team_snapshot.diagnostics` and the + desktop surface. `teamToolsAvailable: false` means the bridge could not obtain + the team tool catalogue; `agentAvailable` separately reports agent access. +- **`INVALID_ARGUMENTS`:** inspect `payload.details` for schema-derived field names + such as `missing`, `required`, `unmapped` and `properties`. It is not the full + `inputSchema`; an alias mismatch may need a change in + `miniapp/node/tools.mjs`. + +## Loopback access and automation + +The bridge binds the Host-assigned loopback address. Its own HTTP handler checks +`Host`, checks `Origin` when present, rejects cross-site fetch metadata, and +requires a per-process `x-bridge-secret` plus JSON content type for mutations. +`GET /api/session` returns that secret after the caller checks. + +These are browser-origin, Host-header and per-process-secret checks, **not native +local-process isolation or proof of human interaction**. A native caller can omit +`Origin`, request the secret with matching headers and drive the local API. +Automation can configure the bridge, pair a device peer, read the roster and send +operations through the live flow while MiniMax Code and the bridge are running. + +Sources: [caller checks](miniapp/node/caller.mjs), +[HTTP handler](miniapp/node/server.mjs). These describe the +repository implementation, not verification of additional live Host hardening. diff --git a/plugins/nattiini45/minimax-bot-bridge/docs/PROTOCOL.md b/plugins/nattiini45/minimax-bot-bridge/docs/PROTOCOL.md new file mode 100644 index 0000000..1b64b66 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/docs/PROTOCOL.md @@ -0,0 +1,364 @@ +# Protocol + +Version `1`: one JSON frame per WebSocket message. Shared types and envelope +validation live in [`https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/protocol`](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/protocol/src/types.ts). +Runtime behavior is implemented by the relay, bridge and app. + +## Roles + +| Role | Runs in | Talks to | +|---|---|---| +| `app` | Expo client on iOS / Android / web | relay | +| `bridge` | MiniMax Code Mini App plugin | relay, `@minimax-bot` tools, Host agent (approvals only) | +| `relay` | Cloudflare Worker + Durable Object | bridge, app | + +WebSocket peer roles are `device` and `bridge`; the phone's frame source is `app`. +The relay authenticates peers, validates envelopes and routes events. It can read +the JSON and keeps a bounded plaintext event replay buffer in memory. There is no +application-level end-to-end encryption. It persists authentication state and device +metadata, but does not execute tools or require MiniMax account credentials. + +## Frame envelope + +```jsonc +{ + "v": 1, // protocol version + "id": "f_9c1e", // unique frame id + "ts": 1793181000000, // epoch ms + "type": "op.send_message", + "source": "app", // app | bridge | relay + "payload": { "chatId": "bot:chief", "text": "hello" }, + "opId": "op_1753_a91f" // optional; operation deduplication key +} +``` + +Optional `in_reply_to` names the frame being answered. The relay alone adds `seq` +to bridge `evt.*` frames and `from` (device id) to device frames. Peers must not set +`seq` or `from`; the relay drops frames that do. Replies carry the operation id in +`payload.opId`, not at envelope level. + +[`decodeFrame`](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/protocol/src/frames.ts) validates required envelope +fields and optional field types, not payload schemas. Its errors are: + +`MALFORMED_JSON` · `NOT_AN_OBJECT` · `UNSUPPORTED_VERSION` · `MISSING_ID` · +`MISSING_TYPE` · `BAD_SOURCE` · `MISSING_TS` · `MISSING_PAYLOAD` · `BAD_SEQ` · +`BAD_FROM` · `BAD_IN_REPLY_TO` · `BAD_OP_ID`. + +## Frame types and connection setup + +| Type | Direction | Purpose | +|---|---|---| +| `op.*` | app → bridge | Request; expects `ack` or `err` | +| `evt.*` | bridge → app | Notification; sequenced by relay | +| `hello.ok` | relay → app | Resume metadata after authenticated upgrade | +| `hello` | Declared legacy control type | Not used to authenticate current connections | +| `ack` | bridge → app | Operation result | +| `err` | bridge → app | Operation failure | + +`isRequest()` and `isEvent()` are prefix checks. `OP_TYPES` and `EVENT_TYPES` list +the shared operation and event names. + +A peer normally calls `POST /r/:accountId/ws-ticket` with +`Authorization: Bearer `, then opens `/r/:accountId/ws` with `role`, +`subject`, `ticket` and, for resume, `lastSeq` query parameters. Tickets expire in +30 seconds; the running relay session rejects reuse. Authentication is part of +the upgrade. The relay sends `hello.ok` with `{ seq, bridgeOnline, truncated }`; +this is resume metadata, not a second credential exchange. + +Both clients can fall back to a longer-lived `token` query parameter when ticket +acquisition or the ticket connection fails. URL logs or proxies may therefore +capture a token. Tickets reduce exposure; they do not guarantee credential-free +URLs. See [app client](https://github.com/nattiini45/minimax-bot-mobile/blob/main/apps/app/src/net/relay-client.ts) and +[bridge client](miniapp/node/relay-client.mjs). + +## Operations (app → bridge) + +`OP_TOOL` maps tool-backed operations. `TEAM_ROUTED_OPS`, `AGENT_BACKED_OPS` and +`BRIDGE_LOCAL_OPS` identify the remaining handlers. The tables summarize common +fields; live tool schemas govern tool arguments. + +### Reading the team + +| Operation | Handler | Payload | Result | +|---|---|---|---| +| `op.list_team` | `list_team` + desktop metadata | — | `{ bots, groups, diagnostics }` | +| `op.bot_avatar` | bridge local | `{ botId }` | `{ botId, mime, data, source }` | +| `op.list_history` | bridge memory | `{ chatId, limit? }` | `{ messages, reactions, activity? }` | +| `op.list_tasks` | `get_tasks` | `{ taskId? \| batchId?, waitMs?, resultOffset? }` | `{ tasks, batchIds?, partial?, note? }` | +| `op.list_helpers` | `list_helpers` | — | `{ helpers }` | +| `op.list_schedules` | `list_schedules` | — | `{ schedules }` | +| `op.get_usage` | bridge ledger | `{ days? }` | `{ buckets, byBot, totals }` | +| `op.list_models` | bridge local | — | `{ supported, default?, models }` | +| `op.list_capabilities` | bridge local | — | `{ skills, plugins, agents, mcp }` | + +`op.list_models` is the Host's own model catalog: the models that can run, each with the +reasoning efforts it offers. The phone offers these as lists, because a model that is +not in the catalog cannot be picked and a model cannot be asked for an effort it does not +have. On a Host that does not let a run pick its model, `supported` is `false` and the +list is empty. + +`op.list_capabilities` is what the desktop can do beyond answering in a chat: its +skills, its plugins, its agents and its MCP servers, read from the Host's installation. +The bridge reads them and never runs them — the phone puts a skill into a conversation's +composer, in the form the desktop writes it, `/name`, and the user decides what to ask +of it. + +`op.bot_avatar` reads the desktop's uploaded picture or skin artwork and sends the +bytes base64-encoded in one frame. `source` is `avatar` or `skin`. Missing pictures +return an error; the client draws a fallback rather than bundling product artwork. + +`op.list_history` merges the desktop's read-only team transcript with bridge-owned +messages and returns the latest 200 messages. The optional `limit` is currently +ignored. The bridge targets 50 chats of its own messages/activity, preserving +active chats above the target and evicting settled chats together. Hosted messages, +bridge-only notes and reactions are in memory and lost on restart. Team history +is reread from the desktop and survives a bridge restart. `activity` carries the +calls of retained turns, using the shared 400-call soft target: oldest whole turns +are dropped, but the newest and running turns stay even above the target. This is +not a hard cap on bridge activity memory. `ledger.json` persists usage, decisions, +access changes and batch ids, **not a transcript**. + +The task tool queries one batch or task at a time. With neither id supplied, the +bridge queries its remembered dispatched batches and marks the list `partial: true`; +it is not a complete list of work started elsewhere. + +### Acting + +| Operation | Handler | Payload | +|---|---|---| +| `op.send_message` | team (`message_bot` / `post_group`) | `{ chatId, text, mentions?, attachments?, clientMessageId? }` | +| `op.abort_turn` | team (`cancel_tasks`) | `{ turnId }` | +| `op.dispatch` | `dispatch_tasks` | `{ botIds, prompt, title?, requestId? }` | +| `op.cancel_task` | `cancel_tasks` | `{ taskId \| batchId }` | +| `op.start_helper` | `start_helper` | `{ title?, task, targetBotId? }` | +| `op.stop_helper` | `stop_helper` | `{ helperId }` | +| `op.recall` | `recall` | `{ botId, query, limit?, key?, offset?, textOffset?, scope? }` | +| `op.remember` | `remember` | `{ botId, text, key?, replaceId?, scope?, pinned?, source? }` | +| `op.forget` | `forget` | `{ botId, id }` | +| `op.create_bot` | `create_bot` | `{ name, role?, instructions?, skin?, color?, model?, main? }` | +| `op.update_bot` | `update_bot` | `{ botId, … }` | +| `op.delete_bot` | `delete_bot` | `{ botId }` | +| `op.set_bot_mode` | bridge | `{ botId, mode }`, where `mode` is `hosted` or `team` | +| `op.create_group` | `create_group` | `{ name, memberIds, admin?, notice?, replyMode? }` | +| `op.update_group` | `update_group` | `{ groupId, … }` | +| `op.delete_group` | `delete_group` | `{ groupId }` | +| `op.create_schedule` | `create_schedule` | `{ name, kind, spec, targetBotId?, prompt? }` | +| `op.delete_schedule` | `delete_schedule` | `{ scheduleId }` | +| `op.react` | bridge memory | `{ messageId, emoji }` | + +`op.update_bot` takes a Bot's `model` as `provider/model` (or `default` to follow the +desktop's own model) and its `effort` as one of the levels `op.list_models` reports for +that model (`default` for the model's own). A level the model does not offer is refused +by the desktop, so the phone only sends what the catalog lists. + +### Agent interaction + +`op.respond_permission` and `op.answer_question` use `context.agent`, and so does a +**hosted** turn: a turn the bridge owns is a run the Host lets the bridge answer for. +A **team** turn does not: the team owns that run, so the Bot answers with its own +instructions, model, effort and session, and the phone is told where a question has to +be answered. + +| Operation | Payload | +|---|---| +| `op.respond_permission` | `{ requestId, decision }`, where `decision` is `allowOnce` or `deny` | +| `op.answer_question` | `{ requestId, answers[] }` | + +### Where a turn runs + +`CHAT_ROUTING` maps the chat a turn belongs to to the team tool that runs it: + +| Chat id | Tool | Target | +|---|---|---| +| `bot:` | `message_bot` | `to: ` | +| `group:` | `post_group` | `group: ` | + +The bridge hands the turn to the team itself, which answers with a durable receipt +and finishes the work later; the bridge asks `get_tasks` until the task settles, so +the reply is the Bot's own text. Mentions are translated from Bot ids to the +`@Name` the team reads before the text goes out, and a mention of a Bot the roster +does not know is dropped rather than sent as an id. The team's receipt carries the id +of the record the turn wrote into the desktop's own transcript, and the bridge reads +that record back: the parts it holds become the turn's messages and the tool calls it +recorded become the turn's activity. The phone therefore shows the exchange the +desktop shows, in the desktop's own order and under the desktop's own ids. + +The tool calls of a team turn are not streamed while the turn runs. The desktop writes +its record when the turn ends, so the live view of a running turn is the time it has +been running; the whole exchange — the narration, the calls and the answer — arrives +with the turn's end. A task the team reports as `waiting_for_user` ends +the turn with a note saying where the answer is given. + +A Bot's own chat can run the other way instead: **hosted**, on the desktop, under the +bridge. The Host only lets the Mini App that owns a run answer its questions, so this +is where a permission check or a questionnaire the Bot raises is answered from the +phone. The Bot keeps its name, role, instructions, model and effort, but uses a +separate bridge-owned session per Bot, reused for later hosted turns during that +bridge process. It does not continue the desktop Bot's session and is not written +into the desktop Bot app's transcript. `op.set_bot_mode` chooses, per Bot, and the choice is kept in the bridge's +configuration. A turn runs in the team unless the phone says otherwise, so that the +desktop's own conversation is the record of what was said. The roster carries the mode +back to the phone as `Bot.runMode`. A group is always the team's. + +The thread a phone reads (`op.list_history`) is the desktop's own transcript for a turn +the team ran — the same messages, with the same ids the desktop shows — together with +the turns the bridge ran itself, which the desktop does not know about. Each message is +recorded once, by whoever owns it. The tool calls of those turns travel with it in +`activity`, so a thread opened after the fact shows the work a turn did and not only +the words it ended with. + +Up to 32 turns may run concurrently; excess calls return retryable `BUSY` rather +than entering a bridge queue. `attachments` is declared in the contract but is not +forwarded by the current send handler. + +## Events (bridge → app) + +| Event | Payload | +|---|---| +| `evt.team_snapshot` | `{ bots, groups, diagnostics, tools }` | +| `evt.turn_started` | `{ chatId, turnId, startedAt? }` | +| `evt.message_chunk` | `{ chatId, turnId, messageId, text }` | +| `evt.message_committed` | `{ message }` | +| `evt.tool_started` | `{ chatId, turnId, toolName, callId }` | +| `evt.tool_finished` | `{ chatId, turnId, toolName, callId, ok, status?, usage?, error? }` | +| `evt.turn_finished` | `{ chatId, turnId, status, reason?, text?, error? }` | +| `evt.permission_requested` | `{ request }`, where the request carries the `chatId` it was raised in | +| `evt.permission_resolved` | `{ requestId, decision }` | +| `evt.question_requested` | `{ request }`, where the request carries the `chatId` it was asked in | +| `evt.question_answered` | `{ requestId }` | +| `evt.task_updated` | `{ task }` | +| `evt.helper_updated` | `{ helper }` | +| `evt.schedule_updated` | `{ schedule }` | +| `evt.bridge_status` | `{ desktopOnline, agentAvailable, teamToolsAvailable, linkedAt? }` | +| `evt.reaction_added` | `{ reaction }` | + +### Agent event translation + +| Agent event | Protocol event | +|---|---| +| `turn.started` | `evt.turn_started` | +| `text.delta` | `evt.message_chunk` | +| `message.committed` | `evt.message_committed` | +| `tool.started` | `evt.tool_started` | +| `tool.finished` | `evt.tool_finished` | +| `permission.requested` | `evt.permission_requested` | +| `question.requested` | `evt.question_requested` | +| `turn.finished` | `evt.turn_finished` | + +The shared chunk type permits `final?`. Normal team transcript replay emits no +chunks; the unreadable-transcript fallback can emit one chunk with `final: true`. +Use message commits and `evt.turn_finished` as completion signals. The bridge +assigns `callId` to correlate repeated calls of the same tool. + +These are the Host agent's events, and they apply to a run the bridge itself owns: a +**hosted** turn, and the continuation of an answered question. A team turn is not +translated from Host agent events: the team reports the finished result, and the +bridge publishes that. + +## Sequencing, resume and idempotency + +- **Sequencing:** the relay stamps `seq` on bridge `evt.*` frames. The client drops + already-applied sequence numbers and detects gaps. +- **Replay:** the relay retains a plaintext event tail in memory, bounded by 512 + entries and 4 MiB by default. The sequence counter is persisted; the replay ring + is not. `lastSeq` requests events after that cursor; `truncated: true` signals a + gap and prompts app resynchronization. A fresh snapshot cannot restore chat text + already lost by a bridge restart. +- **Frame size:** incoming frames over 512 KiB are dropped before JSON parsing. + Account status includes `droppedFrames`. +- **Deduplication:** the bridge keys its process-local cache by operation type, + sender and `opId` (falling back to frame id). Entries expire after ten minutes; + a shared 500-entry cap can evict them earlier, including in-flight entries. + Retries reuse cached outcomes only while retained. Restart, expiry or eviction + can repeat effects: this is best effort, not durable exactly-once execution. +- **Control frames:** `ack`, `err`, `hello` and `hello.ok` are not sequenced or + buffered for replay. The current relay broadcasts bridge replies to connected + devices; clients correlate them using `in_reply_to` / `payload.opId`. + +## HTTP surface + +Relay routes are prefixed `/r/:accountId`. HTTP token authentication uses +`Authorization: Bearer ` unless the upgrade query is specified below. + +| Route | Method | Auth | Purpose | +|---|---|---|---| +| `/ws` | GET (upgrade) | `role`, `subject`, `ticket` (or fallback `token`) in query | Connect bridge or device | +| `/ws-ticket` | POST | bridge or device bearer token | Issue connection ticket | +| `/pair/code` | POST | bridge bearer token | Issue pairing code; default 5 min, clamped 1–15 min | +| `/pair` | POST | pairing code in body | Exchange code for device token | +| `/status` | GET | bridge or device bearer token | Presence, counts, `lastSeq`, `droppedFrames` | +| `/devices` | GET | bridge bearer token | List device metadata, excluding token/hash | +| `/devices/:deviceId` | DELETE | bridge bearer token | Revoke device | +| `/devices/me` | DELETE | device bearer token | Release own credential | +| `/bridge/token` | POST | bridge bearer token | Rotate token and revoke older tokens | +| `/bridge/token` | DELETE | bridge bearer token | Revoke tokens issued at or before current cutoff | + +The browser WebSocket API cannot set an Authorization header, so the upgrade uses +a query credential. Bridge tokens require an `acc` claim matching the path and the +account's owner bridge id when set. Device tokens are checked against the account's +device registry. See [Pairing](PAIRING.md) for storage and renewal limits. + +## Bridge loopback surface + +The bridge listens on the Host-assigned loopback address. Its handler checks `Host`, +optional `Origin` and fetch metadata. Mutations require JSON content type and the +per-process `x-bridge-secret`; sensitive reads require the secret too. A native +local caller can obtain the secret with matching headers: this is not native +process isolation or a human-only API. Additional live Host hardening is not +established by these repository checks. + +| Route | Method | Purpose | +|---|---|---| +| `/api/health` | GET | Liveness | +| `/api/session` | GET | Obtain per-process surface secret after caller checks | +| `/api/status` | GET | Bridge and relay state, diagnostics, pending count | +| `/api/pair/code` | POST | Ask relay for pairing code | +| `/api/devices` | GET | Paired devices | +| `/api/devices/:deviceId` | DELETE | Revoke device | +| `/api/snapshot` | GET | Team snapshot and tool catalogue | +| `/api/config` | POST | Update relay settings and credential | +| `/api/rotate-token` | POST | Rotate bridge token | + +## Error handling + +Example complete bridge error frame: + +```json +{ + "v": 1, + "id": "f_error_1", + "ts": 1793181000000, + "type": "err", + "source": "bridge", + "in_reply_to": "f_9c1e", + "payload": { + "opId": "op_1753_a91f", + "code": "INVALID_ARGUMENTS", + "message": "A required tool argument is missing.", + "retryable": false + } +} +``` + +`retryable` indicates whether the bridge classifies the failure as retryable. It +does not extend the deduplication window. Tool argument errors can additionally +include `payload.details` with schema-derived field-name lists such as `missing`, +`required`, `unmapped` and `properties`; the full `inputSchema` is not returned. + +Relay errors include `UNAUTHORIZED`, `PAIRING_CODE_INVALID`, `PAIRING_CODE_EXPIRED`, +`PAIRING_CODE_UNKNOWN`, `DEVICE_REVOKED`, `BRIDGE_OFFLINE`, `BAD_REQUEST`, +`ACCOUNT_OWNED` and `RATE_LIMITED`. See `RelayErrorCode` / `describeRelayError` in +[`pairing.ts`](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/protocol/src/pairing.ts). + +## Schema-driven tool arguments + +The bridge reads each live tool's `inputSchema` and uses an alias table to map +operation fields (`botId` → `to` / `bot_id` / `target`, `text` → `message` / +`content`, …). Mapping or validation failures return `INVALID_ARGUMENTS` with +field-name details, rather than assuming a fixed parameter spelling. + +Sources: [tool gateway](miniapp/node/tools.mjs), +[bridge handlers](miniapp/node/bridge.mjs), +[agent translation](miniapp/node/agent.mjs), +[relay session](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/relay/src/session.ts), +[relay HTTP routes](https://github.com/nattiini45/minimax-bot-mobile/blob/main/packages/relay/src/index.ts). diff --git a/plugins/nattiini45/minimax-bot-bridge/icon-dark.png b/plugins/nattiini45/minimax-bot-bridge/icon-dark.png new file mode 100644 index 0000000..828b157 Binary files /dev/null and b/plugins/nattiini45/minimax-bot-bridge/icon-dark.png differ diff --git a/plugins/nattiini45/minimax-bot-bridge/icon.png b/plugins/nattiini45/minimax-bot-bridge/icon.png new file mode 100644 index 0000000..57d0c88 Binary files /dev/null and b/plugins/nattiini45/minimax-bot-bridge/icon.png differ diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/client/index.html b/plugins/nattiini45/minimax-bot-bridge/miniapp/client/index.html new file mode 100644 index 0000000..2e92da5 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/client/index.html @@ -0,0 +1,492 @@ + + + + + + MiniMax Bot Bridge + + + +

MiniMax Bot Bridge

+

Connect your phone to the team running on this desktop.

+ +
+
+
+
Relay
+
checking…
+
+
+
+
+ +
+
Pairing code
+
Generate a code to pair a phone.
+
+
+ + +
+
+ + Replaces the bridge token and kills the old one. +
+
+
+ +
+
Paired phones
+
    +
  • None yet.
  • +
+
+ +
+
Relay configuration
+ + + + +
+ + +
+
+ The relay URL, the account id, the bridge id and the bridge token are stored on this desktop + only. The token is minted for one specific bridge id by whoever holds the relay secret: + npm run mint:token -w @minimax-bot/relay. It is never sent back to this surface. +
+
+ + + + diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/miniapp.json b/plugins/nattiini45/minimax-bot-bridge/miniapp/miniapp.json new file mode 100644 index 0000000..ddf8f83 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/miniapp.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "artifacts": { + "client": ["./miniapp/client"], + "node": ["./miniapp/node"] + }, + "runtime": { + "kind": "process", + "entry": "./miniapp/node/server.mjs", + "lifecycle": "on-demand" + }, + "surface": { + "path": "/bridge", + "window": { + "width": 520, + "height": 620, + "minWidth": 380, + "minHeight": 420 + } + }, + "agentAccess": ["session.run"], + "toolAccess": ["call"], + "mcpEndpoints": [] +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/agent.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/agent.mjs new file mode 100644 index 0000000..c58d821 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/agent.mjs @@ -0,0 +1,810 @@ +/** + * Runs Bot turns. + * + * A turn belongs to the Bot or the group the phone addressed, and there are two ways to + * run it: + * + * - **team** — the `@minimax-bot` team runs the turn: that Bot answers with its own + * instructions, its own model and effort, in its own session, and its reply lands in + * its own conversation. + * - **hosted** — the desktop runs the turn under the bridge, in that Bot's own session. + * The Host only lets the Mini App that owns a run answer its questions, so this is + * where a permission check or a questionnaire the Bot raises can be answered from the + * phone. A group is always the team's: who speaks first and who hands over to whom + * are the product's rules. + * + * The Host's agent capability is used for both: a hosted turn runs on it, and an + * answer to a question the Host asked is given back to it. + */ + +import { desktopTurnById, desktopTurnsByTask } from './desktop-team.mjs'; + +/** Mirrors the Host's agent concurrency ceiling. */ +export const MAX_CONCURRENT_RUNS = 32; + +/** + * How many Bot conversations the bridge keeps a note of at once. + * + * The team owns the conversations; the bridge only remembers which session answered + * last, so that a phone that asks about it gets an answer. The chat id is chosen by + * the client, so without a ceiling a client that keeps inventing chats makes the + * desktop hold a note for each of them. + */ +export const MAX_BOT_SESSIONS = 50; + +export class AgentRunner { + /** + * @param {import('./contracts.d.ts').HostContext} context the Mini App context + * @param {{pending?: import('./pending.mjs').PendingSet, router?: import('./bot-router.mjs').BotRouter, + * onEvent?: (type: string, payload: object, mode?: string) => void, + * onTurnFinished?: (info: {chatId: string, durationMs: number, result?: import('./contracts.d.ts').HostRunResult, error?: unknown}) => unknown, + * onTeamAccepted?: (turn: import('./contracts.d.ts').TeamTurn) => unknown}} [options] + */ + constructor(context, { pending, onEvent, onTurnFinished, onTeamAccepted, router } = {}) { + this.context = context; + this.pending = pending; + this.stopped = false; + this.generation = 0; + this.watchers = new Set(); + this.turnModes = new Map(); + this.pendingScopes = new WeakMap(); + this.onEvent = (type, payload) => { + if (this.stopped) return; + const turnId = payload?.turnId ?? payload?.message?.turnId; + onEvent?.(type, payload, this.turnModes.get(turnId) ?? 'hosted'); + }; + if (pending) pending.onExpired = (requestId, item) => this.resolveEvent(requestId, item, 'expired'); + this.onTurnFinished = onTurnFinished ?? (() => {}); + this.onTeamAccepted = onTeamAccepted ?? (() => {}); + /** @type {import('./bot-router.mjs').BotRouter} */ + this.router = router; + /** @type {Map} chatId -> the session the Bot answered in */ + this.sessions = new Map(); + /** @type {Map} turnId -> controller */ + this.aborts = new Map(); + /** @type {Map} turnId -> the team's task */ + this.turns = new Map(); + /** + * The tool calls a Host run reported, so that a call that never reported back does + * not stay on the phone for ever. + * + * @type {Map>>} + */ + this.toolCalls = new Map(); + this.callCounter = 0; + this.activeRuns = 0; + } + + /** + * Remembers a tool call the Host reported. + * + * A turn may call the same tool more than once, so the calls of a tool are kept in + * the order they were started: a finish belongs to the oldest call that is still + * waiting. + * + * @param {string} turnId + * @param {string} name + */ + startTool(turnId, name) { + this.callCounter += 1; + const callId = `call_${this.callCounter}`; + const calls = this.toolCalls.get(turnId) ?? new Map(); + const queue = calls.get(name) ?? []; + queue.push({ callId, startedAt: Date.now() }); + calls.set(name, queue); + this.toolCalls.set(turnId, calls); + return callId; + } + + /** + * Forgets a tool call the Host reported. + * + * @param {string} turnId + * @param {string} name + */ + finishTool(turnId, name) { + const calls = this.toolCalls.get(turnId); + const queue = calls?.get(name); + const call = queue?.shift(); + if (!calls) return call; + if (queue && queue.length === 0) calls.delete(name); + // The turn's bookkeeping goes only when nothing of it is left. + if (calls.size === 0) this.toolCalls.delete(turnId); + return call; + } + + /** + * Forgets every tool call of a turn, so that a call which never reported back does + * not stay on the phone for ever. + * + * @param {string} turnId + */ + clearTools(turnId) { + this.toolCalls.delete(turnId); + } + + /** Whether a turn can be handed to the team right now. */ + get available() { + return Boolean(this.router?.available); + } + + sessionFor(chatId) { + return this.sessions.get(chatId); + } + + /** + * Remembers which session answered in a conversation. + * + * The team owns the conversations; the bridge only keeps a note of which session + * answered last, so that a phone that asks about it gets an answer. The chat id is + * chosen by the client, so without a ceiling a client that keeps inventing chats + * makes the desktop hold a note for each of them. + * + * @param {string} chatId + * @param {string} sessionId + */ + rememberSession(chatId, sessionId) { + if (!sessionId) return; + this.sessions.set(chatId, sessionId); + while (this.sessions.size > MAX_BOT_SESSIONS) { + const oldest = this.sessions.keys().next(); + if (oldest.done) break; + this.sessions.delete(oldest.value); + } + } + + /** + * Runs a turn for a chat. + * + * A private Bot chat runs on the desktop under the bridge by default, in that Bot's + * own session, so that a permission check or a questionnaire the Bot raises can be + * answered from the phone: the Host lets the Mini App that owns a run answer its + * questions, and the team keeps a run to itself. A phone can ask for the team's own + * engine instead, where the desktop Bot app keeps the whole exchange. + * + * A group chat always runs in the team, because a group is more than a Bot: who + * speaks first, who the admin is, who hands over to whom and when the discussion + * pauses are the team's rules, and the phone does not get to make them up. + * + * @param {{chatId: string, text: string, mentions?: string[], requestId?: string, mode?: 'team' | 'hosted'}} input + */ + async run(input) { + this.assertRunning(); + const route = this.router?.route?.(input.chatId); + if (route?.kind === 'bot' && input.mode !== 'team') return this.runHosted(input); + return this.runTeam(input); + } + + /** + * The exchange the desktop recorded for a turn the team ran. + * + * The desktop keeps one record per turn, and that record holds everything the turn + * did. It is found by the task the turn ran under — a group can have more than one + * record for one turn, and they share that task — and by the id the receipt names. + * + * @param {string} chatId + * @param {string | undefined} messageId the id of the record the receipt named + * @param {string | undefined} taskId the task the turn ran under + * @returns {Promise<{messages: Array, activity: Array} | undefined>} + */ + async readTeamExchange(chatId, messageId, taskId) { + const byTask = await desktopTurnsByTask(this.context, chatId, taskId); + if (byTask && byTask.messages.length > 0) return byTask; + const byId = await desktopTurnById(this.context, chatId, messageId); + if (byId && byId.messages.length > 0) return byId; + return undefined; + } + + /** + * Publishes the exchange the desktop recorded. + * + * Every part the desktop draws as a bubble becomes a message on the phone, and every + * tool the turn used becomes a call, so that the phone shows the same conversation + * the desktop shows and not only the answer at the end of it. + * + * @param {string} chatId + * @param {string} turnId the turn these belong to + * @param {{messages: Array, activity: Array}} exchange + */ + publishExchange(chatId, turnId, exchange) { + const previous = this.turnModes.get(turnId); + this.turnModes.set(turnId, 'team'); + for (const message of exchange.messages) { + this.onEvent('evt.message_committed', { + message: { ...message, chatId, turnId }, + }); + } + for (const entry of exchange.activity) { + this.onEvent('evt.tool_started', { + chatId, + turnId, + toolName: entry.toolName, + callId: entry.callId, + }); + this.onEvent('evt.tool_finished', { + chatId, + turnId, + toolName: entry.toolName, + callId: entry.callId, + ok: entry.status === 'ok', + // The state is carried as well as the yes or the no, so that a call the desktop + // never saw report back looks the same on the phone as it does in the thread + // the phone reads back. + status: entry.status, + // A call the desktop never saw report back is not a failure, but it is not a + // success either, so it says what is known rather than what is not. + ...(entry.status === 'unknown' + ? { error: 'The tool did not report back before the turn ended.' } + : entry.status === 'failed' + ? { error: 'The tool did not finish.' } + : {}), + }); + } + if (previous) this.turnModes.set(turnId, previous); + else this.turnModes.delete(turnId); + } + + /** + * Runs a turn the team owns. + * + * @param {{chatId: string, text: string, mentions?: string[], requestId?: string}} input + */ + async runTeam(input) { + this.assertRunning(); + if (!this.router?.available) { + throw codedError( + 'TEAM_UNAVAILABLE', + 'The @minimax-bot team is not running on this desktop. Open MiniMax Bot to link it.', + true, + ); + } + if (this.activeRuns >= MAX_CONCURRENT_RUNS) { + throw codedError('BUSY', 'Too many turns are already running.', true); + } + + const { chatId, text, mentions, requestId } = input; + const controller = new AbortController(); + const generation = this.generation; + const current = () => !this.stopped && generation === this.generation; + this.watchers.add(controller); + this.activeRuns += 1; + const startedAt = Date.now(); + + let result; + let failure; + /** @type {string | undefined} */ + let turnId; + try { + // The team takes the turn and answers with a receipt; the work itself runs in the + // Bot's own conversation, wherever the user opened it. + const turn = await this.router.send({ chatId, text, mentions, requestId, signal: controller.signal }); + turnId = turn.taskId ?? turn.batchId; + if (!current()) return; + this.onTeamAccepted(turn); + this.turnModes.set(turnId, 'team'); + this.turns.set(turnId, turn); + this.aborts.set(turnId, controller); + this.onEvent('evt.turn_started', { chatId, turnId, startedAt: Date.now() }); + + const settled = await this.router.wait(turn, { signal: controller.signal }); + if (!current()) return settled; + + if (settled.sessionId) { + this.rememberSession(chatId, settled.sessionId); + } + + const reply = settled.text ?? ''; + // The desktop keeps the whole exchange of a turn the team ran, and the names of + // that exchange come back with the receipt. Reading it back gives the phone the + // same bubbles the desktop shows: what the Bot said while it worked, the tools + // it used, and the answer it ended with. + const exchange = await this.readTeamExchange(chatId, settled.messageId, settled.taskId); + if (!current()) return settled; + if (exchange) { + this.publishExchange(chatId, turnId, exchange); + } else if (reply) { + // The desktop's own record could not be read. The answer is still the answer, + // so it is published as one message rather than lost. + this.onEvent('evt.message_chunk', { + chatId, + turnId, + messageId: messageIdFor(chatId, turnId), + text: reply, + final: true, + }); + this.onEvent('evt.message_committed', { + message: { + messageId: messageIdFor(chatId, turnId), + chatId, + role: 'assistant', + text: reply, + ts: Date.now(), + turnId, + // The Bot that answered is the Bot the turn was addressed to. + authorId: settled.botId ?? authorIdFor(chatId), + }, + }); + } + if (settled.note) { + // The team stopped for a reason the phone has to know about, and it is not the + // Bot's words, so it is not put in the Bot's mouth. + this.onEvent('evt.message_committed', { + message: { + messageId: `${messageIdFor(chatId, turnId)}_note`, + chatId, + role: 'system', + text: settled.note, + ts: Date.now(), + }, + }); + } + + result = { ...settled, turnId, sessionId: settled.sessionId ?? this.sessions.get(chatId) }; + this.onEvent('evt.turn_finished', { + chatId, + turnId, + status: settled.status === 'cancelled' ? 'aborted' : 'finished', + reason: settled.status === 'cancelled' ? 'cancelled' : undefined, + text: reply, + }); + return result; + } catch (error) { + failure = error; + throw error; + } finally { + this.activeRuns -= 1; + this.watchers.delete(controller); + if (turnId) { + if (this.aborts.get(turnId) === controller) this.aborts.delete(turnId); + this.turns.delete(turnId); + if (!this.router.polls?.has(turnId)) this.router.turns?.delete(turnId); + // A tool call that never reported back must not stay on the phone for ever. + this.clearTools(turnId); + } + // A turn that failed still has to be reported, or the phone is left waiting on a + // reply that will never come. A turn that was stopped is not a failure. + if (current() && failure) { + const stopped = /** @type {any} */ (failure).code === 'CANCELLED'; + this.onEvent('evt.turn_finished', { + chatId, + turnId, + status: stopped ? 'aborted' : 'error', + reason: stopped ? 'cancelled' : 'error', + error: { + code: /** @type {any} */ (failure).code ?? 'TURN_FAILED', + message: failure instanceof Error ? failure.message : String(failure), + }, + }); + } + if (current()) await this.onTurnFinished?.({ + chatId, + durationMs: Date.now() - startedAt, + result, + error: failure, + }); + if (turnId) this.turnModes.delete(turnId); + } + } + + /** + * Runs a turn the bridge owns, in the Bot's own session. + * + * @param {{chatId: string, text: string, mentions?: string[], requestId?: string}} input + */ + async runHosted(input) { + this.assertRunning(); + if (!this.context.agent) { + throw codedError('AGENT_UNAVAILABLE', 'The desktop agent is not available, so the Bot cannot be run from here.', true); + } + if (this.activeRuns >= MAX_CONCURRENT_RUNS) { + throw codedError('BUSY', 'Too many turns are already running.', true); + } + const { chatId, text } = input; + return this.observeHosted(chatId, (options) => this.router.hosted({ + chatId, botId: this.router.route(chatId)?.targetId ?? '', text, ...options, + })); + } + + /** One lifecycle for a new hosted run and a questionnaire continuation. */ + async observeHosted(chatId, execute, { continuation = false } = {}) { + const controller = new AbortController(); + const generation = this.generation; + const current = () => !this.stopped && generation === this.generation; + this.watchers.add(controller); + this.activeRuns += 1; + const startedAt = Date.now(); + let result; + let failure; + let terminal; + let turnId; + const observedTurns = new Set(); + const requestedQuestions = new Set(); + const requestedIds = new Set(); + try { + const run = await execute({ signal: controller.signal, onEvent: (event) => { + if (!current()) return; + // Child permissions name the child's turn, not the root continuation. + if (event.turnId) { + observedTurns.add(event.turnId); + this.turnModes.set(event.turnId, 'hosted'); + if (event.type !== 'permission.requested') turnId = event.turnId; + } + if (event.type === 'question.requested') requestedQuestions.add(event.requestId); + if (event.type === 'question.requested' || event.type === 'permission.requested') requestedIds.add(event.requestId); + if (event.type === 'turn.finished') terminal = event; + this.handleAgentEvent(chatId, event, controller); + } }); + if (!current()) return run; + turnId = run.turnId ?? turnId ?? run.pendingQuestion?.turnId; + if (turnId) { observedTurns.add(turnId); this.turnModes.set(turnId, 'hosted'); } + if (run.sessionId) { + this.rememberSession(chatId, run.sessionId); + this.router?.rememberPhoneSession?.(chatId, run.sessionId); + } + if (run.pendingQuestion && !requestedQuestions.has(run.pendingQuestion.requestId)) { + requestedIds.add(run.pendingQuestion.requestId); + this.handleAgentEvent(chatId, { + ...run.pendingQuestion, type: 'question.requested', sessionId: run.sessionId, + turnId: run.pendingQuestion.turnId ?? turnId, + }, controller); + } + result = { ...run, turnId }; + return result; + } catch (error) { + failure = error; + throw error; + } finally { + this.activeRuns -= 1; + this.watchers.delete(controller); + for (const observed of observedTurns) { + if (this.aborts.get(observed) === controller) this.aborts.delete(observed); + this.clearTools(observed); + } + if (current() && (!continuation || observedTurns.size > 0)) { + // A questionnaire ends a Host turn with status "finished". Only the + // result's pendingQuestion distinguishes waiting from terminal completion. + const waiting = Boolean(result?.pendingQuestion) || result?.status === 'waiting_for_user'; + if (!waiting && (result || observedTurns.size > 0)) this.clearPending((item) => + requestedIds.has(item.request.requestId) || (item.request.chatId === chatId && + !this.watchers.has(this.pendingScopes.get(item)))); + if (!terminal) { + const cancelled = /** @type {any} */ (failure)?.code === 'CANCELLED' || + (failure instanceof Error && failure.name === 'AbortError'); + const status = cancelled || result?.status === 'aborted' ? 'aborted' : + failure || result?.status === 'error' ? 'error' : 'finished'; + this.onEvent('evt.turn_finished', { + chatId, turnId, status, + reason: status === 'aborted' ? 'cancelled' : status === 'error' ? 'error' : undefined, + text: result?.text, + error: failure ? { code: /** @type {any} */ (failure).code ?? 'TURN_FAILED', + message: failure instanceof Error ? failure.message : String(failure) } : result?.error, + }); + } + await this.onTurnFinished?.({ chatId, durationMs: Date.now() - startedAt, + result: result ?? terminal, error: failure }); + } + for (const observed of observedTurns) this.turnModes.delete(observed); + } + } + + assertRunning() { + if (this.stopped) throw codedError('BRIDGE_STOPPED', 'The bridge has stopped.'); + } + + /** Stop observing owned runs, without cancelling official team tasks. */ + stop() { + this.stopped = true; + this.generation += 1; + for (const controller of this.watchers) controller.abort(); + this.aborts.clear(); + this.toolCalls.clear(); + this.turnModes.clear(); + this.clearPending(() => true); + } + + resolveEvent(requestId, item, reason) { + this.onEvent(item.kind === 'permission' ? 'evt.permission_resolved' : 'evt.question_answered', { requestId, reason }); + } + + clearPending(predicate) { + for (const item of this.pending?.list() ?? []) { + if (predicate(item) && this.pending.resolve(item.request.requestId)) { + this.resolveEvent(item.request.requestId, item, 'turn_ended'); + } + } + } + + /** + * Stops a running turn. + * + * The team's work is not a Host run the bridge owns, so stopping it means telling the + * team to stop: the work it already admitted has to be cancelled there. + * + * @param {string} turnId + */ + async abort(turnId) { + const controller = this.aborts.get(turnId); + const turn = this.turns.get(turnId); + if (!controller && !turn) return false; + // A remote refusal must leave the watcher alive and the cancel retryable. + if (turn && await this.router.cancel(turn) === false) return false; + // The poll may have settled while remote cancellation was in flight. + if (this.aborts.get(turnId) !== controller && !this.turns.has(turnId)) return false; + controller?.abort(); + if (this.aborts.get(turnId) === controller) this.aborts.delete(turnId); + return true; + } + + /** Answers a pending permission request. */ + async reply(requestId, decision) { + this.assertRunning(); + // The agent API accepts exactly two decisions. Anything else — including + // `allowAlways` — is refused here rather than forwarded to the Host. + if (decision !== 'allowOnce' && decision !== 'deny') { + throw codedError('INVALID_ARGUMENTS', 'A decision must be either allowOnce or deny.', false); + } + // The request is claimed before the Host is called. Two answers to the same + // request must not both reach the Host: the second one finds nothing to answer + // and is refused. + const item = this.pending?.claim(requestId, 'permission'); + if (!item) { + throw codedError('REQUEST_NOT_FOUND', 'That request is no longer waiting for an answer.', false); + } + const claimId = item.claimId; + // Completion work must remain possible when every run slot is occupied. + if (!this.context.agent) { + this.pending?.release(requestId, claimId); + throw codedError('AGENT_UNAVAILABLE', 'The agent is not available on this desktop.', true); + } + const generation = this.generation; + this.activeRuns += 1; + try { + await this.context.agent.reply({ + sessionId: item.sessionId, + requestId, + decision, + }); + if (!this.stopped && generation === this.generation && this.pending.resolve(requestId)) { + this.onEvent('evt.permission_resolved', { requestId, decision }); + } + } catch (error) { + // The Host did not take the answer, so the request is still waiting for one and + // the phone is allowed to give it again. + this.pending?.release(requestId, claimId); + throw error; + } finally { + this.activeRuns -= 1; + } + } + + /** Answers a pending questionnaire and observes the continuation turn. */ + async answer(requestId, answers) { + this.assertRunning(); + const item = this.pending?.claim(requestId, 'question'); + if (!item) { + throw codedError('REQUEST_NOT_FOUND', 'That request is no longer waiting for an answer.', false); + } + const claimId = item.claimId; + if (!this.context.agent) { + this.pending?.release(requestId, claimId); + throw codedError('AGENT_UNAVAILABLE', 'The agent is not available on this desktop.', true); + } + // Keep the owning chat on the request: the bounded session index can evict it. + const chatId = item.request.chatId ?? this.chatIdForSession(item.sessionId); + const generation = this.generation; + let continued = false; + try { + const result = await this.observeHosted(chatId, (options) => this.context.agent.answer( + { sessionId: item.sessionId, requestId, answers: normaliseAnswers(answers) }, + { ...options, onEvent: (event) => { + if (event.type === 'turn.started') continued = true; + options.onEvent(event); + } }, + ), { continuation: true }); + if (this.stopped || generation !== this.generation) return result; + if (this.pending.resolve(requestId)) this.onEvent('evt.question_answered', { requestId }); + return result; + } catch (error) { + if (this.stopped || generation !== this.generation) throw error; + // Once continuation started, or Host explicitly reports an unknown outcome, + // replaying the answer could repeat a side effect. Only pre-admission failures + // leave the original questionnaire answerable. + if (continued || /** @type {any} */ (error)?.code === 'AGENT_OUTCOME_UNKNOWN') { + if (this.pending.resolve(requestId)) this.resolveEvent(requestId, item, 'continuation_failed'); + } else { + this.pending?.release(requestId, claimId); + } + throw error; + } + } + + chatIdForSession(sessionId) { + for (const [chatId, value] of this.sessions) { + if (value === sessionId) return chatId; + } + return 'unknown'; + } + + handleAgentEvent(chatId, event, controller) { + if (this.stopped) return; + switch (event.type) { + case 'turn.started': + this.aborts.set(event.turnId, controller); + this.onEvent('evt.turn_started', { chatId, turnId: event.turnId, startedAt: Date.now() }); + break; + + case 'text.delta': + this.onEvent('evt.message_chunk', { + chatId, + turnId: event.turnId, + messageId: messageIdFor(chatId, event.turnId), + text: event.text, + }); + break; + + case 'message.committed': + this.onEvent('evt.message_committed', { + message: { + messageId: messageIdFor(chatId, event.turnId), + chatId, + role: 'assistant', + text: event.text, + ts: Date.now(), + // The turn this belongs to, so that the phone can group the answer with the + // tool calls the turn made. + turnId: event.turnId, + authorId: authorIdFor(chatId), + }, + }); + break; + + case 'tool.started': { + const callId = this.startTool(event.turnId, event.name); + this.onEvent('evt.tool_started', { + chatId, + turnId: event.turnId, + toolName: event.name, + callId, + }); + break; + } + + case 'tool.finished': { + const call = this.finishTool(event.turnId, event.name); + // The host does not report how long a tool took, so the bridge times it itself: + // the phone shows a real duration rather than a blank. + const measured = call === undefined ? undefined : Date.now() - call.startedAt; + const durationMs = measured === undefined ? undefined : Math.max(0, measured); + this.onEvent('evt.tool_finished', { + chatId, + turnId: event.turnId, + toolName: event.name, + callId: call?.callId ?? `call_${(this.callCounter += 1)}`, + ok: event.ok === true, + ...(durationMs === undefined ? {} : { usage: { durationMs } }), + ...(event.error ? { error: String(event.error) } : {}), + }); + break; + } + + case 'permission.requested': { + if (this.pending?.seen(event.requestId)) break; + const request = { + requestId: event.requestId, + sessionId: event.sessionId, + turnId: event.turnId, + // The conversation that is waiting: the phone shows the request where it was + // asked, not in a list somewhere else. + chatId, + toolName: event.toolName, + toolInput: event.toolInput, + reason: event.reason, + }; + this.pending?.add('permission', request, event.sessionId); + const item = this.pending?.get(event.requestId); + if (item) this.pendingScopes.set(item, controller); + this.onEvent('evt.permission_requested', { request }); + break; + } + + case 'question.requested': { + if (this.pending?.seen(event.requestId)) break; + const request = { + requestId: event.requestId, + sessionId: event.sessionId, + turnId: event.turnId, + chatId, + mode: event.mode, + title: event.request.title, + steps: event.request.steps, + ...(typeof event.request.expiresAt === 'number' ? { expiresAt: event.request.expiresAt } : {}), + }; + this.pending?.add('question', request, event.sessionId); + const item = this.pending?.get(event.requestId); + if (item) this.pendingScopes.set(item, controller); + this.onEvent('evt.question_requested', { request }); + break; + } + + case 'turn.finished': + // A finished turn is no longer abortable, even while its result is arriving. + this.aborts.delete(event.turnId); + // A tool call that never reported back must not stay on the phone for ever. + this.clearTools(event.turnId); + this.onEvent('evt.turn_finished', { + chatId, + turnId: event.turnId, + status: event.status, + reason: event.status === 'error' ? 'error' : event.status === 'aborted' ? 'cancelled' : undefined, + text: event.text, + error: event.error, + }); + break; + + default: + break; + } + } +} + +export function messageIdFor(chatId, turnId) { + return `msg_${turnId ?? chatId}`; +} + +export function authorIdFor(chatId) { + return chatId.startsWith('bot:') ? chatId.slice(4) : undefined; +} + +/** + * Builds an error that carries the protocol's error code. + * + * @param {string} code + * @param {string} message + * @param {boolean} [retryable] + * @returns {Error & {code: string, retryable: boolean}} + */ +function codedError(code, message, retryable = false) { + const error = new Error(message); + return Object.assign(error, { code, retryable }); +} + +/** + * The answers in the shape the Host's agent takes. + * + * The Host expects every step of a questionnaire to be answered: the options that were + * chosen, or the free-text answer, or an explicit skip. An answer in any other shape + * is translated here, so the Host is never handed something it will refuse. + * + * A phone build that predates the current app sends the answer as a single value with + * the option it chose. That shape is still understood, so a phone that has not been + * rebuilt can still answer. + * + * @param {unknown} answers + * @returns {Array<{stepId: string, selectedOptionIds: string[], selectedOther: boolean, otherText?: string, skipped?: boolean}>} + */ +function normaliseAnswers(answers) { + if (!Array.isArray(answers)) return []; + const normalised = []; + for (const answer of answers) { + const stepId = typeof answer?.stepId === 'string' ? answer.stepId : ''; + if (!stepId) continue; + const selected = Array.isArray(answer?.selectedOptionIds) + ? answer.selectedOptionIds.filter((id) => typeof id === 'string') + : []; + const legacyOption = typeof answer?.optionId === 'string' ? answer.optionId : ''; + const otherText = typeof answer?.otherText === 'string' ? answer.otherText : ''; + const legacyValue = typeof answer?.value === 'string' ? answer.value : ''; + const options = selected.length ? selected : legacyOption ? [legacyOption] : []; + // In the older shape the value is the label of the option that was chosen, so it + // is only a free-text answer when no option was chosen at all. + const text = otherText || (legacyOption ? '' : legacyValue); + normalised.push({ + stepId, + selectedOptionIds: options, + selectedOther: answer?.selectedOther === true || (!options.length && Boolean(text)), + ...(text ? { otherText: text } : {}), + ...(answer?.skipped === true ? { skipped: true } : {}), + }); + } + return normalised; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-brief.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-brief.mjs new file mode 100644 index 0000000..5305c94 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-brief.mjs @@ -0,0 +1,45 @@ +/** + * The brief a Bot is given when it answers the phone. + * + * A Bot is who it is because of its instructions, its role and its name, and the brief + * is where those reach the model. The bridge builds the brief from the same record the + * desktop keeps, so the Bot that answers from the phone is the Bot that answers from + * the desktop: same name, same role, same instructions. + * + * The model's own settings do not travel in the brief: the run itself carries the Bot's + * model and effort, resolved out of the Host's catalog (see `bot-model.mjs`). + * + * The team's own engine builds a longer brief — with the group's rules, the team's + * roster and the memory it selected. A turn the team runs carries all of that; a turn + * the bridge runs carries what makes the Bot itself. That is the trade: a turn the + * bridge runs can be approved from the phone, and it is not written into the desktop + * Bot app's own list of messages. + */ + +/** + * @param {any} bot the Bot's record: name, id, role, instructions, isMain + * @param {string} text what the user said + * @returns {string} + */ +export function buildBotBrief(bot, text) { + const name = String(bot?.name ?? 'the Bot'); + const id = String(bot?.id ?? bot?.botId ?? ''); + const role = String(bot?.role ?? '').trim(); + const instructions = String(bot?.instructions ?? '').trim(); + const lines = [ + '', + id ? `You are ${name} (${id}) on the user's MiniMax Bot team.` : `You are ${name} on the user's MiniMax Bot team.`, + role ? `Your role: ${role}.` : '', + bot?.isMain ? 'You are the main Bot of the team.' : '', + '', + 'Your instructions:', + instructions || '(none)', + '', + 'This is a private chat with the user.', + 'Answer as yourself, in your own voice, and do the work that was asked of you.', + '', + '', + String(text ?? ''), + ]; + return lines.filter((line, index, all) => !(line === '' && all[index - 1] === '')).join('\n'); +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-model.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-model.mjs new file mode 100644 index 0000000..8f31c81 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-model.mjs @@ -0,0 +1,184 @@ +/** + * The model a Bot's turn runs on. + * + * The roster names a Bot's model as `providerId/modelId` and its effort as a level, but + * the Host's agent takes a *selection*: the catalog entry, plus the effort to run it at. + * The official engine resolves the same way — the model the Bot names, at the Bot's + * effort, and nothing at all on a Host that does not let a run pick its model — so a + * hosted turn runs on the Bot's own model instead of on whatever the session happens to + * carry. + * + * The catalog is asked for at most once a minute: a busy thread must not ask the Host + * for the same list on every turn. + */ + +/** How long the catalog is kept before it is asked for again. */ +const CATALOG_TTL_MS = 60000; + +/** + * A catalog entry as the Host's composer would select it. + * + * Thinking is turned on when the model offers it, at the model's own default effort. + * + * @param {any} entry + */ +function toSelection(entry) { + return { + providerId: entry.providerId, + modelId: entry.modelId, + ...(entry.variants?.includes('thinking') ? { variant: 'thinking' } : {}), + ...(entry.thinking?.default ? { thinking: { effort: entry.thinking.default } } : {}), + }; +} + +/** + * The efforts a catalog entry offers. + * + * @param {any} entry + * @returns {string[]} + */ +function effortOptions(entry) { + const levels = Array.isArray(entry?.thinking?.efforts) + ? entry.thinking.efforts.filter((level) => typeof level === 'string') + : []; + return entry?.variants?.includes('') ? [...levels, 'off'] : levels; +} + +/** + * The selection that runs `entry` at `effort`. + * + * `off` is thinking turned off; every other effort is a level the model offers. + * + * @param {any} entry + * @param {string} effort + * @returns {any | undefined} undefined when the model does not offer that effort + */ +function withEffort(entry, effort) { + if (!entry || !effortOptions(entry).includes(effort)) return undefined; + if (effort === 'off') return { providerId: entry.providerId, modelId: entry.modelId, variant: '' }; + return { + providerId: entry.providerId, + modelId: entry.modelId, + ...(entry.variants?.includes('thinking') ? { variant: 'thinking' } : {}), + thinking: { effort }, + }; +} + +/** + * A catalog entry as the phone needs it. + * + * The id is the `provider/model` the team tool takes, and the efforts are the levels + * this model actually offers — a phone cannot offer an effort the model does not have. + * + * @param {any} entry + * @returns {{id: string, name: string, providerId: string, modelId: string, efforts: string[], defaultEffort?: string} | undefined} + */ +function describeEntry(entry) { + if (!entry?.providerId || !entry?.modelId) return undefined; + return { + id: `${entry.providerId}/${entry.modelId}`, + name: String(entry.displayName ?? entry.modelId), + providerId: String(entry.providerId), + modelId: String(entry.modelId), + efforts: effortOptions(entry), + ...(entry.thinking?.default ? { defaultEffort: String(entry.thinking.default) } : {}), + }; +} + +/** + * Picks the model a Bot's turn runs on. + */ +export class ModelPicker { + /** + * @param {any} context the Mini App context, read afresh because the Host hands the + * agent in with the context and a run may outlive it + */ + constructor(context) { + this.context = context; + /** @type {{at: number, value: Promise} | undefined} */ + this.cache = undefined; + } + + /** Whether this Host lets a run pick its model. */ + get supported() { + return typeof this.context?.agent?.models === 'function'; + } + + /** + * The Host's model catalog, kept for a minute. + * + * @returns {Promise} + */ + async catalog() { + if (!this.supported) return undefined; + const now = Date.now(); + if (!this.cache || now - this.cache.at > CATALOG_TTL_MS) { + const value = Promise.resolve(this.context.agent.models()); + this.cache = { at: now, value }; + // A failed lookup is not served from the cache for the next minute. + value.catch(() => { + if (this.cache?.value === value) this.cache = undefined; + }); + } + return this.cache.value; + } + + /** + * The catalog as the phone needs it: the models on offer and the efforts each one + * has. + * + * A Host that does not let a run pick its model says so instead of sending an empty + * list, so the phone can say that the model cannot be chosen here instead of showing + * an empty list that looks like a broken one. + * + * @returns {Promise<{supported: boolean, default?: any, models: any[]}>} + */ + async describe() { + if (!this.supported) return { supported: false, models: [] }; + const catalog = await this.catalog().catch(() => undefined); + if (!catalog) return { supported: false, models: [] }; + const entries = Array.isArray(catalog.models) ? catalog.models : []; + const models = entries.map((entry) => describeEntry(entry)).filter(Boolean); + // The default is named the way the catalog names it, not by its bare id. + const wanted = catalog.default; + const fallback = wanted + ? entries.find( + (entry) => + entry?.providerId === wanted.providerId && entry?.modelId === wanted.modelId, + ) + : undefined; + return { + supported: true, + ...(describeEntry(fallback ?? wanted) ? { default: describeEntry(fallback ?? wanted) } : {}), + models, + }; + } + + /** + * The selection a Bot's turn runs on. + * + * The Bot's own model wins; its effort is folded into it. A model the catalog does not + * offer, or an effort it does not offer, falls back to the plain model rather than + * failing the turn: a stale setting should not cost the user their conversation. + * + * @param {{model?: string, effort?: string}} bot + * @returns {Promise} undefined when the Host does not let a run pick + * its model, in which case the session keeps the model it already has + */ + async selectionFor(bot) { + if (!this.supported) return undefined; + const catalog = await this.catalog(); + if (!catalog) return undefined; + const models = Array.isArray(catalog.models) ? catalog.models : []; + const wanted = String(bot?.model ?? '').trim(); + const effort = String(bot?.effort ?? '').trim().toLowerCase(); + const entry = !wanted || /^(default|默认)$/i.test(wanted) + ? catalog.default + : (models.find((model) => `${model.providerId}/${model.modelId}`.toLowerCase() === wanted.toLowerCase()) ?? + models.find((model) => String(model.modelId).toLowerCase() === wanted.toLowerCase()) ?? + models.find((model) => String(model.displayName ?? '').toLowerCase() === wanted.toLowerCase())); + if (!entry) return undefined; + if (!effort || effort === 'default') return toSelection(entry); + return withEffort(entry, effort) ?? toSelection(entry); + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-router.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-router.mjs new file mode 100644 index 0000000..79eca00 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bot-router.mjs @@ -0,0 +1,536 @@ +/** + * Routes a phone's turn to the Bot it was addressed to. + * + * The bridge used to hand every message to the Host's general assistant, so picking a + * Bot on the phone changed nothing but the colour of the bubble. A turn now goes to + * the Bot the phone picked, and it can go two ways: + * + * - **team** — the `@minimax-bot` team runs it: `message_bot` for a Bot's own chat, + * `post_group` for a group. The Bot answers with its own instructions, model and + * effort, in its own session, and its reply lands in its own conversation. + * - **hosted** — the desktop runs it under the bridge, in that Bot's own session. The + * Host only lets the Mini App that owns a run answer its questions, so this is where + * a permission check or a questionnaire the Bot raises can be answered from the phone. + * + * A group is always the team's: who speaks first, who the admin is and who hands over + * to whom are the product's rules, and the bridge does not make them up. + * + * The team is reached through the Host's tool API — the same channel the roster + * already uses — because that is where the official plugin exposes its engine. The + * team answers a turn with a durable receipt and finishes it later, so the bridge asks + * for the result until the task settles. Nothing here is invented: the reply is the + * Bot's own text, and a task that fails is reported as a failure. + */ + +import { CHAT_ROUTING } from './protocol/index.js'; +import { buildBotBrief } from './bot-brief.mjs'; +import { ModelPicker } from './bot-model.mjs'; + +/** The prefix of a chat id that names one Bot's own conversation. */ +export const BOT_CHAT_PREFIX = 'bot:'; + +/** The prefix of a chat id that names a group conversation. */ +export const GROUP_CHAT_PREFIX = 'group:'; + +/** + * How long the team is asked to hold a task query before it answers. + * + * The team's own limit is twenty seconds; asking for the same window means the bridge + * waits for a state change instead of polling the team in a tight loop. + */ +const QUERY_WAIT_MS = 20000; + +/** + * The task statuses that will not change on their own. + * + * `waiting_for_user` and `paused` are settled as far as the phone is concerned: the + * turn is over until the user acts, and the phone is told what to do about it. + */ +const SETTLED = new Set(['done', 'failed', 'unknown', 'cancelled', 'waiting_for_user', 'paused']); + +/** + * How many phone conversations the bridge keeps a session for. + * + * A phone's conversation lives in the Host's session it ran in, and the bridge keeps + * the name of it. The chat id is chosen by the client, so this is a ceiling and not a + * promise. + */ +const MAX_PHONE_SESSIONS = 64; + +export class BotRouter { + /** + * @param {import('./contracts.d.ts').HostContext} context the Mini App context + * @param {object} options + * @param {import('./tools.mjs').ToolGateway} [options.gateway] the team tool gateway + * @param {() => Promise<{bots?: Array<{botId?: string, name?: string}>}>} [options.roster] + * the team roster, used to name a mention the way the team reads it + */ + constructor(context, { gateway, roster } = {}) { + this.context = context; + this.gateway = gateway; + this.roster = roster ?? (async () => ({ bots: [] })); + /** The model a hosted turn runs on, picked the way the product picks it. */ + this.models = new ModelPicker(context); + /** + * The Host session each phone conversation runs in. + * + * A phone's conversation is its own: it does not continue the session the desktop + * last used, because that session belongs to whatever the user was doing on the + * desktop. + * + * @type {Map} + */ + this.phoneSessions = new Map(); + /** @type {Map} */ + this.turns = new Map(); + this.polls = new Map(); + /** Per-chat tails hold the session-establishing run, not just its start call. */ + /** @type {Map>} */ + this.hostedQueues = new Map(); + this.hostedCount = 0; + } + + /** Whether a turn can be handed to the team right now. */ + get available() { + return Boolean(this.gateway?.available); + } + + /** + * Which team tool runs a turn in this chat, and who it is for. + * + * @param {string} chatId + * @returns {{tool: string, kind: 'bot' | 'group', targetId: string} | undefined} + */ + route(chatId) { + const id = String(chatId ?? ''); + for (const [prefix, tool] of Object.entries(CHAT_ROUTING)) { + if (id.startsWith(prefix)) { + return { + tool, + kind: prefix === BOT_CHAT_PREFIX ? 'bot' : 'group', + targetId: id.slice(prefix.length), + }; + } + } + return undefined; + } + + /** + * Runs a turn in the Bot's own session, with the bridge owning the run. + * + * The team runs a turn in the Bot's session but keeps the run to itself, so a + * permission check or a questionnaire raised by that Bot is answered in the desktop + * Bot app and not from the phone. A run the bridge owns is the only one the phone can + * answer, because the Host's agent API lets the Mini App that started a run see its + * questions and answer them. + * + * The Bot is still the Bot: the brief carries its name, role and instructions, the + * model is the Bot's own, and the conversation is the phone's own — a session the + * bridge keeps for this chat, not the session the desktop last used. The desktop's + * session belongs to whatever the user was doing on the desktop: a phone message + * dropped into it is answered in the middle of that conversation, which is how a + * short message from the phone comes back as a repeat of an earlier answer, with the + * state that was true when that answer was written. + * + * @param {{chatId: string, botId: string, text: string, onEvent?: (event: any) => void, signal?: AbortSignal}} input + */ + hosted(input) { + return this.inHostedChat(input.chatId, () => this.hostedTurn(input), input.signal); + } + + /** + * Serialize a chat through result/session installation. Different chats remain + * parallel; queued work is bounded and checks its owning signal before admission. + */ + async inHostedChat(chatId, execute, signal) { + signal?.throwIfAborted(); + if (this.hostedCount >= 32) throw codedError('BUSY', 'Too many hosted turns are already waiting.', true); + this.hostedCount += 1; + const previous = this.hostedQueues.get(chatId) ?? Promise.resolve(); + const work = previous.then(() => { signal?.throwIfAborted(); return execute(); }); + const tail = work.then(() => {}, () => {}); + this.hostedQueues.set(chatId, tail); + try { return await work; } finally { + this.hostedCount -= 1; + if (this.hostedQueues.get(chatId) === tail) this.hostedQueues.delete(chatId); + } + } + + /** @param {{chatId: string, botId: string, text: string, onEvent?: (event: any) => void, signal?: AbortSignal}} input */ + async hostedTurn({ chatId, botId, text, onEvent, signal }) { + if (!this.context.agent) { + throw codedError( + 'AGENT_UNAVAILABLE', + 'The desktop agent is not available, so the Bot cannot be run from here.', + true, + ); + } + signal?.throwIfAborted(); + const bot = (await this.roster())?.bots?.find((entry) => entry?.botId === botId); + signal?.throwIfAborted(); + if (!bot) { + throw codedError('NOT_FOUND', 'This Bot is not on the team.'); + } + // The phone's own conversation with this Bot. It starts empty and grows with what + // the phone says; the desktop's session is not part of it. + const sessionId = this.phoneSessions.get(chatId); + // The Bot's own model, at its own effort, resolved out of the Host's catalog: the + // roster names the model as text, while the run takes a selection. + const model = await this.models.selectionFor(bot); + signal?.throwIfAborted(); + let result; + try { + result = await this.context.agent.run( + { + text: buildBotBrief(bot, text), + ...(sessionId ? { sessionId } : {}), + ...(model ? { model } : {}), + }, + { signal, onEvent }, + ); + } catch (error) { + // The Host rejects a model it no longer offers before the turn starts; say which + // setting to fix rather than reporting a turn that never began as a broken run. + if (model && errorCode(error) === 'INVALID_ARGUMENTS') { + throw codedError( + 'MODEL_UNAVAILABLE', + `This Bot's model is not available on this desktop any more: ${model.modelId}.`, + false, + ); + } + throw error; + } + // The Host names the session the turn ran in; the next turn of this chat continues it. + if (!signal?.aborted && result?.sessionId) this.rememberPhoneSession(chatId, result.sessionId); + return { + status: result?.status ?? 'finished', + text: result?.text ?? '', + sessionId: result?.sessionId ?? sessionId, + turnId: result?.turnId, + pendingQuestion: result?.pendingQuestion, + error: result?.error, + usage: result?.usage, + }; + } + + /** + * Remembers the session a phone's conversation runs in. + * + * The chat id is chosen by the client, so the number of sessions the bridge keeps is + * a ceiling and not a promise: the least recently used chat goes first. + * + * @param {string} chatId + * @param {string} sessionId + */ + rememberPhoneSession(chatId, sessionId) { + if (!chatId || !sessionId) return; + this.phoneSessions.set(chatId, sessionId); + while (this.phoneSessions.size > MAX_PHONE_SESSIONS) { + const oldest = this.phoneSessions.keys().next(); + if (oldest.done) break; + this.phoneSessions.delete(oldest.value); + } + } + + /** + * Hands the turn to the Bot or the group it was addressed to. + * + * @param {{chatId: string, text: string, mentions?: string[], requestId?: string, signal?: AbortSignal}} input + * @returns {Promise<{batchId: string, taskId?: string, tool: string, kind: string, targetId: string, chatId: string}>} + */ + async send({ chatId, text, mentions, requestId, signal }) { + signal?.throwIfAborted(); + const route = this.route(chatId); + if (!route) { + throw codedError( + 'INVALID_ARGUMENTS', + 'This conversation does not belong to a Bot or a group, so there is nobody to answer.', + ); + } + if (!this.available) { + throw codedError( + 'TEAM_UNAVAILABLE', + 'The @minimax-bot team is not running on this desktop. Open MiniMax Bot to link it.', + true, + ); + } + + // The team reads a mention as a name, not as an id, so the ids the phone sends are + // translated before the text goes out. A mention of a Bot the roster does not know + // is dropped: the team would ignore it anyway. + const body = + route.kind === 'group' ? await this.withMentions(text, mentions) : String(text ?? ''); + + const payload = + route.kind === 'group' + ? { groupId: route.targetId, text: body, requestId } + : { botId: route.targetId, text: body, requestId }; + + // Stop may invalidate local admission while a mention lookup is pending. Do + // not forward this signal to the mutation: stopping observation does not + // authorize cancelling official work once the team has accepted it. + signal?.throwIfAborted(); + const result = await this.gateway.call(route.tool, payload); + if (!result.ok) { + throw codedError( + result.code ?? 'TEAM_UNAVAILABLE', + result.message ?? 'The team did not take the message.', + result.disposition !== 'not_dispatched', + ); + } + if (result.isError === true) { + throw codedError('TEAM_ERROR', result.message ?? 'The team refused the message.', true); + } + + const receipt = readReceipt(result); + const taskId = receipt.tasks?.[0]?.task_id; + const batchId = String(receipt.batch_id ?? ''); + if (!batchId) { + throw codedError('TEAM_ERROR', 'The team did not return a task for the message.', true); + } + const turn = { batchId, taskId, chatId, tool: route.tool, kind: route.kind, targetId: route.targetId }; + this.turns.set(turnIdOf(turn), turn); + return turn; + } + + /** + * Waits for the task to settle and reports what the Bot said. + * + * @param {{batchId: string, taskId?: string, chatId: string}} turn + * @param {{signal?: AbortSignal}} [options] + */ + async wait(turn, { signal } = {}) { + const key = turnIdOf(turn); + this.polls.set(key, (this.polls.get(key) ?? 0) + 1); + try { + return await this.poll(turn, { signal }); + } finally { + const remaining = (this.polls.get(key) ?? 1) - 1; + if (remaining > 0) this.polls.set(key, remaining); + else { + this.polls.delete(key); + this.turns.delete(key); + } + } + } + + /** @param {{batchId: string, taskId?: string, chatId: string}} turn @param {{signal?: AbortSignal}} [options] */ + async poll(turn, { signal } = {}) { + /** @type {string} */ + let text = ''; + let task; + let offset = 0; + for (;;) { + if (signal?.aborted) throw codedError('CANCELLED', 'The turn was stopped.'); + const result = await this.gateway.call( + 'get_tasks', + { + ...(turn.taskId ? { taskId: turn.taskId } : { batchId: turn.batchId }), + waitMs: QUERY_WAIT_MS, + resultOffset: offset, + }, + signal ? { signal } : {}, + ); + if (!result.ok) { + throw codedError( + result.code ?? 'TEAM_UNAVAILABLE', + result.message ?? 'The team did not report the result.', + true, + ); + } + if (result.isError === true) { + throw codedError('TEAM_ERROR', result.message ?? 'The team could not report the result.', true); + } + + const view = readReceipt(result); + task = view.tasks?.[0]; + if (!task) { + throw codedError('TEAM_ERROR', 'The team reported no task for the message.', true); + } + if (typeof task.result === 'string') text += task.result; + // A long reply arrives in pages; the offset says whether more is waiting. + const next = task.next_result_offset; + if (typeof next === 'number' && next > offset) { + offset = next; + continue; + } + if (!SETTLED.has(String(task.status))) { + // Still running: ask again and let the team hold the query. + continue; + } + break; + } + + const status = String(task.status); + if (status === 'done') { + return { + status: 'finished', + text, + sessionId: task.session_id, + messageId: task.result_message_id, + // The task the turn ran under. The desktop names its own record of that turn + // after it, so it is what finds the exchange the desktop recorded. + taskId: task.task_id, + botId: task.bot_id, + chatId: task.chat_id ?? turn.chatId, + }; + } + if (status === 'cancelled') { + return { status: 'cancelled', text, taskId: task.task_id, chatId: turn.chatId }; + } + if (status === 'waiting_for_user') { + return { + status: 'waiting_for_user', + text, + taskId: task.task_id, + chatId: turn.chatId, + note: 'The Bot is waiting for an answer. Open MiniMax Bot on the desktop to answer it.', + }; + } + if (status === 'paused') { + return { + status: 'paused', + text, + taskId: task.task_id, + chatId: turn.chatId, + note: 'The group paused the discussion. Send another message to continue it.', + }; + } + throw codedError( + 'TURN_FAILED', + task.error ? `The Bot could not finish the turn: ${task.error}` : 'The Bot could not finish the turn.', + true, + ); + } + + /** + * Stops a running turn. + * + * @param {{batchId: string, taskId?: string}} turn + */ + async cancel(turn) { + const result = await this.gateway.call('cancel_tasks', { + ...(turn.taskId ? { taskId: turn.taskId } : { batchId: turn.batchId }), + }); + if (!result.ok || result.isError === true || readReceipt(result)?.ok === false) { + throw codedError( + result.code ?? 'TEAM_ERROR', + result.message ?? readReceipt(result)?.error ?? 'The team did not stop the turn.', + true, + ); + } + const tasks = readReceipt(result).tasks; + const completedBeforeCancel = Array.isArray(tasks) && tasks.length > 0 && + tasks.every(task => SETTLED.has(task.status) && task.status !== 'cancelled'); + if (!this.polls.has(turnIdOf(turn))) this.turns.delete(turnIdOf(turn)); + return !completedBeforeCancel; + } + + /** + * The text the team receives, with the mentions named the way it reads them. + * + * @param {string} text + * @param {string[] | undefined} mentions + */ + async withMentions(text, mentions) { + const ids = Array.isArray(mentions) ? mentions.map((id) => String(id ?? '').trim()).filter(Boolean) : []; + if (!ids.length) return String(text ?? ''); + let bots = []; + try { + bots = (await this.roster())?.bots ?? []; + } catch { + // A roster that cannot be read must not lose the message: the team decides who + // speaks when nothing is mentioned. + bots = []; + } + const names = []; + for (const id of ids) { + const bot = bots.find((entry) => entry?.botId === id); + const name = String(bot?.name ?? '').trim(); + if (name && !names.includes(name)) names.push(name); + } + if (!names.length) return String(text ?? ''); + return `${names.map((name) => `@${name}`).join(' ')}\n${text}`; + } +} + +/** + * The task receipt, out of whatever envelope the tool answered in. + * + * The team wraps its answer in `value`, but the bridge does not insist on it: a tool + * that answers with the receipt itself is read just as well. + * + * @param {{content?: unknown, structuredContent?: unknown}} result + */ +function readReceipt(result) { + const data = extractData(result); + const receipt = data && typeof data === 'object' && 'value' in data ? data.value : data; + if (!receipt || typeof receipt !== 'object') return {}; + return /** @type {Record} */ (receipt); +} + +/** + * Pulls a JSON object out of a tool result, whatever envelope it arrived in. + * + * @param {unknown} result + */ +function extractData(result) { + if (!result || typeof result !== 'object') return undefined; + const holder = /** @type {any} */ (result); + if (holder.structuredContent && typeof holder.structuredContent === 'object') { + const structured = holder.structuredContent; + if ('data' in structured && structured.data && typeof structured.data === 'object') { + return structured.data; + } + if (Array.isArray(structured)) return structured; + return structured; + } + const content = holder.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block && typeof block.text === 'string') { + try { + const parsed = JSON.parse(block.text); + if (parsed && typeof parsed === 'object') return parsed; + } catch { + /* not JSON; keep looking */ + } + } + } + } + if (typeof content === 'string') { + try { + return JSON.parse(content); + } catch { + return undefined; + } + } + return undefined; +} + +/** The key a running turn is remembered under. */ +function turnIdOf(turn) { + return turn.taskId ?? turn.batchId; +} + +/** + * Builds an error that carries the protocol's error code. + * + * @param {string} code + * @param {string} message + * @param {boolean} [retryable] + */ +function codedError(code, message, retryable = false) { + return Object.assign(new Error(message), { code, retryable }); +} + +/** + * The code an error carries, if it carries one. + * + * @param {unknown} error + * @returns {string | undefined} + */ +function errorCode(error) { + const code = /** @type {any} */ (error)?.code; + return typeof code === 'string' ? code : undefined; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bridge.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bridge.mjs new file mode 100644 index 0000000..27f2e89 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/bridge.mjs @@ -0,0 +1,1708 @@ +/** + * The bridge. + * + * Owns the team connection, runs Bot turns, answers every operation the client can ask + * for, and publishes the result as events. The desktop is the single writer of chat + * state; the phone is a projection of it. + */ + +import { createHash } from 'node:crypto'; +import { AgentRunner, authorIdFor } from './agent.mjs'; +import { BotRouter } from './bot-router.mjs'; +import { readCapabilities } from './capabilities.mjs'; +import { saveConfig } from './config.mjs'; +import { desktopBots, desktopGroups, desktopMessages, readBotPicture, readDesktopState } from './desktop-team.mjs'; +import { Ledger } from './ledger.mjs'; +import { PendingSet } from './pending.mjs'; +import { RelayClient } from './relay-client.mjs'; +import { OP_TOOL, ToolGateway } from './tools.mjs'; +import { + MAX_ACTIVITY_CALLS, + createFrame, + isValidClientMessageId, + newId, + trimTurnActivity, + turnOrderOf, +} from './protocol/index.js'; + +const OP_DEDUPE_TTL_MS = 10 * 60 * 1000; +const HISTORY_LIMIT = 200; + +/** + * Where a Bot's turn runs when the phone sends it a message, unless the phone says + * otherwise. + * + * `team` runs the turn in the team's own engine, where the desktop Bot app keeps the + * whole exchange: the phone's message and the Bot's answer are both in the desktop's + * own conversation, which is what the phone is a remote for. + * + * `hosted` runs the turn on the desktop under the bridge, in the Bot's own session, so + * that a permission check or a questionnaire the Bot raises can be answered from the + * phone — the Host only lets the Mini App that owns a run answer its questions. What + * it costs is the transcript: the official engine does not write a turn the bridge + * owns into the Bot's own list of messages. + * + * The phone can choose either, per Bot, and the choice is remembered. + */ +const DEFAULT_RUN_MODE = 'team'; + +/** + * How much state a client can make the bridge hold. + * + * Every map below is keyed by something a client chose: a chat id, a message id, an + * operation id. A client that keeps inventing new ones makes the desktop's memory grow + * without bound, and the desktop is the one that pays for it. Each of these is a ceiling + * on how many keys are kept at once; the oldest entry is dropped when the ceiling is + * reached, so the state that survives is the state that was used most recently. + */ +const MAX_TRACKED_CHATS = 50; +const MAX_REACTION_MESSAGES = 500; +const MAX_OP_CACHE_ENTRIES = 500; + +/** + * The longest emoji a reaction may carry, in code points. + * + * The emoji is stored verbatim and broadcast to every device in the room, so a client + * that sends a megabyte of text as an "emoji" would put it on every phone. + */ +const MAX_REACTION_EMOJI_LENGTH = 32; + +/** + * The first value a Bot carries under one of several names. + * + * The team tool is not the only writer of a Bot's record, and each one spells the + * presentation fields differently: the octopus's look is `skin` in one place and + * `look` in another, the colour is `color` here and `colour` there. + * + * @param {any} bot + * @param {string[]} names + * @returns {string | undefined} + */ +function firstString(bot, names) { + for (const name of names) { + const value = bot?.[name]; + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return undefined; +} + +/** + * Maps one Bot from the team tool's shape to the protocol's shape. + * + * The team tool reports `bot_id` and `main`; the protocol the phone speaks uses + * `botId` and `isMain`. The tool also reports the model as `provider/model`, which + * is kept as it is: a phone that shows "custom_provider:ollama-cloud/muse-spark-open" + * is telling the truth about which model the Bot runs on. + * + * The Bot's look is carried too, so the phone draws the same octopus the desktop + * draws. A picture the phone could not load — a path on the desktop, say — is left + * out, so the app falls back to the octopus instead of a broken frame. + * + * @param {any} bot + * @returns {{botId: string, name: string, role?: string, skin?: string, color?: string, avatar?: string, model?: string, instructions?: string, effort?: string, isMain: boolean}} + */ +function mapBot(bot) { + const avatar = firstString(bot, ['avatar_url', 'avatarUrl', 'avatar', 'image_url', 'imageUrl']); + const loadable = avatar && /^(https?:\/\/|data:image\/)/i.test(avatar) ? avatar : undefined; + return { + botId: String(bot?.bot_id ?? bot?.botId ?? ''), + name: String(bot?.name ?? ''), + role: bot?.role ? String(bot.role) : undefined, + skin: firstString(bot, ['skin', 'look', 'skin_id', 'skinId']), + color: firstString(bot, ['color', 'colour']), + avatar: loadable, + model: bot?.model ? String(bot.model) : undefined, + // What the Bot is for, in the Bot's own words: the brief a turn runs carries it. + instructions: bot?.instructions ? String(bot.instructions) : undefined, + effort: bot?.effort ? String(bot.effort) : undefined, + isMain: bot?.main === true || bot?.isMain === true, + }; +} + +/** + * Maps one group from the team tool's shape to the protocol's shape. + * + * The tool reports the group's members and its admin by *name*, while the protocol + * carries ids, so the names are resolved against the roster. A name that is not in + * the roster is kept as it is rather than dropped, so a member is never lost. + * + * @param {any} group + * @param {Map} idByName Bot name to bot id. + */ +function mapGroup(group, idByName) { + const members = Array.isArray(group?.members) + ? group.members + : Array.isArray(group?.memberIds) + ? group.memberIds + : []; + const adminName = group?.admin ? String(group.admin) : ''; + return { + groupId: String(group?.group_id ?? group?.groupId ?? ''), + name: String(group?.name ?? ''), + notice: group?.notice ? String(group.notice) : undefined, + admin: idByName.get(adminName) ?? adminName, + memberIds: members.map((member) => idByName.get(String(member)) ?? String(member)), + replyMode: group?.reply_mode === 'all' || group?.replyMode === 'all' ? 'all' : 'admin', + }; +} + +/** + * The task list, in the protocol's shape. + * + * The team tool answers in snake_case (`task_id`, `bot_id`, `created_at`) and calls + * the task's status `status`; the phone reads camelCase and calls it `state`. The + * renaming happens here rather than in the tool's own vocabulary. + * + * @param {any} data + */ +function normaliseTasks(data) { + // The team's task tools answer inside a `value` envelope. + const source = + data && typeof data === 'object' && data.value && typeof data.value === 'object' ? data.value : data; + if (!source || typeof source !== 'object' || !Array.isArray(source.tasks)) return source; + const batchId = source.batch_id ?? source.batchId; + const requestId = source.request_id ?? source.requestId; + return { + ...source, + ...(batchId ? { batchId } : {}), + ...(requestId ? { requestId } : {}), + tasks: source.tasks.map((task) => { + if (!task || typeof task !== 'object') return task; + const { + task_id: taskId, + bot_id: botId, + status, + created_at: createdAt, + started_at: startedAt, + ended_at: endedAt, + result_message_id: resultMessageId, + chat_id: chatId, + ...rest + } = task; + const state = status ?? rest.state; + return { + ...rest, + ...(taskId !== undefined ? { taskId } : {}), + ...(state !== undefined ? { state } : {}), + ...(botId ? { botIds: [botId] } : {}), + ...(createdAt !== undefined ? { createdAt } : {}), + updatedAt: endedAt ?? startedAt ?? createdAt ?? 0, + ...(resultMessageId ? { resultMessageId } : {}), + ...(chatId ? { chatId } : {}), + }; + }), + }; +} + +/** + * The helper list, in the protocol's shape. + * + * The team tool answers with a bare array of helpers, each with a `helper_id`, a + * `status` and a duration in seconds. The phone reads an object with a `helpers` + * list, so the array is wrapped and the fields are renamed. + * + * @param {any} data + */ +function normaliseHelpers(data) { + const list = Array.isArray(data) + ? data + : Array.isArray(data?.helpers) + ? data.helpers + : []; + const rest = data && typeof data === 'object' && !Array.isArray(data) ? data : {}; + return { + ...rest, + helpers: list.map((helper) => { + if (!helper || typeof helper !== 'object') return helper; + const { helper_id: helperId, status, seconds, report, error, ...fields } = helper; + return { + ...fields, + ...(helperId !== undefined ? { helperId } : {}), + ...(status ? { state: status } : {}), + ...(seconds !== undefined ? { seconds } : {}), + ...(report ?? error ? { detail: report ?? error } : {}), + }; + }), + }; +} + +/** + * The schedule list, in the protocol's shape. + * + * The team tool answers with a bare array of schedules. Its `next_run` and + * `last_run` are ISO timestamps and the trigger is either a `time` or an + * `every_minutes`, while the protocol carries epoch milliseconds and one `spec`. + * + * @param {any} data + */ +function normaliseSchedules(data) { + const list = Array.isArray(data) + ? data + : Array.isArray(data?.schedules) + ? data.schedules + : []; + const rest = data && typeof data === 'object' && !Array.isArray(data) ? data : {}; + return { + ...rest, + schedules: list.map((schedule) => { + if (!schedule || typeof schedule !== 'object') return schedule; + const { + schedule_id: scheduleId, + bot_id: botId, + time, + every_minutes: everyMinutes, + next_run: nextRun, + last_run: lastRun, + ...fields + } = schedule; + const spec = time ?? (everyMinutes === undefined ? undefined : String(everyMinutes)); + return { + ...fields, + ...(scheduleId !== undefined ? { scheduleId } : {}), + ...(botId ? { targetBotId: botId } : {}), + ...(spec !== undefined ? { spec } : {}), + ...(nextRun ? { nextRunAt: toEpoch(nextRun) } : {}), + ...(lastRun ? { lastRunAt: toEpoch(lastRun) } : {}), + }; + }), + }; +} + +/** + * Normalises a tool's answer into the protocol's shape. + * + * Each tool answers in its own way: the task tools answer with an object, the + * helper and schedule tools with a bare array. The phone only ever sees one shape. + * + * @param {string} toolName + * @param {any} data + */ +function normaliseResult(toolName, data) { + switch (toolName) { + case 'dispatch_tasks': + case 'get_tasks': + case 'cancel_tasks': + return normaliseTasks(data); + case 'list_helpers': + return normaliseHelpers(data); + case 'list_schedules': + return normaliseSchedules(data); + default: + return data; + } +} + +/** + * An ISO timestamp as epoch milliseconds, or the value itself when it already is one. + * + * @param {unknown} value + */ +function toEpoch(value) { + if (typeof value === 'number') return value; + const parsed = Date.parse(String(value ?? '')); + return Number.isNaN(parsed) ? undefined : parsed; +} + +/** + * A topic key for a memory the client did not file under one. + * + * The team's memory tool insists on a topic: a memory is either filed under a topic + * or replaces an existing entry. The key has to match the tool's own pattern — a + * lowercase word, then lowercase letters, digits, dots, dashes and underscores — and + * it has to be unique, because writing the same topic twice without the entry's + * revision is a conflict. + */ +function generateTopicKey() { + return `phone.${Date.now().toString(36)}.${Math.random().toString(36).slice(2, 8)}`; +} + +/** How many batches the bridge asks about when it rebuilds the task list. */ +const MAX_BATCHES = 20; + +/** How often the team snapshot is retried while the Host prepares the generation. */ +const SNAPSHOT_RETRIES = 12; +/** The gap between two snapshot attempts. */ +const SNAPSHOT_RETRY_MS = 1500; + +/** + * What the room is told when the team roster cannot be read. + * + * A fixed sentence, because this travels to every phone in the room and the Host's own + * error text can name internal paths, tool identifiers and host-side detail. The + * detail is logged on the desktop and kept for the surface. + */ +const TEAM_ROSTER_UNREADABLE = + 'The team roster could not be read. Open MiniMax Bot on this desktop to try again.'; + +export class Bridge { + /** + * @param {any} context The Mini App context the Host supplies. + * @param {{config?: import('./config.mjs').BridgeConfig, logger?: import('./relay-client.mjs').RelayLogger}} [options] + */ + constructor(context, { config, logger } = {}) { + this.context = context; + this.config = config; + this.configEpoch = 0; + this.configCommit = Promise.resolve(); + this.stopped = false; + this.lifecycleEpoch = 0; + this.logger = logger ?? context.logger; + this.tools = new ToolGateway(context); + /** Active operations retain their room ledger; idle rooms need not stay in memory. @type {Map>} */ + this.roomLedgers = new Map(); + this.installRoom(config); + this.relay = new RelayClient({ + url: config?.relayUrl, + accountId: config?.accountId, + bridgeId: config?.bridgeId, + token: config?.bridgeToken, + name: config?.bridgeName, + logger: this.logger, + onFrame: (frame) => { + this.handleFrame(frame).catch((error) => { + this.logger.error(`frame handling failed: ${error instanceof Error ? error.message : error}`); + }); + }, + onStatus: (online) => { + this.emit('evt.bridge_status', { + desktopOnline: true, + agentAvailable: this.runner.available, + teamToolsAvailable: this.tools.available, + linkedAt: this.linkedAt, + // The live catalogue travels with the status, so a client can show which + // team tools this desktop can reach without a second round trip. + tools: this.tools.catalog, + }); + if (online) this.replayPending(); + }, + }); + + /** @type {Map>} chatId -> messages */ + this.history = new Map(); + /** @type {Map>} chatId -> the tool calls the bridge's own turns made */ + this.activity = new Map(); + /** @type {Map>} messageId -> emoji -> count */ + this.reactions = new Map(); + /** @type {Map, expiresAt: number}>} */ + this.opCache = new Map(); + /** @type {string | undefined} */ + this.snapshotError = undefined; + /** + * The detail behind the sentence the room was told. + * + * @type {string | undefined} + */ + this.snapshotErrorDetail = undefined; + /** @type {number} */ + this.snapshotAttempts = 0; + /** @type {ReturnType | undefined} */ + this.snapshotTimer = undefined; + this.linkedAt = Date.now(); + /** + * Called with a fresh token after a rotation, so the host can persist it. + * + * @type {((token: string) => Promise | void) | null} + */ + this.onToken = null; + } + + /** Canonical destination/subject identity, shared with the socket client. */ + roomIdentity(config = this.config) { + return RelayClient.prototype.identity({ url: config?.relayUrl, accountId: config?.accountId, bridgeId: config?.bridgeId }); + } + + /** Replace all authority-owned runtime state without cancelling official tasks. */ + installRoom(config, ledger = new Ledger(this.context.dataDir, { room: this.roomIdentity(config) })) { + if (this.runner) { + this.runner.stop(); + this.lifecycleEpoch += 1; + } + if (this.snapshotTimer) clearTimeout(this.snapshotTimer); + this.snapshotTimer = undefined; + this.snapshotAttempts = 0; + this.snapshotError = undefined; + this.snapshotErrorDetail = undefined; + const scope = { key: this.roomIdentity(config), filtered: Boolean(config?.relayUrl) }; + this.roomScope = scope; + this.ledger = ledger; + // A late operation and a newly active generation of the same room must share + // one write queue and live record set. Weak values avoid retaining idle history. + this.roomLedgers.set(scope.key, new WeakRef(ledger)); + for (const [key, reference] of this.roomLedgers) { + if (!reference.deref()) this.roomLedgers.delete(key); + } + this.pending = new PendingSet(); + this.router = new BotRouter(this.context, { gateway: this.tools, roster: async () => this.readTeam() }); + const current = () => this.roomScope === scope && !this.stopped; + this.runner = new AgentRunner(this.context, { + pending: this.pending, router: this.router, + onEvent: (type, payload, mode) => { if (current()) this.handleEvent(type, payload, mode); }, + onTurnFinished: info => { if (current()) return this.onTurnFinished(info); }, + onTeamAccepted: turn => { + if (!current()) return; + ledger.rememberBatch(turn.batchId); + ledger.rememberTurn({ chatId: turn.chatId, taskId: turn.taskId }); + void this.persistLedger('the accepted team batch', ledger); + }, + }); + this.runner.stopped = this.stopped; + this.history = new Map(); + this.activity = new Map(); + this.reactions = new Map(); + this.opCache = new Map(); + this.linkedAt = Date.now(); + } + + assertScope(epoch) { + if (this.stopped || epoch !== this.lifecycleEpoch) throw codedError('SCOPE_CHANGED', 'The bridge room changed while this operation was running.'); + } + + async start() { + this.stopped = false; + this.runner.stopped = false; + const epoch = ++this.lifecycleEpoch; + await this.ledger.init(); + if (this.stopped || epoch !== this.lifecycleEpoch) return; + await this.tools.refresh(); + if (this.stopped || epoch !== this.lifecycleEpoch) return; + // A token that is about to run out is replaced before it does, so a short + // lifetime does not quietly take the desktop offline. + await this.rotateTokenIfExpiring(); + if (this.stopped || epoch !== this.lifecycleEpoch) return; + this.relay.start(); + this.logger.info('bridge started', { + bridgeId: this.config?.bridgeId, + teamTools: this.tools.catalog.length, + agent: this.runner.available, + }); + } + + stop() { + this.stopped = true; + this.lifecycleEpoch += 1; + this.runner.stop(); + if (this.snapshotTimer) clearTimeout(this.snapshotTimer); + this.snapshotTimer = undefined; + this.relay.stop(); + } + + /** + * All configuration writers share one persistence + memory commit. Updates are + * derived inside the queue, never from a snapshot taken before another write. + * + * @param {(current: import('./config.mjs').BridgeConfig) => import('./config.mjs').BridgeConfig} update + * @param {{reconfigure?: boolean, validate?: () => void}} [options] + */ + commitConfig(update, { reconfigure = false, validate = () => {} } = {}) { + const commit = this.configCommit.then(async () => { + validate(); + const current = this.config; + let next = update(current); + const roomChanged = this.roomIdentity(next) !== this.roomIdentity(current); + const tokenChanged = next.bridgeToken !== current?.bridgeToken; + let destination; + if (roomChanged) { + const key = this.roomIdentity(next); + destination = this.roomLedgers.get(key)?.deref() ?? await new Ledger(this.context.dataDir, { room: key }).init(); + } + if (this.context?.dataDir) { + await saveConfig(this.context.dataDir, next); + try { + validate(); + } catch (error) { + // stop/start can invalidate a rotation while the file write is pending. + // No other config writer can enter until the previous state is restored. + await saveConfig(this.context.dataDir, current); + throw error; + } + } + this.config = next; + if (roomChanged) this.installRoom(next, destination); + if (roomChanged || tokenChanged) this.configEpoch += 1; + if (roomChanged || (reconfigure && tokenChanged)) { + const settings = { + url: next.relayUrl, accountId: next.accountId, bridgeId: next.bridgeId, + token: next.bridgeToken, name: next.bridgeName, + }; + if (this.stopped) { + this.relay.stop(); + if (roomChanged) this.relay.queue = []; + Object.assign(this.relay, settings); + } else { + this.relay.reconfigure(settings); + } + } else { + // A cosmetic/no-op save must not revoke the proof of an in-flight token + // rotation. Name metadata is used on the next handshake, not broadcast. + this.relay.url = next.relayUrl; + this.relay.accountId = next.accountId; + this.relay.bridgeId = next.bridgeId; + this.relay.token = next.bridgeToken; + this.relay.name = next.bridgeName; + } + return next; + }); + // A failed write or stale rotation must not poison subsequent configuration. + this.configCommit = commit.then(() => {}, () => {}); + return commit; + } + + // -- outbound ------------------------------------------------------------ + + emit(type, payload) { + if (this.stopped) return; + this.relay.send(createFrame({ type, payload, source: 'bridge' })); + } + + /** + * Sends an event to the phones and keeps the conversation it belongs to. + * + * The team's replies are part of the thread. A phone that opens a chat later, or a + * second phone that joins, must see them — not only the messages that phone sent + * itself. + */ + handleEvent(type, payload, mode) { + if (this.stopped) return; + // The tool calls of a turn the bridge runs itself are kept, so that a phone which + // opens the chat later sees the work the turn did and not only its answer. A turn + // the team runs is in the desktop's own transcript, and that record is read back + // from there instead. + if (type === 'evt.tool_started' || type === 'evt.tool_finished') { + this.rememberToolCall(type, payload, mode); + if (type === 'evt.tool_finished' && mode === 'hosted') { + this.ledger.recordTool({ botId: authorIdFor(payload.chatId), toolName: payload.toolName, + ok: payload.ok === true, error: payload.ok !== true, + durationMs: payload.usage?.durationMs ?? 0 }); + void this.persistLedger('a hosted tool outcome'); + } + } + if (type === 'evt.turn_finished') { + const list = this.activity.get(payload?.chatId); + if (list) { + // A missing tool finish is unknown, not still running after its turn ended. + for (const entry of list) { + if (entry.turnId === payload.turnId && entry.status === 'running') entry.status = 'unknown'; + } + this.trimStoredActivity(payload.chatId); + } + } + if (type === 'evt.message_committed') { + const message = payload?.message; + if (message && message.role === 'assistant' && message.chatId) { + // The phone draws the face of whoever answered, so the sender has to travel + // with the message. The desktop's own messages name their sender; the bridge's + // own turns already carry an author. + const author = firstString(message, ['authorId', 'author_id', 'bot_id', 'botId', 'sender_id', 'senderId']); + const normalised = author && !message.authorId ? { ...message, authorId: author } : message; + // A turn the team runs is in the desktop's own transcript already; only a turn + // the bridge runs itself is kept here, so the phone never shows it twice. + if ((mode ?? this.runModeFor(normalised.chatId)) === 'hosted') { + this.pushHistory(normalised.chatId, normalised); + } + this.emit(type, { ...payload, message: normalised }); + return; + } + if (message && message.role === 'system' && message.chatId) { + // The bridge's own words about the run: the team stopped for a reason the phone + // has to know about, or the group paused. The desktop does not keep them — + // they were never in its transcript — so the bridge keeps them, or a phone + // that opens the chat later finds a Bot that asked a question and no hint of + // where the answer goes. + this.pushHistory(message.chatId, message); + this.emit(type, payload); + return; + } + } + this.emit(type, payload); + } + + /** + * Re-emits held approvals and questions so a resuming phone sees them. + * + * The team snapshot goes out with it: a phone that (re)connects has no roster of its + * own, and this is the one event that carries the whole team. + */ + replayPending() { + // A request that is being answered right now is not waiting for an answer, so it + // is not re-emitted: a phone that reconnects must not be shown a request it would + // only be allowed to refuse. + for (const item of this.pending.listUnclaimed()) { + if (item.kind === 'permission') { + this.emit('evt.permission_requested', { request: item.request }); + } else { + this.emit('evt.question_requested', { request: item.request }); + } + } + this.emit('evt.bridge_status', { + desktopOnline: true, + agentAvailable: this.runner.available, + teamToolsAvailable: this.tools.available, + linkedAt: this.linkedAt, + tools: this.tools.catalog, + }); + void this.publishTeamSnapshot(); + } + + /** Publishes the current team roster to the phones. */ + async publishTeamSnapshot() { + if (this.stopped) return false; + const epoch = this.lifecycleEpoch; + try { + const team = await this.readTeam(); + if (this.stopped || epoch !== this.lifecycleEpoch) return false; + this.snapshotError = undefined; + this.snapshotAttempts = 0; + this.emit('evt.team_snapshot', { + ...team, + diagnostics: this.diagnostics(), + tools: this.tools.catalog, + }); + return true; + } catch (error) { + if (this.stopped || epoch !== this.lifecycleEpoch) return false; + // The reason travels with the diagnostics, so the phone can say what is wrong + // instead of showing an empty team with no explanation. It is a fixed sentence: + // the detail is for the desktop, not for every phone in the room. + this.snapshotError = TEAM_ROSTER_UNREADABLE; + this.snapshotErrorDetail = error instanceof Error ? error.message : String(error); + this.logger.warn('team snapshot failed', { message: this.snapshotErrorDetail }); + this.emit('evt.bridge_status', { + desktopOnline: true, + agentAvailable: this.runner.available, + teamToolsAvailable: this.tools.available, + linkedAt: this.linkedAt, + tools: this.tools.catalog, + diagnostics: this.diagnostics(), + }); + this.scheduleSnapshotRetry(); + return false; + } + } + + /** + * Retries the team snapshot while the Host is still preparing this generation. + * + * The Host activates a Mini App's MCP server and binds its tools to the current + * generation; until that has happened a tool call is refused as unavailable. The + * bridge therefore keeps trying for a short while instead of leaving the phone + * with an empty team. + */ + scheduleSnapshotRetry() { + if (this.stopped || this.snapshotTimer) return; + const epoch = this.lifecycleEpoch; + this.snapshotAttempts += 1; + if (this.snapshotAttempts > SNAPSHOT_RETRIES) return; + this.snapshotTimer = setTimeout(() => { + this.snapshotTimer = undefined; + if (!this.stopped && epoch === this.lifecycleEpoch) void this.publishTeamSnapshot(); + }, SNAPSHOT_RETRY_MS); + this.snapshotTimer.unref?.(); + } + + // -- inbound ------------------------------------------------------------- + + async handleFrame(frame) { + if (this.stopped) return; + if (!frame || typeof frame.type !== 'string') return; + if (!frame.type.startsWith('op.')) return; + const epoch = this.lifecycleEpoch; + + const opId = frame.opId ?? frame.id; + // An operation without an id cannot be answered, and its cache key would be the + // same for every frame of its type - so one such frame would silence that + // operation for the whole account, and every later frame of that type would be + // answered with its result. It is refused instead of being run. + if (!opId) { + this.replyError(frame, codedError('INVALID_ARGUMENTS', 'An operation must carry an opId.')); + return; + } + // The cache key includes the operation type and the sender: two different + // operations must never share a result, and one device's operation must never + // swallow another device's when the two operation ids happen to be the same. + const cacheKey = `${frame.type}:${frame.from ?? ''}:${opId}`; + + const cached = this.opCache.get(cacheKey); + if (cached && cached.expiresAt > Date.now()) { + // The in-flight promise is shared, so a duplicate that arrives while the first + // call is still running waits for it instead of running the operation twice. + const outcome = await cached.promise; + if (this.stopped || epoch !== this.lifecycleEpoch) return; + if (outcome.ok) { + this.reply(frame, outcome.value); + } else { + this.replyError(frame, outcome.error); + } + return; + } + + // Claim the slot before awaiting, so a concurrent duplicate cannot slip past. + /** @type {Promise<{ok: boolean, value?: unknown, error?: unknown}>} */ + const promise = this.dispatch(frame.type, frame.payload ?? {}, frame.from).then( + (value) => ({ ok: true, value }), + (error) => ({ ok: false, error }), + ); + this.opCache.set(cacheKey, { promise, expiresAt: Date.now() + OP_DEDUPE_TTL_MS }); + this.pruneCache(); + + const outcome = await promise; + if (this.stopped || epoch !== this.lifecycleEpoch) return; + if (outcome.ok) { + this.reply(frame, outcome.value); + } else { + this.replyError(frame, outcome.error); + } + } + + /** + * Writes the ledger down, without failing the caller. + * + * The ledger is a record of what happened, not the thing that happened: a + * decision that has been applied must not be undone by a disk that cannot be + * written, and the caller must not be told that the work failed when it did + * not. The entry stays in memory, so it is still there for the session. + * + * @param {string} what what was being recorded, for the log + */ + async persistLedger(what, ledger = this.ledger) { + try { + await ledger.persist(); + } catch { + // Never expose paths, credentials or raw filesystem errors through diagnostics. + this.logger.warn(`The ledger could not be written; ${what} is only recorded in memory.`); + } + } + + /** + * Revokes a paired phone. + * + * The revocation is written down with the decisions: it is a change to who may + * reach the relay, and a record of what was allowed is only worth something if + * the changes are in it too. + */ + async revokeDevice(deviceId) { + const ledger = this.ledger; + const revoked = await this.relay.revokeDevice(deviceId); + if (revoked) { + ledger.recordAccessChange({ + action: 'device-revoked', + subject: deviceId, + detail: 'the phone cannot reach the relay any more', + }); + await this.persistLedger('the revocation', ledger); + } + return revoked; + } + + reply(frame, result) { + if (this.stopped) return; + this.relay.send( + createFrame({ + type: 'ack', + payload: { opId: frame.opId ?? frame.id, result }, + source: 'bridge', + in_reply_to: frame.id, + }), + ); + } + + replyError(frame, error) { + if (this.stopped) return; + const code = typeof error?.code === 'string' ? error.code : 'INTERNAL'; + const retryable = code === 'BUSY' || code === 'AGENT_UNAVAILABLE' || code === 'TOOL_NOT_AVAILABLE'; + this.relay.send( + createFrame({ + type: 'err', + payload: { + opId: frame.opId ?? frame.id, + code, + message: error instanceof Error ? error.message : String(error), + retryable, + // `INVALID_ARGUMENTS` carries the tool's live schema and the properties that + // are missing, so the client can ask for the exact field instead of guessing. + ...(error && error.details ? { details: error.details } : {}), + }, + source: 'bridge', + in_reply_to: frame.id, + }), + ); + } + + pruneCache() { + const now = Date.now(); + for (const [key, entry] of this.opCache) { + if (entry.expiresAt <= now) this.opCache.delete(key); + } + // The cache key is built from an operation id the client chose, so the time to live + // alone does not bound it: a client that sends a hundred thousand distinct + // operations inside one window keeps a hundred thousand entries. The oldest goes + // first, and an operation that is repeated after it was dropped is simply run again. + while (this.opCache.size > MAX_OP_CACHE_ENTRIES) { + const oldest = this.opCache.keys().next(); + if (oldest.done) break; + this.opCache.delete(oldest.value); + } + } + + // -- dispatch ------------------------------------------------------------ + + /** + * Runs one operation. + * + * @param {string} [from] the phone the relay says sent it, when it knows + */ + async dispatch(type, payload, from) { + this.runner.assertRunning(); + const epoch = this.lifecycleEpoch; + const ledger = this.ledger; + switch (type) { + case 'op.send_message': + return this.sendMessage(payload); + case 'op.abort_turn': + return { aborted: await this.runner.abort(payload.turnId) }; + case 'op.respond_permission': { + // The request is read before the reply: answering it takes it off the + // pending set, and the record needs the tool it was about. + const request = this.pending.get(payload.requestId); + await this.runner.reply(payload.requestId, payload.decision); + // The decision is a record of what was allowed and by whom, so it is + // written down: the request, the tool it was about, the phone that + // answered and the answer itself. + ledger.recordPermission({ + requestId: payload.requestId, + toolName: request?.request?.toolName ?? 'unknown', + deviceId: from ?? 'unknown', + decision: payload.decision, + }); + // The decision has already been applied - the Host has allowed or + // refused the tool call - so a ledger that cannot be written must not + // make the phone think the decision failed. The phone would be told + // "failed" while the tool ran, which is worse than a missing record. + await this.persistLedger('the permission decision', ledger); + return { requestId: payload.requestId, decision: payload.decision }; + } + case 'op.answer_question': + await this.runner.answer(payload.requestId, payload.answers ?? []); + return { requestId: payload.requestId }; + case 'op.list_history': { + const history = await this.historyFor(payload.chatId); + this.assertScope(epoch); + return { + messages: history.messages, + activity: history.activity, + reactions: await this.reactionsFor(payload.chatId), + }; + } + case 'op.get_usage': + return this.ledger.report({ days: payload.days ?? 7 }); + case 'op.react': + return this.react(payload); + case 'op.set_bot_mode': + return this.setBotMode(payload, epoch); + case 'op.list_models': + // The Host's own catalog: the models that can run and the efforts each of + // them has. The phone offers this list instead of a free text field. + return this.router.models.describe(); + case 'op.list_capabilities': + // The skills, plugins and agents the desktop has, read from its own + // installation. + return readCapabilities(this.context); + case 'op.list_team': { + await this.tools.refresh(); + this.assertScope(epoch); + try { + const team = await this.readTeam(); + return { ...team, diagnostics: this.diagnostics() }; + } catch (error) { + this.assertScope(epoch); + // Intentional tool and validation failures remain actionable. Unexpected + // Host exceptions are desktop diagnostics, not room-wide error text. + if (error?.code === 'TOOL_REPORTED_ERROR' || error?.code === 'INVALID_ARGUMENTS') throw error; + this.snapshotError = TEAM_ROSTER_UNREADABLE; + this.snapshotErrorDetail = error instanceof Error ? error.message : String(error); + this.logger.warn('the requested team roster could not be read', { message: this.snapshotErrorDetail }); + throw codedError('TEAM_ROSTER_UNREADABLE', TEAM_ROSTER_UNREADABLE); + } + } + case 'op.bot_avatar': { + const botId = String(payload.botId ?? ''); + if (!botId) throw codedError('INVALID_ARGUMENTS', 'botId is required.'); + const picture = await this.readBotPicture(botId); + if (!picture) { + throw codedError('NOT_FOUND', 'This Bot has no picture on the desktop.'); + } + return { + botId, + mime: picture.mime, + data: picture.buffer.toString('base64'), + source: picture.source, + }; + } + case 'op.list_tasks': { + // The team's task tool answers for one batch or one task at a time — there is + // no "list everything" call. A client that names one gets that one; a client + // that names neither gets every batch the bridge has seen. + if (payload.taskId || payload.batchId) return this.callTool('get_tasks', payload); + return this.listKnownTasks(); + } + case 'op.remember': { + // A memory is filed under a topic or replaces an entry; the team refuses one + // with neither. A note the phone asks to remember gets its own topic. + const request = { ...payload }; + if (!request.key && !request.replaceId) request.key = generateTopicKey(); + const result = await this.callTool('remember', request); + return { ...result, key: request.key ?? result.key }; + } + default: { + const toolName = OP_TOOL[type]; + if (!toolName) { + throw codedError('UNKNOWN_OP', `No handler for ${type}`); + } + const result = await this.callTool(toolName, payload); + if (!this.stopped && epoch === this.lifecycleEpoch) await this.publishMutation(type, result); + return result; + } + } + } + + /** + * Publishes the event that carries a mutation's result to the phones. + * + * The phone never polls for these: it renders what the bridge tells it. A mutation + * that is not published leaves the phone's list stale until the next full refresh. + */ + async publishMutation(type, result) { + const data = result?.data && typeof result.data === 'object' ? result.data : {}; + switch (type) { + case 'op.create_bot': + case 'op.update_bot': + case 'op.delete_bot': + case 'op.create_group': + case 'op.update_group': + case 'op.delete_group': + await this.publishTeamSnapshot(); + return; + case 'op.dispatch': + case 'op.cancel_task': + this.emit('evt.task_updated', { task: data.task ?? data }); + return; + case 'op.start_helper': + case 'op.stop_helper': + this.emit('evt.helper_updated', { helper: data.helper ?? data }); + return; + case 'op.create_schedule': + case 'op.delete_schedule': + this.emit('evt.schedule_updated', { schedule: data.schedule ?? data }); + return; + default: + return; + } + } + + /** + * The shared Desktop dispatcher has no relay-room component in its cache key. + * @param {string | undefined} value + */ + teamRequestId(value) { + if (!this.roomScope.filtered || typeof value !== 'string' || !value.trim()) return value; + return `room-${createHash('sha256').update(JSON.stringify([this.roomScope.key, value.trim()])).digest('hex')}`; + } + + /** + * Calls a team tool and normalises the result. + * + * The tool envelope differs per tool, so the raw result is returned alongside a best + * effort structured view; the client renders the structured view and falls back to the + * raw content when it cannot. The tool's own fields are also spread at the top level, + * so the protocol's declared result shapes (`entries`, `tasks`, `bots`, …) are present + * exactly where the client reads them. + */ + async callTool(toolName, payload) { + const ledger = this.ledger; + if (toolName === 'dispatch_tasks' && this.roomScope.filtered) { + const requestId = firstString(payload, ['requestId', 'request_id', 'id']); + if (requestId) payload = { ...payload, requestId: this.teamRequestId(requestId) }; + } + const startedAt = Date.now(); + const result = await this.tools.call(toolName, payload); + ledger.recordTool({ + botId: payload.botId ?? payload.targetBotId ?? 'unknown', + toolName, + ok: result.ok === true && result.isError !== true, + durationMs: Date.now() - startedAt, + tokens: extractTokens(result), + error: result.ok === true && result.isError !== true ? undefined : true, + }); + + if (!result.ok) { + throw codedError( + result.code ?? 'TOOL_FAILED', + result.message ?? `The ${toolName} tool did not complete.`, + false, + result.details, + ); + } + if (result.isError === true) { + // The tool ran and failed; its own wording is kept, because that is what tells + // the user what went wrong. + throw codedError( + 'TOOL_REPORTED_ERROR', + result.message ?? `The ${toolName} tool reported an error.`, + false, + { tool: toolName }, + ); + } + + const data = extractData(result); + const normalised = normaliseResult(toolName, data); + // A batch the team reported is remembered, so the task list can be rebuilt later. + const batchId = normalised?.batchId ?? normalised?.batch_id; + if (batchId) ledger.rememberBatch(String(batchId)); + if (Array.isArray(normalised?.tasks)) for (const task of normalised.tasks) { + ledger.rememberTurn({ taskId: task?.taskId ?? task?.task_id }); + } + await this.persistLedger('the tool outcome', ledger); + + return { + tool: toolName, + content: result.content, + structuredContent: result.structuredContent, + data: normalised, + ...(normalised && typeof normalised === 'object' && !Array.isArray(normalised) ? normalised : {}), + }; + } + + /** + * Rebuilds the task list from the batches the bridge knows. + * + * The team's task tool answers for one batch at a time, so a list is assembled by + * asking about each batch in turn. The list is therefore never complete: a task + * the team dispatched without the bridge is invisible to the phone, and the answer + * says so. + */ + async listKnownTasks() { + const epoch = this.lifecycleEpoch; + const batches = this.ledger.batches(); + if (batches.length === 0) { + return { + tasks: [], + batchIds: [], + partial: true, + note: 'The team tool answers for one batch at a time, so the list only covers the work the bridge dispatched.', + }; + } + + /** @type {any[]} */ + const tasks = []; + const seen = new Set(); + for (const batchId of batches.slice(-MAX_BATCHES)) { + this.assertScope(epoch); + try { + const result = await this.callTool('get_tasks', { batchId }); + for (const task of result.tasks ?? []) { + const id = task?.taskId ?? task?.task_id; + if (id && seen.has(id)) continue; + if (id) seen.add(id); + tasks.push(task); + } + } catch (error) { + // One batch the team no longer knows must not hide the others. + this.logger.warn('batch query failed', { + batchId, + message: error instanceof Error ? error.message : String(error), + }); + } + } + + return { + tasks, + batchIds: batches, + partial: true, + note: 'The team tool answers for one batch at a time, so the list only covers the work the bridge dispatched.', + }; + } + + /** + * Reads the team roster, preferring the tool's structured output. + * + * The team tool reports the roster in its own shape; the mapping into the + * protocol's shape happens here, so the phone only ever sees one shape. + * + * The tool answers with a summary — an id, a name, a role, a model. What a Bot + * *looks* like is not in that summary, so the desktop's own record of the team is + * read as well and the two are merged: the phone shows the Bot the desktop shows, + * not a list of names. + */ + async readTeam() { + const result = await this.tools.call('list_team', {}); + if (!result.ok) throw codedError(result.code ?? 'TOOL_FAILED', result.message ?? 'The team roster could not be read.'); + const raw = extractData(result); + const data = raw?.value ?? raw; + if (result.isError === true || data?.ok === false) { + // Intentional tool errors are actionable. Unexpected infrastructure exceptions + // still pass through op.list_team's existing fixed-message redaction. + throw codedError('TOOL_REPORTED_ERROR', result.message ?? + (typeof data?.error === 'string' ? data.error : 'The team roster could not be read.')); + } + if (!data || !Array.isArray(data.bots) || !Array.isArray(data.groups) || + data.bots.some(bot => !bot || typeof bot !== 'object' || Array.isArray(bot)) || + data.groups.some(group => !group || typeof group !== 'object' || Array.isArray(group))) { + throw codedError('INVALID_TOOL_RESULT', 'The team roster returned an unsupported result.'); + } + const bots = data.bots.map(mapBot); + const idByName = new Map(bots.map((bot) => [bot.name, bot.botId])); + const groups = Array.isArray(data.groups) ? data.groups.map((group) => mapGroup(group, idByName)) : []; + return { bots: await this.withDesktopTeam(bots), groups: await this.withDesktopGroups(groups) }; + } + + /** + * The team's Bots, with what the desktop knows about how they look. + * + * A Bot the desktop does not know about keeps the summary's fields: the tool's + * answer is still true, it is only less complete. + */ + async withDesktopTeam(bots) { + // Where a turn runs is the bridge's own setting, so it is carried on the Bot the + // phone reads: the settings screen shows the mode the desktop is actually using. + const withMode = bots.map((bot) => ({ ...bot, runMode: this.runModeFor(`bot:${bot.botId}`) })); + const state = await readDesktopState(this.context); + if (!state) return withMode; + const desktop = new Map(desktopBots(state).map((bot) => [bot.botId, bot])); + return withMode.map((bot) => { + const known = desktop.get(bot.botId); + if (!known) return bot; + return { + ...bot, + name: known.name || bot.name, + role: known.role ?? bot.role, + skin: known.skin ?? bot.skin, + color: known.color ?? bot.color, + face: known.face ?? bot.face, + instructions: known.instructions ?? bot.instructions, + effort: bot.effort ?? known.effort, + isMain: known.isMain || bot.isMain, + }; + }); + } + + /** + * The team's groups, with the members the desktop knows by id. + * + * The tool names the members, the desktop knows their ids, and a phone needs the + * ids to open a group chat. + */ + async withDesktopGroups(groups) { + const state = await readDesktopState(this.context); + if (!state) return groups; + const desktop = new Map(desktopGroups(state).map((group) => [group.groupId, group])); + return groups.map((group) => { + const known = desktop.get(group.groupId); + if (!known) return group; + return { + ...group, + name: known.name || group.name, + admin: known.admin ?? group.admin, + memberIds: known.memberIds.length ? known.memberIds : group.memberIds, + notice: known.notice ?? group.notice, + replyMode: known.replyMode ?? group.replyMode, + }; + }); + } + + /** + * The picture a Bot shows, read from the desktop. + * + * The desktop keeps the only copy of the artwork, so the phone asks for it and + * receives the bytes. A Bot with no picture of its own and no look has none, and + * the phone draws its octopus instead. + */ + async readBotPicture(botId) { + const state = await readDesktopState(this.context); + if (!state) return undefined; + const bot = desktopBots(state).find((candidate) => candidate.botId === botId); + if (!bot) return undefined; + return readBotPicture(this.context, bot); + } + + diagnostics() { + return { + agentAvailable: this.runner.available, + teamToolsAvailable: this.tools.available, + toolCount: this.tools.catalog.length, + partial: this.tools.partial, + linkedAt: this.linkedAt, + // A fixed sentence, never the Host's own error text: this travels to every + // phone in the room. + reason: this.snapshotError ?? this.tools.reason, + }; + } + + /** + * The diagnostics for the desktop's own surface. + * + * The surface is behind the surface secret and is read by the person sitting at the + * desktop, so it is where the detail belongs. + */ + desktopDiagnostics() { + return { + ...this.diagnostics(), + detail: this.snapshotErrorDetail ?? this.tools.detail, + }; + } + + async sendMessage(payload) { + const epoch = this.lifecycleEpoch; + const runner = this.runner; + runner.assertRunning(); + const { chatId, text, mentions } = payload; + if (!chatId || !text) { + throw codedError('INVALID_ARGUMENTS', 'chatId and text are required.'); + } + + // The client may choose the id so its optimistic bubble can be replaced in place + // when this message is committed. An id the client may not choose is ignored. + const messageId = isValidClientMessageId(payload.clientMessageId) + ? payload.clientMessageId + : `msg_${newId('u')}`; + const userMessage = { + messageId, + chatId, + role: 'user', + text, + ts: Date.now(), + pending: false, + }; + // Team turns already live in the authorized Desktop transcript. Keep only + // hosted user messages here, so team history never duplicates the user's bubble. + if (this.runModeFor(chatId) === 'hosted') { + this.pushHistory(chatId, userMessage); + } + this.emit('evt.message_committed', { message: userMessage }); + + // The team reads a mention as a name, so the ids the phone sends are translated on + // the way out. The router does that, because only it knows the roster. + const result = await runner.run({ + chatId, + text, + mentions, + requestId: this.teamRequestId(requestIdFor(payload, messageId)), + mode: this.runModeFor(chatId), + }); + + this.assertScope(epoch); + return { + messageId: userMessage.messageId, + turnId: result?.turnId, + sessionId: result?.sessionId ?? runner.sessionFor(chatId), + }; + } + + /** + * Where a turn in this conversation runs. + * + * A group is always the team's: who speaks first, who the admin is and who hands + * over to whom are the team's rules. A Bot's own chat is the phone's to choose. + * + * @param {string} chatId + * @returns {'team' | 'hosted'} + */ + runModeFor(chatId) { + if (!String(chatId ?? '').startsWith('bot:')) return 'team'; + const botId = String(chatId).slice(4); + const chosen = this.config?.botModes?.[botId]; + return chosen === 'hosted' ? 'hosted' : DEFAULT_RUN_MODE; + } + + /** + * Sets where a Bot's turns run, and remembers it. + * + * @param {{botId: string, mode: 'team' | 'hosted'}} payload + */ + async setBotMode(payload, epoch = undefined) { + const botId = String(payload?.botId ?? ''); + const mode = payload?.mode; + if (mode !== 'team' && mode !== 'hosted') { + throw codedError('INVALID_ARGUMENTS', 'A run mode must be either team or hosted.'); + } + if (!botId) throw codedError('INVALID_ARGUMENTS', 'A Bot id is required.'); + await this.commitConfig((current) => { + /** @type {Record} */ + const botModes = { ...(current?.botModes ?? {}) }; + botModes[botId] = mode; + return { ...current, botModes }; + }, { validate: () => { if (epoch !== undefined) this.assertScope(epoch); } }); + this.logger.info('the bridge run mode was set', { botId, mode }); + return { botId, mode }; + } + + react(payload) { + const { messageId, emoji } = payload; + if (!messageId || !emoji) { + throw codedError('INVALID_ARGUMENTS', 'messageId and emoji are required.'); + } + if ([...String(emoji)].length > MAX_REACTION_EMOJI_LENGTH) { + throw codedError( + 'INVALID_ARGUMENTS', + `An emoji may be at most ${MAX_REACTION_EMOJI_LENGTH} characters long.`, + ); + } + const bucket = this.reactions.get(messageId) ?? new Map(); + bucket.set(emoji, (bucket.get(emoji) ?? 0) + 1); + this.reactions.set(messageId, bucket); + // A client that reacts to a million different messages would otherwise make the + // desktop hold a million buckets. The oldest reaction is the one that goes. + while (this.reactions.size > MAX_REACTION_MESSAGES) { + const oldest = this.reactions.keys().next(); + if (oldest.done) break; + this.reactions.delete(oldest.value); + } + const reaction = { messageId, emoji }; + this.emit('evt.reaction_added', { reaction }); + return { reaction }; + } + + /** + * The reactions of a chat, as a flat list. + * + * A reaction the phone gives is the bridge's own count: the desktop's team tools + * cannot carry one, so the count lives here, in memory, and is read back with the + * thread — the message it belongs to is the message the phone can see. + */ + async reactionsFor(chatId) { + const { messages } = await this.historyFor(chatId); + const out = []; + for (const message of messages) { + const bucket = this.reactions.get(message.messageId); + if (!bucket) continue; + for (const [emoji, count] of bucket) { + out.push({ messageId: message.messageId, emoji, count }); + } + } + return out; + } + + /** + * Keeps the tool calls of a turn the bridge runs itself. + * + * The phone is told about every call as it happens, and this remembers them, so that + * a phone which opens the chat afterwards sees the same work the live view showed. + * A turn the team runs is not kept here: the desktop's own transcript holds it, and + * that record is read back from there. + */ + rememberToolCall(type, payload, mode) { + const chatId = payload?.chatId; + const turnId = payload?.turnId; + const toolName = payload?.toolName; + if (typeof chatId !== 'string' || !chatId) return; + if (typeof turnId !== 'string' || !turnId) return; + if (typeof toolName !== 'string' || !toolName) return; + if ((mode ?? this.runModeFor(chatId)) !== 'hosted') return; + const list = this.activity.get(chatId) ?? []; + const callId = + typeof payload?.callId === 'string' && payload.callId ? payload.callId : `${turnId}:${toolName}:${list.length}`; + const index = list.findIndex((entry) => entry.callId === callId); + if (type === 'evt.tool_started') { + if (index >= 0) return; + list.push({ turnId, callId, toolName, status: 'running', startedAt: Date.now() }); + } else { + if (index < 0) return; + const durationMs = typeof payload?.usage?.durationMs === 'number' ? payload.usage.durationMs : undefined; + list[index] = { + ...list[index], + status: payload?.ok === true ? 'ok' : 'failed', + ...(durationMs === undefined ? {} : { durationMs }), + ...(typeof payload?.error === 'string' && payload.error ? { error: payload.error } : {}), + }; + } + this.activity.set(chatId, list); + // A turn can report tools before committing any message. Such a conversation + // counts towards the same chat ceiling, rather than growing a second cache. + if (!this.history.has(chatId)) this.history.set(chatId, []); + this.trimStoredActivity(chatId); + } + + /** Retains whole turns as calls arrive, not only when a phone reads them back. */ + trimStoredActivity(chatId) { + const list = this.activity.get(chatId); + if (list) { + const running = new Set(list.filter((entry) => entry.status === 'running').map((entry) => entry.turnId)); + this.activity.set(chatId, trimTurnActivity(list, MAX_ACTIVITY_CALLS, [], + (turnId) => running.has(turnId) || this.runner.aborts.has(turnId))); + } + this.trimChatState(); + } + + /** Drops the oldest settled chat, including both its messages and tool calls. */ + trimChatState() { + for (const [chatId, messages] of this.history) { + if (this.history.size <= MAX_TRACKED_CHATS) break; + const activity = this.activity.get(chatId) ?? []; + if (messages.some((message) => this.runner.aborts.has(message.turnId)) || + activity.some((entry) => entry.status === 'running' || this.runner.aborts.has(entry.turnId))) continue; + this.history.delete(chatId); + this.activity.delete(chatId); + } + } + + /** + * The thread of a conversation, as the phone shows it. + * + * Two records are read together. The desktop keeps the conversation — a turn the team + * ran is in the desktop's own transcript, and that transcript outlives anything else. + * The bridge keeps the turns it ran itself, which the desktop does not know about. + * + * A turn the team runs is recorded once, by the desktop, and a turn the bridge runs + * is recorded once, by the bridge, so the two never show the same message twice. + * Nothing is copied and nothing is written: the desktop's copy is read as it is. + * + * @param {string} chatId + * @returns {Promise<{messages: Array, activity: Array}>} + */ + async historyFor(chatId) { + const epoch = this.lifecycleEpoch; + const own = this.history.get(chatId) ?? []; + const ownActivity = this.activity.get(chatId) ?? []; + const desktop = await desktopMessages(this.context, chatId); + this.assertScope(epoch); + // Pairing grants the official Desktop transcript. Room scope protects only + // bridge-private hosted state and asynchronous callback/result ownership. + if (desktop.messages.length === 0 && own.length === 0) { + return { messages: own, activity: ownActivity }; + } + const byId = new Map(); + for (const message of [...desktop.messages, ...own]) byId.set(message.messageId, message); + const list = [...byId.values()].sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0)); + const messages = list.length > HISTORY_LIMIT ? list.slice(list.length - HISTORY_LIMIT) : list; + // The calls are matched by their own id, so a call the desktop's record holds and a + // call the bridge's own turn made are never counted twice. + const byCall = new Map(); + for (const entry of [...desktop.activity, ...ownActivity]) byCall.set(entry.callId, entry); + const activity = this.trimActivity([...byCall.values()], messages); + return { messages, activity }; + } + + /** + * The calls of the turns the thread shows. + * + * A call whose turn is not in the messages belongs to a turn the reader cannot see, so + * it goes with that turn. What is left is kept whole: a turn that showed only some + * of its calls would say that the Bot used fewer tools than it did. + * + * @param {Array} entries the calls of the conversation + * @param {Array} messages the messages the thread shows + * @returns {Array} + */ + trimActivity(entries, messages) { + const turns = turnOrderOf(messages); + const shown = new Set(turns); + const kept = entries.filter((entry) => shown.has(entry?.turnId)); + const running = new Set(kept.filter((entry) => entry.status === 'running').map((entry) => entry.turnId)); + return trimTurnActivity(kept, MAX_ACTIVITY_CALLS, turns, + (turnId) => running.has(turnId) || this.runner.aborts.has(turnId)); + } + + pushHistory(chatId, message) { + const list = this.history.get(chatId) ?? []; + // A turn can commit more than once: the team narrates while it works. The newest + // version of a message replaces the one before it, exactly as the phone renders it. + const index = list.findIndex((entry) => entry.messageId === message.messageId); + if (index >= 0) { + list[index] = message; + } else { + list.push(message); + } + // A message that replaced an earlier one carries a newer `ts`, so the list is put + // back in order: the phone sorts by `ts` when it renders, and a thread that reads + // differently after a reload is a thread that reads wrong. + list.sort((a, b) => (a.ts ?? 0) - (b.ts ?? 0)); + if (list.length > HISTORY_LIMIT) list.splice(0, list.length - HISTORY_LIMIT); + this.history.set(chatId, list); + this.trimStoredActivity(chatId); + } + + /** @param {{chatId: string, durationMs: number, result?: import('./contracts.d.ts').HostRunResult, error?: unknown}} info */ + onTurnFinished({ chatId, durationMs, result, error }) { + const author = authorIdFor(chatId); + this.ledger.recordTurn({ + botId: author ?? 'unknown', + botName: author, + chars: result?.text?.length ?? 0, + durationMs, + error: Boolean(error) || result?.status === 'error', + tokens: usageNumber(result?.usage, ['totalTokens', 'total']), + cost: usageNumber(result?.usage, ['cost', 'totalCost']), + }); + return this.persistLedger('the turn outcome'); + } + + // -- local views --------------------------------------------------------- + + teamSnapshot() { + return { + bots: [], + groups: [], + diagnostics: this.diagnostics(), + tools: this.tools.catalog, + }; + } + + /** + * Asks the relay for a fresh token and keeps it. + * + * The relay signs the new token and kills every older one, so a token that + * leaked stops working the moment it is replaced. + * + * @returns {Promise} the new token + */ + async rotateToken() { + const configEpoch = this.configEpoch; + const relayEpoch = this.relay.epoch; + const original = this.config; + const originalRoom = this.roomIdentity(original); + const validate = () => { + if (configEpoch !== this.configEpoch || relayEpoch !== this.relay.epoch || + originalRoom !== this.roomIdentity() || original?.bridgeToken !== this.config?.bridgeToken) { + throw new Error('The relay configuration changed during token rotation.'); + } + }; + // Network work does not hold the persistence queue. The client must not install + // the token until this same configuration wins its serialized disk commit. + const token = await this.relay.rotateToken({ commit: false }); + await this.commitConfig((current) => ({ ...current, bridgeToken: token }), { validate }); + const committedEpoch = this.configEpoch; + const assertInstalled = () => { + if (committedEpoch !== this.configEpoch || relayEpoch !== this.relay.epoch || + this.config?.bridgeToken !== token) { + throw new Error('The relay configuration changed during token rotation.'); + } + }; + assertInstalled(); + this.ledger.recordAccessChange({ + action: 'bridge-token-rotated', + subject: original?.bridgeId ?? 'unknown', + detail: 'the previous bridge token was revoked', + }); + await this.persistLedger('the token rotation'); + assertInstalled(); + if (this.onToken) await this.onToken(token); + assertInstalled(); + this.logger.info('the bridge token was rotated'); + return token; + } + + /** + * Replaces the token before it runs out. + * + * The bridge cannot mint for itself — only the operator holding the relay + * secret can — so a token that quietly expired would take the desktop offline + * until it was re-minted by hand. It rotates itself while the current one is + * still good enough to ask for a replacement. + * + * @param {number} [withinMs] how close to the expiry counts as "about to run out" + * @returns {Promise} whether it rotated + */ + async rotateTokenIfExpiring(withinMs = 6 * 60 * 60 * 1000) { + const expiresAt = this.relay.tokenExpiresAt(); + if (expiresAt === null) return false; + if (expiresAt - Date.now() > withinMs) return false; + try { + await this.rotateToken(); + return true; + } catch (error) { + this.logger.warn( + `the bridge token could not be rotated: ${error instanceof Error ? error.message : error}`, + ); + return false; + } + } +} + +/** + * Builds an error that carries the protocol's error code. + * + * @param {string} code + * @param {string} message + * @param {boolean} [retryable] + * @param {unknown} [details] extra context, e.g. the tool's live schema + * @returns {Error & {code: string, retryable: boolean, details?: unknown}} + */ +function codedError(code, message, retryable = false, details) { + const error = new Error(message); + return Object.assign(error, { code, retryable, ...(details ? { details } : {}) }); +} + +/** + * The request id a turn carries into the team. + * + * The team deduplicates retries by request id, so the same phone message must always + * carry the same one: a retry after a lost answer asks the Bot once, not twice. The + * phone's own message id is used when it has one, because that id is stable across a + * retry. + * + * @param {Record} payload + * @param {string} messageId + * @returns {string} + */ +function requestIdFor(payload, messageId) { + const chosen = isValidClientMessageId(payload.clientMessageId) ? payload.clientMessageId : messageId; + return `phone-${chosen}`; +} + +/** Pulls a JSON object out of a tool result, whatever envelope it arrived in. */ +export function extractData(result) { + if (!result) return undefined; + if (result.structuredContent && typeof result.structuredContent === 'object') { + const structured = result.structuredContent; + if ('data' in structured && structured.data && typeof structured.data === 'object') { + return structured.data; + } + if (Array.isArray(structured)) return structured; + return structured; + } + const content = result.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block && typeof block.text === 'string') { + try { + const parsed = JSON.parse(block.text); + if (parsed && typeof parsed === 'object') return parsed; + } catch { + /* not JSON; keep looking */ + } + } + if (block && block.type === 'text' && typeof block.text === 'string') { + return { text: block.text }; + } + } + } + if (typeof content === 'string') { + try { + return JSON.parse(content); + } catch { + return { text: content }; + } + } + return undefined; +} + +/** Reads only supplied, finite usage; absent usage is not estimated. */ +function usageNumber(usage, names) { + if (!usage || typeof usage !== 'object') return undefined; + for (const name of names) { + const value = usage[name]; + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) return value; + } + return undefined; +} + +/** Reads token usage out of a tool receipt, when the receipt carries it. */ +export function extractTokens(result) { + const data = extractData(result); + if (!data || typeof data !== 'object') return 0; + const usage = data.usage ?? data.tokenUsage ?? data.tokens; + if (typeof usage === 'number') return usage; + if (usage && typeof usage === 'object') { + const total = usage.total ?? usage.totalTokens; + return typeof total === 'number' ? total : 0; + } + return 0; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/caller.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/caller.mjs new file mode 100644 index 0000000..0e3c0a7 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/caller.mjs @@ -0,0 +1,162 @@ +/** + * Who is allowed to talk to the bridge's loopback surface. + * + * The surface binds loopback, so every process on the machine — and every page in + * the user's browser — can reach the port. Two headers narrow that down to the + * desktop surface: + * + * - `Host` is set by the browser from the address the page was loaded from, and + * the desktop surface is loaded from the bridge's own address. A hostile page + * that resolves its own name to the loopback address — DNS rebinding — sends + * its own hostname instead, and is refused. + * - `Origin` is set by the browser from the page that made the request, and a + * cross-site request never carries the surface's own origin. + * + * Neither header is a secret, so neither is enough on its own: a local process + * can send either one. They are what makes the per-process secret hard to get + * hold of in the first place. Without them, a page could read `GET /api/session` + * and then do anything the surface can do. + */ + +/** + * @typedef {object} Surface + * @property {string} host The address the Host assigned to the bridge. + * @property {number} port The port the Host assigned to the bridge. + */ + +/** + * @typedef {{ok: true} | {ok: false, message: string}} Verdict + */ + +/** + * Decides whether a request may be answered. + * + * @param {import('node:http').IncomingMessage} request + * @param {Surface} surface + * @returns {Verdict} + */ +export function checkCaller(request, surface) { + const headers = request.headers ?? {}; + + const host = headers['host']; + const address = typeof host === 'string' ? parseHost(host) : null; + if (!address) { + return { ok: false, message: 'The request did not name the bridge.' }; + } + if (!isAssignedHost(address.hostname, surface.host)) { + return { ok: false, message: 'This request was not addressed to this bridge.' }; + } + if (address.port !== surface.port) { + return { ok: false, message: 'This request was not addressed to this bridge.' }; + } + + const origin = headers['origin']; + if (typeof origin === 'string' && origin.length > 0) { + // The origin has to be the origin the request was sent to. A page on + // another origin is another page, whatever it calls itself. + let from = null; + try { + from = new URL(origin); + } catch { + return { ok: false, message: 'This request came from somewhere else.' }; + } + if (from.protocol !== 'http:' && from.protocol !== 'https:') { + // `null` - an opaque origin, which is what a sandboxed page sends - and + // `file:` are not origins a page on this surface can have. + return { ok: false, message: 'This request came from somewhere else.' }; + } + if (Number(from.port) !== surface.port) { + return { ok: false, message: 'This request came from somewhere else.' }; + } + if (normalise(from.hostname) !== normalise(address.hostname)) { + return { ok: false, message: 'This request came from somewhere else.' }; + } + } + + // The browser says where the request came from, and a cross-site request is + // exactly what the two checks above are about. A repeated header is joined + // with a comma, so the value is read as a list. + const fetchSite = headers['sec-fetch-site']; + if (typeof fetchSite === 'string') { + const sites = fetchSite.split(',').map((site) => site.trim().toLowerCase()); + if (sites.includes('cross-site')) { + return { ok: false, message: 'This request came from somewhere else.' }; + } + } + + return { ok: true }; +} + +/** + * The host and port a `Host` header names. + * + * @param {string} value + * @returns {{hostname: string, port: number | null} | null} + */ +function parseHost(value) { + const trimmed = String(value ?? '').trim(); + if (!trimmed) return null; + + // An IPv6 literal: `[::1]:53378`. + const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(trimmed); + if (bracketed) { + return { + hostname: bracketed[1], + port: bracketed[2] === undefined ? null : Number(bracketed[2]), + }; + } + + const index = trimmed.lastIndexOf(':'); + if (index === -1) return { hostname: trimmed, port: null }; + const port = trimmed.slice(index + 1); + if (!/^\d+$/.test(port)) return null; + return { hostname: trimmed.slice(0, index), port: Number(port) }; +} + +/** + * Whether a hostname names the address the Host assigned to the bridge. + * + * The surface is served from that address, so it is the only hostname a + * legitimate request carries. `localhost` is accepted as well when the assigned + * address is a loopback one, because it names the same machine. + * + * A name that merely *contains* a loopback address — `127.0.0.1.example.com` — + * names somewhere else, and so does another loopback address: the bridge is + * reachable on the one the Host assigned, not on every one. + * + * @param {string} hostname + * @param {string} assigned + * @returns {boolean} + */ +function isAssignedHost(hostname, assigned) { + const host = normalise(hostname); + const want = normalise(assigned); + if (host === want) return true; + return want !== 'localhost' && isLoopbackHost(want) && host === 'localhost'; +} + +/** + * Whether a hostname names this machine. + * + * @param {string} hostname + * @returns {boolean} + */ +function isLoopbackHost(hostname) { + const host = normalise(hostname).replace(/^\[/, '').replace(/\]$/, ''); + if (host === 'localhost') return true; + if (host === '::1') return true; + // 127.0.0.0/8 is loopback. + return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host); +} + +/** + * A hostname as a name, without the case. + * + * @param {string} hostname + * @returns {string} + */ +function normalise(hostname) { + return String(hostname ?? '') + .trim() + .toLowerCase(); +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/capabilities.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/capabilities.mjs new file mode 100644 index 0000000..2f0d209 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/capabilities.mjs @@ -0,0 +1,254 @@ +/** + * What the desktop can do beyond answering in a chat. + * + * The desktop's own skills, plugins and agents are read from the Host's installation + * and nothing else. The bridge never runs them and never writes to them: it tells the + * phone what is there, so the user can ask for one by name — a skill the way the + * desktop writes it, `/name`. + * + * Everything here is best effort. A directory the Host does not have is simply not + * listed, because one missing folder must not turn the whole list into an error. + */ + +import { readdir, readFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +/** How many entries of one kind are listed. */ +const LIMIT = 300; + +/** The folders under the plugins directory that are not plugins themselves. */ +const NOT_PLUGINS = new Set(['cache', 'marketplaces']); + +/** + * The Host's home directory, found from the bridge's own data directory. + * + * The bridge's data lives in `/v2/plugin-data/liveboards/`, so the home + * is four levels up. + * + * @param {any} context + * @returns {string} the home directory, or '' when the bridge does not know where it is + */ +export function hostHome(context) { + const dataDir = String(context?.dataDir ?? ''); + if (!dataDir) return ''; + return resolve(dataDir, '..', '..', '..', '..'); +} + +/** + * The front matter of a Markdown file: the block between the first two `---` lines. + * + * A value may be quoted over several lines, which is how the longer descriptions are + * written, so a quote that is not closed yet takes the lines that follow it. + * + * @param {string} text + * @returns {Record} + */ +export function frontmatter(text) { + const block = /^---\r?\n([\s\S]*?)\r?\n---/.exec(String(text ?? '')); + if (!block) return {}; + /** @type {Record} */ + const out = {}; + const lines = block[1].split(/\r?\n/); + for (let index = 0; index < lines.length; index += 1) { + const pair = /^([A-Za-z][\w-]*):\s*(.*)$/.exec(lines[index]); + if (!pair) continue; + let value = pair[2].trim(); + if (value.startsWith('"') && !value.endsWith('"')) { + const parts = [value.slice(1)]; + while (index + 1 < lines.length) { + index += 1; + const next = lines[index].trim(); + parts.push(next); + if (next.endsWith('"')) { + parts[parts.length - 1] = next.slice(0, -1); + break; + } + } + value = parts.join(' ').trim(); + } else if (value.startsWith('"') && value.endsWith('"') && value.length > 1) { + value = value.slice(1, -1); + } + out[pair[1]] = value; + } + return out; +} + +/** + * The directories in a folder, or none when the folder cannot be read. + * + * A junction counts as a folder: the Host keeps the skills it installed as junctions + * into the user's own skill folders, and a reader that only accepts real folders would + * miss most of them. + * + * @param {string} dir + * @returns {Promise} + */ +async function directories(dir) { + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + return entries.filter( + (entry) => entry.isDirectory() || entry.isSymbolicLink(), + ); +} + +/** + * The skills the desktop has. + * + * A skill is a folder with a `SKILL.md` in it; its name and its description are in the + * file's front matter. Two places are read: the skills the user installed, and the ones + * the Host carries itself. + * + * @param {string} home + * @returns {Promise>} + */ +export async function readSkills(home) { + if (!home) return []; + const roots = [ + { dir: resolve(home, 'skills'), source: 'user' }, + { dir: resolve(home, '.builtin-skills'), source: 'built-in' }, + ]; + const skills = []; + const seen = new Set(); + for (const root of roots) { + for (const folder of await directories(root.dir)) { + if (folder.name.startsWith('.')) continue; + const text = await readFile(resolve(root.dir, folder.name, 'SKILL.md'), 'utf8').catch(() => ''); + if (!text) continue; + const meta = frontmatter(text); + const id = meta.name || folder.name; + if (seen.has(id)) continue; + seen.add(id); + skills.push({ + id, + name: meta.name || folder.name, + ...(meta.description ? { description: meta.description } : {}), + source: root.source, + }); + } + } + return skills.sort((a, b) => a.id.localeCompare(b.id)).slice(0, LIMIT); +} + +/** + * The plugins the desktop has. + * + * Two sources are read: the plugins installed under the Host's home, and the official + * ones the Host keeps in its cache. Both carry the same manifest. + * + * @param {string} home + * @returns {Promise>} + */ +export async function readPlugins(home) { + if (!home) return []; + const roots = [ + { dir: resolve(home, 'plugins'), source: 'installed' }, + { dir: resolve(home, 'v2', 'plugin-cache', 'official'), source: 'official' }, + ]; + const plugins = []; + for (const root of roots) { + for (const folder of await directories(root.dir)) { + if (root.source === 'installed' && NOT_PLUGINS.has(folder.name)) continue; + const manifest = await readFile( + resolve(root.dir, folder.name, '.minimax-plugin', 'plugin.json'), + 'utf8', + ).catch(() => ''); + if (!manifest) continue; + let meta; + try { + meta = JSON.parse(manifest); + } catch { + continue; + } + const id = String(meta.name ?? folder.name); + plugins.push({ + id, + name: String(meta.displayName ?? meta.name ?? folder.name), + ...(meta.description ? { description: String(meta.description) } : {}), + source: root.source, + ...(meta.version ? { version: String(meta.version) } : {}), + }); + } + } + // The same plugin can be installed and cached; the installed one is the one that runs. + const byId = new Map(); + for (const plugin of plugins) { + const known = byId.get(plugin.id); + if (!known || plugin.source === 'installed') byId.set(plugin.id, plugin); + } + return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)).slice(0, LIMIT); +} + +/** + * The agents the desktop has. + * + * An agent is a worker the desktop can hand a task to. Its own folder carries only its + * settings, so what the phone gets is the name it is called by. + * + * @param {string} home + * @returns {Promise>} + */ +export async function readAgents(home) { + if (!home) return []; + const root = resolve(home, 'agents'); + const folders = await directories(root); + const agents = []; + for (const folder of folders) { + if (folder.name.startsWith('.')) continue; + const settings = await readFile(resolve(root, folder.name, 'config.yaml'), 'utf8').catch(() => ''); + const description = settings.match(/description:\s*(.+)/)?.[1]?.trim(); + agents.push({ + id: folder.name, + name: folder.name, + ...(description ? { description } : {}), + source: 'installed', + }); + } + return agents.sort((a, b) => a.id.localeCompare(b.id)).slice(0, LIMIT); +} + +/** + * The MCP servers the desktop has. + * + * A Bot's turn can call the tools of these servers; the phone cannot call them itself, + * so the list is what the desktop can do, shown next to its skills and plugins. + * + * @param {string} home + * @returns {Promise>} + */ +export async function readMcpServers(home) { + if (!home) return []; + const registry = await readFile(resolve(home, 'mcp.json'), 'utf8').catch(() => ''); + if (!registry) return []; + let servers; + try { + servers = JSON.parse(registry)?.mcpServers; + } catch { + return []; + } + if (!servers || typeof servers !== 'object') return []; + return Object.entries(servers) + .map(([id, server]) => ({ + id, + name: id, + ...(server?.description ? { description: String(server.description) } : {}), + source: server?.builtin ? 'built-in' : 'user', + })) + .sort((a, b) => a.id.localeCompare(b.id)) + .slice(0, LIMIT); +} + +/** + * Everything the desktop can be asked to use. + * + * @param {any} context + * @returns {Promise<{skills: any[], plugins: any[], agents: any[], mcp: any[]}>} + */ +export async function readCapabilities(context) { + const home = hostHome(context); + const [skills, plugins, agents, mcp] = await Promise.all([ + readSkills(home), + readPlugins(home), + readAgents(home), + readMcpServers(home), + ]); + return { skills, plugins, agents, mcp }; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/config.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/config.mjs new file mode 100644 index 0000000..fb626cf --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/config.mjs @@ -0,0 +1,206 @@ +import { mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { writePrivateFile } from './private-file.mjs'; + +const CONFIG_FILE = 'bridge-config.json'; + +/** + * The file the bridge token lives in. + * + * The token is a credential for the account's room and it is not revocable on + * its own, so it does not share a file with the relay address: a file that is + * copied around - into a backup, into a support bundle - carries the address + * and not the credential. + */ +const TOKEN_FILE = 'bridge-token'; + +/** + * @typedef {object} BridgeConfig + * @property {string} relayUrl Public base URL of the relay, e.g. `wss://relay.example.workers.dev`. + * @property {string} accountId Room the bridge and its phones share. + * @property {string} bridgeId Stable id of this desktop. + * @property {string} bridgeToken HMAC token the relay accepts for this bridge. + * @property {string} bridgeName Display name shown on the phone. + * @property {Record} [botModes] Where each Bot's turns run. + */ + +/** + * Loads the bridge configuration. + * + * The bridge id is generated once and persisted, because the relay pairs a phone against + * a specific desktop and the phone must be able to find it again after a restart. + * + * @param {string} dataDir + * @param {Partial} [overrides] + * @returns {Promise} + */ +export async function loadConfig(dataDir, overrides = {}) { + await mkdir(dataDir, { recursive: true }); + const file = join(dataDir, CONFIG_FILE); + + let stored = {}; + try { + stored = JSON.parse(await readFile(file, 'utf8')); + } catch { + stored = {}; + } + + // The token used to share the configuration file. It has a file of its own now, + // written for its owner only, and a configuration that still carries it is + // moved. + const hadLegacyToken = Object.hasOwn(stored, 'bridgeToken'); + const legacyToken = typeof stored.bridgeToken === 'string' ? stored.bridgeToken : ''; + delete stored.bridgeToken; + + let fileToken = ''; + try { + fileToken = (await readFile(join(dataDir, TOKEN_FILE), 'utf8')).trim(); + } catch { + fileToken = ''; + } + const movedToken = !fileToken && Boolean(legacyToken); + if (movedToken) { + fileToken = legacyToken; + await writePrivateFile(join(dataDir, TOKEN_FILE), fileToken); + } + + const config = { + relayUrl: overrides.relayUrl ?? stored.relayUrl ?? '', + accountId: overrides.accountId ?? stored.accountId ?? 'default', + bridgeId: overrides.bridgeId ?? stored.bridgeId ?? '', + bridgeToken: overrides.bridgeToken ?? fileToken, + bridgeName: overrides.bridgeName ?? stored.bridgeName ?? 'Desktop', + botModes: { ...(stored.botModes ?? {}) }, + }; + + // A relay address that is not one the bridge may use is not used at all. The + // bridge holds the long-lived token, so it never presents it to an address it + // cannot vouch for; the desktop is left unconfigured instead, which is the + // state a fresh install is in anyway. + if (config.relayUrl && !isAllowedRelayUrl(config.relayUrl)) { + config.relayUrl = ''; + } + + if (!config.bridgeId) { + config.bridgeId = `bridge_${randomId()}`; + await saveConfig(dataDir, config); + } else if (hadLegacyToken) { + // Cleanup must also recover an interrupted migration where bridge-token was + // already written. Never replace that authoritative file with the residue. + const { bridgeToken, ...publicConfig } = config; + await writePrivateFile(file, `${JSON.stringify(publicConfig, null, 2)}\n`); + } + + return config; +} + +/** + * @param {string} dataDir + * @param {BridgeConfig} config + * @returns {Promise} + */ +export async function saveConfig(dataDir, config) { + await mkdir(dataDir, { recursive: true }); + const { bridgeToken, ...rest } = config; + await writePrivateFile(join(dataDir, CONFIG_FILE), `${JSON.stringify(rest, null, 2)}\n`); + if (bridgeToken) { + await writePrivateFile(join(dataDir, TOKEN_FILE), bridgeToken); + } +} + +/** + * The scheme each transport uses, so that one relay address names one relay. + * + * The frame channel is a WebSocket and the pairing request is an HTTP POST, and the + * operator is given a single address. `wss://` and `https://` therefore name the + * same relay, and the token it carries must not be able to travel from one to the + * other unnoticed. + */ +const TO_HTTP = { + 'http:': 'http:', + 'https:': 'https:', + 'ws:': 'http:', + 'wss:': 'https:', +}; + +/** + * The origin of a relay address, in its HTTP form, or `''` when the address is + * not a relay address. + * + * Two addresses with the same origin are the same relay, so a token minted for one + * of them is a token for the other. + * + * @param {string} value + * @returns {string} + */ +export function relayOrigin(value) { + const trimmed = String(value ?? '').trim().replace(/\/+$/, ''); + const match = /^([a-z][a-z0-9+.-]*:)/i.exec(trimmed); + if (!match) return ''; + const scheme = match[1].toLowerCase(); + const target = TO_HTTP[scheme]; + if (!target) return ''; + try { + return new URL(target + trimmed.slice(scheme.length)).origin; + } catch { + return ''; + } +} + +/** + * Whether the bridge may talk to this relay address. + * + * The bridge holds the long-lived bridge token, so it is strict about where that + * token goes. A secure relay is fine wherever it is; a cleartext one is only the + * relay on this machine, because anything else on the network would read the token + * off the wire. A LAN address, a link-local address and a metadata address are all + * refused: they are the addresses that are reachable but not meant to be trusted. + * + * @param {string} value + * @returns {boolean} + */ +export function isAllowedRelayUrl(value) { + const trimmed = String(value ?? '').trim(); + const match = /^([a-z][a-z0-9+.-]*:)/i.exec(trimmed); + if (!match) return false; + const scheme = match[1].toLowerCase(); + + if (scheme === 'https:' || scheme === 'wss:') { + try { + new URL(trimmed); + return true; + } catch { + return false; + } + } + + if (scheme !== 'http:' && scheme !== 'ws:') return false; + + // Cleartext is the local relay and nothing else. + try { + return isLoopbackHost(new URL(trimmed).hostname); + } catch { + return false; + } +} + +/** + * @param {string} hostname + * @returns {boolean} + */ +function isLoopbackHost(hostname) { + const host = hostname.toLowerCase().replace(/^\[/, '').replace(/\]$/, ''); + if (host === 'localhost') return true; + if (host === '::1') return true; + // 127.0.0.0/8 is loopback. + return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host); +} + +function randomId() { + const cryptoObj = globalThis.crypto; + if (cryptoObj && typeof cryptoObj.randomUUID === 'function') { + return cryptoObj.randomUUID().replace(/-/g, '').slice(0, 16); + } + return Math.floor(Math.random() * 0xffffffffffff).toString(16); +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-team.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-team.mjs new file mode 100644 index 0000000..195ca35 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-team.mjs @@ -0,0 +1,331 @@ +/** + * The desktop's own Bot data. + * + * The team tools answer with a summary: an id, a name, a role, a model. That is + * enough to list a team, but not to *show* one — the look a Bot wears, its face, its + * own picture and the artwork behind that look all live in the MiniMax Bot app's own + * files on this desktop. + * + * So this module reads them there, read-only: the desktop keeps the only copy of the + * team, and the phone shows the very same Bot the desktop shows. Nothing is copied + * into the app; the picture is asked for over the link and travels as bytes. + * + * Every lookup fails soft. A desktop without the Bot app, an older layout, a missing + * picture — all of them mean "no picture", never a broken screen. + */ +import { readFile, readdir, stat } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { desktopMessage, desktopTurn } from './desktop-turn.mjs'; + +/** The plugin id of the MiniMax Bot app. */ +const BOT_PLUGIN_ID = 'minimax-bot'; + +/** The name of the Bot app's own state file. */ +const STATE_FILE = 'state.json'; + +/** Where the Bot app keeps the pictures its Bots uploaded themselves. */ +const AVATAR_DIR = 'avatars'; + +/** + * A look id, e.g. `fuzzy`. + * + * The id names a file on the desktop, so it is restricted to a plain word: nothing + * that could walk out of the skins folder. + */ +const SKIN_ID = /^[a-z][a-z0-9_-]{0,31}$/; + +/** The name of an uploaded picture. */ +const AVATAR_FILE = /^[A-Za-z0-9_-]+\.(?:png|jpe?g|webp|gif)$/i; + +/** + * The Bot app's data directory: the sibling of this bridge's own directory. + * + * The runtime keeps one directory per Mini App, named after the plugin, so the Bot + * app's data sits next to ours rather than inside it. + */ +export function botDataDir(context) { + return resolve(context.dataDir, '..', BOT_PLUGIN_ID); +} + +/** + * The name of the file a conversation is kept in. + * + * The Bot app names a conversation's file after the conversation, with everything that + * is not a plain word character turned into an underscore. The same rule is used here, + * so that the phone reads the very file the desktop writes. + */ +function messageFile(chatId) { + return `${chatId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`; +} + +/** + * The conversation the desktop holds, in the protocol's shape. + * + * The desktop's own transcript is the record of what was said: it survives a restart of + * anything, and it is the same conversation the desktop shows. The phone reads it + * read-only — the desktop's copy is never touched — and a turn the bridge runs on the + * desktop itself is not in it, which is why the two are read together. + * + * A Bot's turn is one record on the desktop that holds the whole exchange, so it + * reaches the phone as the messages and the tool calls that record holds. + * + * @param {any} context the Mini App context + * @param {string} chatId `bot:` or `group:` + * @returns {Promise<{messages: Array, activity: Array}>} + */ +export async function desktopMessages(context, chatId) { + const records = await readDesktopRecords(context, chatId); + const messages = []; + const activity = []; + for (const record of records) { + if (record?.role === 'bot') { + const turn = desktopTurn(record); + messages.push(...turn.messages); + activity.push(...turn.activity); + continue; + } + const message = desktopMessage(record, chatId); + if (message.messageId) messages.push(message); + } + return { messages, activity }; +} + +/** + * One turn of the desktop's own transcript, by the id of its record. + * + * A turn the team ran is written into the desktop's transcript, and the id of that + * record comes back with the turn's receipt, so the exact exchange the desktop shows + * can be read back and shown on the phone. + * + * @param {any} context the Mini App context + * @param {string} chatId `bot:` or `group:` + * @param {string} messageId the id of the record the turn wrote + * @returns {Promise<{messages: Array, activity: Array} | undefined>} + */ +export async function desktopTurnById(context, chatId, messageId) { + if (typeof messageId !== 'string' || !messageId) return undefined; + const records = await readDesktopRecords(context, chatId); + const record = records.find((entry) => entry?.id === messageId); + if (!record) return undefined; + return desktopTurn(record); +} + +/** + * One turn of the desktop's own transcript, by the task it ran under. + * + * A turn can be written as more than one record — a group can have more than one Bot + * answer — and those records share the task the turn ran under, so the task finds the + * whole exchange of that turn. + * + * @param {any} context the Mini App context + * @param {string} chatId `bot:` or `group:` + * @param {string} taskId the task the turn ran under + * @returns {Promise<{messages: Array, activity: Array} | undefined>} + */ +export async function desktopTurnsByTask(context, chatId, taskId) { + if (typeof taskId !== 'string' || !taskId) return undefined; + const records = await readDesktopRecords(context, chatId); + const mine = records.filter((entry) => entry?.role === 'bot' && entry?.taskId === taskId); + if (mine.length === 0) return undefined; + const messages = []; + const activity = []; + for (const record of mine) { + const turn = desktopTurn(record); + messages.push(...turn.messages); + activity.push(...turn.activity); + } + return { messages, activity }; +} + +/** + * The records the desktop keeps for one conversation. + * + * Every read fails soft: a desktop without the Bot app, a conversation that was never + * had, an older layout — all of them mean "nothing to read", never a broken screen. + */ +async function readDesktopRecords(context, chatId) { + if (typeof chatId !== 'string' || !chatId) return []; + try { + const raw = await readFile(join(botDataDir(context), 'messages', messageFile(chatId)), 'utf8'); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : []; + } catch { + return []; + } +} + +/** + * The Bot app's live team state, or `undefined` when it cannot be read. + * + * The state is the desktop's own record of the team: who is on it, what each one is + * for, and what each one looks like. + */ +export async function readDesktopState(context) { + try { + const raw = await readFile(join(botDataDir(context), STATE_FILE), 'utf8'); + const parsed = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? parsed : undefined; + } catch { + return undefined; + } +} + +/** + * The team as the desktop holds it, in the protocol's shape. + * + * Only the fields the phone shows are carried over: the look, the face, the colour, + * the Bot's own picture and what it is for. + */ +export function desktopBots(state) { + const bots = Array.isArray(state?.bots) ? state.bots : []; + return bots + .map((bot) => ({ + botId: typeof bot?.id === 'string' ? bot.id : '', + name: typeof bot?.name === 'string' ? bot.name : '', + role: typeof bot?.role === 'string' && bot.role ? bot.role : undefined, + instructions: typeof bot?.instructions === 'string' && bot.instructions ? bot.instructions : undefined, + skin: typeof bot?.skin === 'string' && bot.skin ? bot.skin : undefined, + color: typeof bot?.color === 'string' && bot.color ? bot.color : undefined, + face: typeof bot?.face === 'string' && bot.face ? bot.face : undefined, + avatar: typeof bot?.avatar === 'string' && bot.avatar ? bot.avatar : undefined, + isMain: bot?.isMain === true, + })) + .filter((bot) => bot.botId); +} + +/** + * The Host session a Bot last answered in, for one conversation. + * + * The desktop keeps a session per chat and Bot: it is what lets a Bot remember what it + * was told. Reading it here is read-only. + * + * The bridge does not run a phone's turn in this session. That session belongs to + * whatever the user was doing on the desktop — a coding conversation, most of the time — + * and a phone message dropped into it is answered in the middle of that conversation. + * + * @param {any} state the desktop's state + * @param {string} chatId `bot:` or `group:` + * @param {string} botId + * @returns {string | undefined} + */ +export function desktopBotSession(state, chatId, botId) { + const sessions = state?.sessions; + if (!sessions || typeof sessions !== 'object') return undefined; + const forChat = sessions[chatId]; + if (!forChat || typeof forChat !== 'object') return undefined; + const sessionId = forChat[botId]; + return typeof sessionId === 'string' && sessionId ? sessionId : undefined; +} + +/** + * The groups as the desktop holds them, in the protocol's shape. + * + * The desktop knows the members by id; the phone needs those ids to open a group + * chat, so they are carried over as they are. + */ +export function desktopGroups(state) { + const groups = Array.isArray(state?.groups) ? state.groups : []; + return groups + .map((group) => ({ + groupId: typeof group?.id === 'string' ? group.id : '', + name: typeof group?.name === 'string' ? group.name : '', + admin: typeof group?.adminId === 'string' ? group.adminId : undefined, + memberIds: Array.isArray(group?.memberIds) + ? group.memberIds.filter((id) => typeof id === 'string') + : [], + notice: typeof group?.notice === 'string' && group.notice ? group.notice : undefined, + replyMode: group?.replyMode === 'all' || group?.replyMode === 'admin' ? group.replyMode : undefined, + })) + .filter((group) => group.groupId); +} + +/** + * The picture of the Bot app's client files. + * + * The Bot app renders every look from its own artwork, so the file that matches a + * look is the picture the desktop shows. + */ +async function findSkinFile(context, skin) { + if (!SKIN_ID.test(skin)) return undefined; + + // The runtime's cache of official plugins, one directory per plugin version. + const cache = resolve(context.dataDir, '..', '..', '..', 'plugin-cache', 'official'); + let entries; + try { + entries = await readdir(cache, { withFileTypes: true }); + } catch { + return undefined; + } + + let best; + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const root = join(cache, entry.name); + const file = join(root, 'miniapp', 'client', 'skins', `${skin}.png`); + const info = await stat(file).catch(() => undefined); + if (!info?.isFile()) continue; + // Only the Bot app's own artwork counts: another plugin may carry a file with + // the same name for a different picture. + if (!(await isBotPlugin(root))) continue; + const mtime = (await stat(root).catch(() => undefined))?.mtimeMs ?? 0; + if (!best || mtime > best.mtime) best = { file, mtime }; + } + return best?.file; +} + +/** Whether a cached plugin directory holds the MiniMax Bot app. */ +async function isBotPlugin(root) { + try { + const manifest = JSON.parse(await readFile(join(root, 'miniapp', 'miniapp.json'), 'utf8')); + const servers = Array.isArray(manifest?.mcpEndpoints) + ? manifest.mcpEndpoints.map((endpoint) => endpoint?.server) + : []; + if (servers.includes(BOT_PLUGIN_ID)) return true; + return manifest?.surface?.path === `/${BOT_PLUGIN_ID}`; + } catch { + return false; + } +} + +/** The kind of image the bytes hold, from the bytes themselves. */ +export function mimeOf(buffer) { + if (!Buffer.isBuffer(buffer) || buffer.length < 12) return 'application/octet-stream'; + if (buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) { + return 'image/png'; + } + if (buffer[0] === 0xff && buffer[1] === 0xd8) return 'image/jpeg'; + if (buffer.subarray(0, 3).toString('latin1') === 'GIF') return 'image/gif'; + if (buffer.subarray(0, 4).toString('latin1') === 'RIFF' && buffer.subarray(8, 12).toString('latin1') === 'WEBP') { + return 'image/webp'; + } + return 'application/octet-stream'; +} + +/** + * The picture a Bot shows, read from the desktop. + * + * A Bot that has a picture of its own shows that. A Bot that wears a look shows the + * artwork of that look. A Bot with neither has no picture, and the phone draws its + * octopus instead. + */ +export async function readBotPicture(context, bot) { + if (!bot) return undefined; + + // The Bot's own picture, uploaded on the desktop. + const avatarName = typeof bot.avatar === 'string' ? bot.avatar : ''; + if (AVATAR_FILE.test(avatarName)) { + const file = join(botDataDir(context), AVATAR_DIR, avatarName); + const buffer = await readFile(file).catch(() => undefined); + if (buffer?.length) return { buffer, mime: mimeOf(buffer), source: 'avatar' }; + } + + // The artwork of the look the Bot wears. + const skin = typeof bot.skin === 'string' ? bot.skin : ''; + if (!skin) return undefined; + const file = await findSkinFile(context, skin); + if (!file) return undefined; + const buffer = await readFile(file).catch(() => undefined); + if (!buffer?.length) return undefined; + return { buffer, mime: mimeOf(buffer), source: 'skin' }; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-turn.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-turn.mjs new file mode 100644 index 0000000..7c4a3c4 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/desktop-turn.mjs @@ -0,0 +1,136 @@ +/** + * The desktop's own record of a turn, in the phone's shape. + * + * The desktop keeps one record for a turn, and that record holds the whole exchange: + * what the Bot said while it worked, which tools it used, and the answer it ended with. + * The desktop draws one bubble for every part of it, so the phone has to be given the + * same parts — the same words, in the same order — or the two surfaces show different + * conversations. + * + * The record's own `text` is only the answer the turn ended with. The narration lives + * in `parts`, and it is the parts that make up the exchange. + * + * This module is pure: it turns a record into what the phone shows. Reading the file + * is `desktop-team.mjs`'s job, so that the mapping can be tested without a desktop. + */ + +/** How a tool call can have ended in the desktop's own record. */ +const TOOL_STATES = new Set(['ok', 'failed', 'unknown']); + +/** + * The state of a tool call, as the desktop recorded it. + * + * A call the desktop never saw report back is `unknown`, which is not the same as a + * failure, so it keeps its own name instead of being folded into `failed`. + */ +function toolState(state) { + return typeof state === 'string' && TOOL_STATES.has(state) ? state : 'unknown'; +} + +/** + * The messages a turn's record holds, in the desktop's own order. + * + * Every part of the record that the desktop draws as a bubble becomes one message on + * the phone. The answer the turn ended with keeps the record's own id, so a reaction + * given to it belongs to the same message on both surfaces. + * + * @param {any} record one record of the desktop's own transcript + * @returns {{messages: Array, activity: Array}} + */ +export function desktopTurn(record) { + const chatId = typeof record?.chatId === 'string' ? record.chatId : ''; + const id = typeof record?.id === 'string' ? record.id : ''; + // The task the turn ran under names the turn itself, and it is the one name both + // surfaces know the turn by. + const turnId = typeof record?.taskId === 'string' && record.taskId ? record.taskId : id; + const ts = typeof record?.ts === 'number' ? record.ts : 0; + const authorId = typeof record?.botId === 'string' && record.botId ? record.botId : undefined; + const answer = typeof record?.text === 'string' ? record.text : ''; + const parts = Array.isArray(record?.parts) ? record.parts : []; + + const messages = []; + const activity = []; + /** The index of the part that holds the answer the turn ended with. */ + let answerIndex = -1; + + parts.forEach((part, index) => { + if (!part || typeof part !== 'object') return; + if (part.type === 'text') { + const text = typeof part.text === 'string' ? part.text : ''; + // The desktop drops the parts that hold nothing, and so does this. + if (!text.trim()) return; + // The last part that holds the answer is the answer: the desktop ends a turn with + // it, and it is what the record's own text is. + if (text.trim() === answer.trim()) answerIndex = messages.length; + messages.push({ + messageId: `${id}#p${index}`, + chatId, + role: 'assistant', + text, + ts, + ...(authorId ? { authorId } : {}), + turnId, + }); + return; + } + if (part.type === 'tool') { + const toolName = typeof part.name === 'string' ? part.name : ''; + if (!toolName) return; + activity.push({ + turnId, + callId: `${id}#t${index}`, + toolName, + status: toolState(part.state), + }); + return; + } + // An 'event' part is the desktop's own bookkeeping — a Bot was created, a group was + // posted to — and the phone has no counterpart for it, so it is left out rather + // than turned into something the desktop never said. + }); + + if (answerIndex >= 0) { + messages[answerIndex] = { ...messages[answerIndex], messageId: id, text: answer }; + } else if (answer.trim()) { + // A record with no parts at all still holds an answer, and the desktop draws it. + messages.push({ + messageId: id, + chatId, + role: 'assistant', + text: answer, + ts, + ...(authorId ? { authorId } : {}), + turnId, + }); + } + + return { messages, activity }; +} + +/** + * A message of the desktop's own record that is not a Bot's answer. + * + * The desktop calls a Bot's own answer 'bot', a message that arrived from somewhere + * else 'relay', and a message the user typed there 'user'. The phone calls the first + * an assistant message and the other two the user's. + * + * @param {any} record one record of the desktop's own transcript + * @param {string} chatId the conversation the record belongs to + * @returns {import('./contracts.d.ts').ChatMessage} + */ +export function desktopMessage(record, chatId) { + const id = typeof record?.id === 'string' ? record.id : ''; + const authorId = typeof record?.botId === 'string' && record.botId ? record.botId : undefined; + return { + messageId: id, + chatId, + role: record?.role === 'bot' ? 'assistant' : 'user', + text: typeof record?.text === 'string' ? record.text : '', + ts: typeof record?.ts === 'number' ? record.ts : 0, + ...(authorId ? { authorId } : {}), + // A message the user typed on the desktop is marked, so the phone can say where it + // came from. A message that arrived here from the phone is the user's own, and the + // desktop already says that it came from elsewhere. + ...(record?.role === 'user' ? { origin: 'desktop' } : {}), + }; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/ledger.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/ledger.mjs new file mode 100644 index 0000000..11a1432 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/ledger.mjs @@ -0,0 +1,270 @@ +/** + * Usage and execution ledger. + * + * The MiniMax Bot Mini App serves usage from its own store, which a bridge cannot read. + * The receipts the team tools return do carry token usage, so the bridge reconstructs the + * same numbers from the calls it makes and the turns it runs. + */ + +import { mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { createHash } from 'node:crypto'; + +import { writePrivateFileAtomic } from './private-file.mjs'; + +const LEDGER_FILE = 'ledger.json'; +const MAX_ENTRIES = 5000; +/** How many dispatched batches are remembered for the task list. */ +const MAX_BATCHES = 50; + +export class Ledger { + /** @param {string} dataDir @param {{maxEntries?: number, room?: string}} [options] */ + constructor(dataDir, { maxEntries = MAX_ENTRIES, room = undefined } = {}) { + this.room = room; + // Legacy unscoped ledgers are deliberately never adopted by a configured room. + this.dataDir = room === undefined ? dataDir : join(dataDir, 'rooms', createHash('sha256').update(room).digest('hex')); + /** @type {Array<{chatId?: string, taskId?: string, messageId?: string}>} */ + this.historyRefs = []; + this.maxEntries = maxEntries; + /** @type {Array} */ + this.entries = []; + /** + * The batches the bridge has dispatched. + * + * The team's task tool answers for one batch at a time and has no "list + * everything" call, so the batch ids are what make a task list possible at all. + * They are kept across restarts for the same reason. + * + * @type {string[]} + */ + this.batchIds = []; + this.writeQueue = Promise.resolve(); + } + + async init() { + await mkdir(this.dataDir, { recursive: true }); + try { + const parsed = JSON.parse(await readFile(join(this.dataDir, LEDGER_FILE), 'utf8')); + if (this.room !== undefined && parsed.room !== this.room) return this; + this.historyRefs = Array.isArray(parsed.historyRefs) ? parsed.historyRefs.slice(-this.maxEntries) : []; + if (Array.isArray(parsed.entries)) { + this.entries = parsed.entries.slice(-this.maxEntries); + } + if (Array.isArray(parsed.batchIds)) { + this.batchIds = parsed.batchIds.slice(-MAX_BATCHES); + } + } catch { + this.entries = []; + this.batchIds = []; + } + return this; + } + + persist() { + const contents = `${JSON.stringify({ entries: this.entries, batchIds: this.batchIds, + ...(this.room === undefined ? {} : { room: this.room, historyRefs: this.historyRefs }), + }, null, 2)}\n`; + const write = this.writeQueue.then(async () => { + await mkdir(this.dataDir, { recursive: true }); + await writePrivateFileAtomic(join(this.dataDir, LEDGER_FILE), contents); + }); + // A failed write belongs to its caller, not to every later admitted record. + this.writeQueue = write.then(() => {}, () => {}); + return write; + } + + /** Keep references, not copies of the Desktop product's transcript. */ + rememberTurn({ chatId = undefined, taskId = undefined, messageId = undefined } = {}) { + if (!taskId && !messageId) return; + if (this.historyRefs.some(ref => ref.chatId === chatId && ref.taskId === taskId && ref.messageId === messageId)) return; + this.historyRefs.push({ chatId, taskId, messageId }); + if (this.historyRefs.length > this.maxEntries) this.historyRefs.splice(0, this.historyRefs.length - this.maxEntries); + } + + /** Remembers a dispatched batch, so its tasks can be read back later. */ + rememberBatch(batchId) { + if (!batchId || this.batchIds.includes(batchId)) return; + this.batchIds.push(batchId); + if (this.batchIds.length > MAX_BATCHES) { + this.batchIds = this.batchIds.slice(-MAX_BATCHES); + } + } + + /** The remembered batch ids, oldest first. */ + batches() { + return [...this.batchIds]; + } + + /** Records a completed turn. */ + recordTurn({ botId, botName, chars = 0, durationMs = 0, error = false, tokens = undefined, cost = undefined }) { + this.push({ + ts: Date.now(), + kind: 'turn', + botId, + botName, + chars, + durationMs, + error, + ...(typeof tokens === 'number' && Number.isFinite(tokens) && tokens >= 0 ? { tokens } : {}), + ...(typeof cost === 'number' && Number.isFinite(cost) && cost >= 0 ? { cost } : {}), + }); + } + + /** Records a completed tool call. */ + recordTool({ botId, toolName, ok = true, durationMs = 0, tokens = 0, error = false }) { + this.push({ + ts: Date.now(), + kind: 'tool', + botId, + toolName, + ok, + tokens, + durationMs, + error, + }); + } + + /** + * Records a permission decision. + * + * A decision is not a tool call and it is not usage: it is a record of what + * was allowed, by which phone, and when. It is kept so that the answer can be + * attributed later. + */ + recordPermission({ requestId, toolName, deviceId, decision }) { + this.push({ + ts: Date.now(), + kind: 'permission', + requestId, + toolName, + deviceId, + decision, + }); + } + + /** + * Records a change to who may reach the relay. + * + * A revoked phone and a rotated bridge token are the two moments when the set + * of people who can drive the desktop changes. A record of what was allowed is + * only worth something if the changes are in it too. + */ + recordAccessChange({ action, subject, detail }) { + this.push({ + ts: Date.now(), + kind: 'access', + action, + subject, + detail, + }); + } + + push(entry) { + this.entries.push(entry); + if (this.entries.length > this.maxEntries) { + this.entries = this.entries.slice(-this.maxEntries); + } + } + + /** + * Builds a usage report. + * + * A span of one day or less is bucketed by hour, anything longer by day, matching how + * the MiniMax Bot usage panel reads. + */ + report({ days = 7, botNames = {} } = {}) { + const hourly = days <= 1; + // Hourly mode always spans a full day; day mode spans the requested window. + const span = hourly ? 24 : Math.max(1, Math.min(90, days)); + const now = new Date(); + const start = new Date(now); + if (hourly) { + start.setMinutes(0, 0, 0); + start.setHours(start.getHours() - 23); + } else { + start.setHours(0, 0, 0, 0); + start.setDate(start.getDate() - (span - 1)); + } + const startMs = start.getTime(); + + const buckets = new Map(); + for (let i = 0; i < span; i += 1) { + const cursor = new Date(start); + if (hourly) cursor.setHours(start.getHours() + i); + else cursor.setDate(start.getDate() + i); + const key = bucketKey(cursor, hourly); + buckets.set(key, { + key, + label: hourly ? `${String(cursor.getHours()).padStart(2, '0')}:00` : `${cursor.getMonth() + 1}/${cursor.getDate()}`, + turns: 0, + chars: 0, + tools: 0, + durationMs: 0, + errors: 0, + }); + } + + const byBot = new Map(); + const totals = { turns: 0, chars: 0, tools: 0, durationMs: 0, errors: 0 }; + + for (const entry of this.entries) { + if (entry.ts < startMs) continue; + // A decision and an access change are records, not usage. They say what + // was allowed and by whom, and counting them as tool calls would put them + // into the usage totals and into a bot that is not there. + if (entry.kind !== 'turn' && entry.kind !== 'tool') continue; + const bucket = buckets.get(bucketKey(new Date(entry.ts), hourly)); + if (!bucket) continue; + + const botId = entry.botId ?? 'unknown'; + const bot = byBot.get(botId) ?? { + botId, + name: entry.botName ?? botNames[botId] ?? botId, + turns: 0, + chars: 0, + tools: 0, + }; + + if (entry.kind === 'turn') { + bucket.turns += 1; + bucket.chars += entry.chars ?? 0; + bucket.durationMs += entry.durationMs ?? 0; + totals.turns += 1; + totals.chars += entry.chars ?? 0; + totals.durationMs += entry.durationMs ?? 0; + bot.turns += 1; + bot.chars += entry.chars ?? 0; + } else { + bucket.tools += 1; + totals.tools += 1; + bot.tools += 1; + } + + if (entry.error) { + bucket.errors += 1; + totals.errors += 1; + } + + byBot.set(botId, bot); + } + + return { + buckets: [...buckets.values()], + byBot: [...byBot.values()].sort((a, b) => b.turns - a.turns), + totals, + }; + } + + /** The most recent calls, newest first. Drives the execution-records view. */ + recent(limit = 50) { + return this.entries.slice(-limit).reverse(); + } +} + +function bucketKey(date, hourly) { + const pad = (value) => String(value).padStart(2, '0'); + if (hourly) { + return `${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:00`; + } + return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/pending.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/pending.mjs new file mode 100644 index 0000000..41d60c0 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/pending.mjs @@ -0,0 +1,165 @@ +/** + * Pending approvals and questions. + * + * A turn that stops for a permission check or a questionnaire is held by the desktop, not + * by the socket, so the phone can answer it minutes later — or after a reconnect. The + * pending set is re-emitted whenever a device resumes so a phone that was offline still + * sees the request. + */ + +const DEFAULT_TTL_MS = 10 * 60 * 1000; + +export class PendingSet { + constructor({ ttlMs = DEFAULT_TTL_MS, now = () => Date.now() } = {}) { + this.ttlMs = ttlMs; + this.now = now; + /** @type {Map} */ + this.items = new Map(); + /** The token of the last claim, so that no two claims share one. */ + this.claimCounter = 0; + /** Resolved ids retain their original expiry, so event replay cannot reopen them. */ + /** @type {Map} */ + this.resolved = new Map(); + this.onExpired = (_id, _item) => {}; + /** @type {ReturnType | undefined} */ + this.expiryTimer = undefined; + } + + /** @param {'permission'|'question'} kind @param {import('./contracts.d.ts').PendingRequest} request @param {string} sessionId */ + add(kind, request, sessionId) { + this.expire(); + const previous = this.items.get(request.requestId) ?? this.resolved.get(request.requestId); + if (previous) return { ...previous.request, expiresAt: previous.expiresAt }; + const retainedUntil = this.now() + this.ttlMs; + const expiresAt = typeof request.expiresAt === 'number' && Number.isFinite(request.expiresAt) + ? Math.min(retainedUntil, request.expiresAt) : retainedUntil; + this.items.set(request.requestId, { kind, request, sessionId, expiresAt }); + this.scheduleExpiry(); + return { ...request, expiresAt }; + } + + seen(requestId) { + this.expire(); + return this.items.has(requestId) || this.resolved.has(requestId); + } + + get(requestId) { + this.expire(); + return this.items.get(requestId); + } + + /** + * Claims a request for an answer. + * + * The claim is taken before the Host is called, so a second answer to the same + * request finds nothing to answer and is refused. Without the claim, both answers + * pass the lookup and both reach the Host, and a phone that double-taps the approve + * button produces two answers rather than one. + * + * The claim carries a token, so that a caller can only give back a claim that is + * its own: a request whose id was reused while a claim was in flight would + * otherwise be released by the wrong caller. + * + * @param {string} requestId + * @param {'permission'|'question'} [kind] + * @returns the claimed item, or undefined when there is nothing to claim + */ + claim(requestId, kind) { + this.expire(); + const item = this.items.get(requestId); + if (!item) return undefined; + if (kind && item.kind !== kind) return undefined; + if (item.claimed) return undefined; + this.claimCounter += 1; + item.claimed = true; + item.claimId = this.claimCounter; + return item; + } + + /** + * Gives a claimed request back, so that it can be answered again. + * + * This is for the case where the Host did not take the answer: the request is still + * waiting for one, and the phone is allowed to give it. + * + * @param {string} requestId + * @param {number} [claimId] the token of the claim being given back + */ + release(requestId, claimId) { + const item = this.items.get(requestId); + if (!item) return false; + // Only the holder of the claim may give it back. + if (claimId !== undefined && item.claimId !== claimId) return false; + item.claimed = false; + item.claimId = undefined; + return true; + } + + /** + * The requests that are waiting for an answer. + * + * A request that is being answered right now is not waiting for one, so it is not + * listed: a phone that reconnects must not be shown a request that cannot be + * answered. + * + * @param {'permission'|'question'} [kind] + */ + listUnclaimed(kind) { + this.expire(); + const all = [...this.items.values()].filter((item) => !item.claimed); + return kind ? all.filter((item) => item.kind === kind) : all; + } + + resolve(requestId) { + this.expire(); + const item = this.items.get(requestId); + if (!item) return false; + this.resolved.set(requestId, item); + this.items.delete(requestId); + this.scheduleExpiry(); + return true; + } + + list(kind) { + this.expire(); + const all = [...this.items.values()]; + return kind ? all.filter((item) => item.kind === kind) : all; + } + + /** Drops expired requests. Returns the ids that were removed. */ + expire() { + const now = this.now(); + const removed = []; + for (const [id, item] of this.items) { + if (item.expiresAt <= now) { + this.items.delete(id); + removed.push(id); + this.onExpired(id, item); + } + } + for (const [id, item] of this.resolved) { + if (item.expiresAt <= now) this.resolved.delete(id); + } + this.scheduleExpiry(); + return removed; + } + + /** One unref'd timer resolves stale UI even when no phone makes another call. */ + scheduleExpiry() { + if (this.expiryTimer) clearTimeout(this.expiryTimer); + this.expiryTimer = undefined; + if (!this.items.size) return; + let next = Infinity; + for (const item of this.items.values()) next = Math.min(next, item.expiresAt); + this.expiryTimer = setTimeout(() => { + this.expiryTimer = undefined; + this.expire(); + }, Math.max(1, Math.min(2_147_483_647, next - this.now()))); + this.expiryTimer.unref?.(); + } + + get size() { + this.expire(); + return this.items.size; + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/private-file.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/private-file.mjs new file mode 100644 index 0000000..b5655af --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/private-file.mjs @@ -0,0 +1,57 @@ +/** + * Writing files that hold a credential. + * + * A file the bridge writes with the default mode is readable by every account on + * the machine. The bridge token is a credential for the account's room, so the + * files that hold it are written for their owner only. + */ + +import { chmod, open, rename, unlink, writeFile } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; + +/** The mode a file with a credential in it is written with. */ +export const PRIVATE_FILE_MODE = 0o600; + +/** + * Writes a file only its owner can read. + * + * The mode of `writeFile` only applies to a file that is created, so a file that + * already exists is set again - otherwise a configuration written before this + * would keep the mode it was created with. + * + * @param {string} path + * @param {string} contents + */ +export async function writePrivateFile(path, contents) { + await writeFile(path, contents, { encoding: 'utf8', mode: PRIVATE_FILE_MODE }); + try { + await chmod(path, PRIVATE_FILE_MODE); + } catch { + // A file system without modes (Windows) has nothing to set. + } +} + +/** + * Replaces a private file only after its complete replacement has reached disk. + * A failed write/sync/rename never truncates the previous file. This is not a + * cross-process lock or a promise against filesystem/power-loss failures. + * @param {string} path + * @param {string} contents + */ +export async function writePrivateFileAtomic(path, contents) { + const temporary = `${path}.${randomUUID()}.tmp`; + let created = false; + try { + // Mark ownership before writing: a partial failed write still needs cleanup. + const handle = await open(temporary, 'wx', PRIVATE_FILE_MODE); + created = true; + await handle.close(); + await writePrivateFile(temporary, contents); + const complete = await open(temporary, 'r+'); + try { await complete.sync(); } finally { await complete.close(); } + await rename(temporary, path); + created = false; + } finally { + if (created) await unlink(temporary).catch(() => {}); + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/activity.js b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/activity.js new file mode 100644 index 0000000..18ddb30 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/activity.js @@ -0,0 +1,75 @@ +/** + * How many tool calls a conversation keeps. + * + * The phone shows the calls of a turn under the turn they belong to, and a chat can + * hold a lot of them: one turn of the desktop's own engine used fifty-seven tools in a + * single afternoon. The limit bounds what a phone is asked to hold and what the relay + * is asked to carry in one frame. + * + * It is a limit on the calls of a conversation, not on the calls of a turn. A turn is + * kept whole — a turn that showed only some of its calls would say that the Bot used + * fewer tools than it did — so the limit is reached by dropping whole turns, and a + * turn that is larger than the limit on its own is kept whole rather than under-reported. + */ +export const MAX_ACTIVITY_CALLS = 400; +/** + * The turns of a thread, in the order the thread shows them. + * + * @param messages the messages of the thread, oldest first + */ +export function turnOrderOf(messages) { + const turns = []; + for (const message of messages) { + const turnId = message?.turnId; + if (typeof turnId === 'string' && turnId && !turns.includes(turnId)) + turns.push(turnId); + } + return turns; +} +/** + * Keeps the tool calls of a conversation within a limit, without ever splitting a turn. + * + * The bridge and the phone both keep the calls, and they have to keep them the same + * way: the phone would otherwise drop what the bridge sent, and the two would disagree + * about how many tools a turn used. The rule therefore lives here, in the contract the + * two share. + * + * Whole turns go, and the oldest go first. The newest turn is never dropped: it is the + * one the reader is looking at, and a turn that is larger than the limit on its own is + * kept whole rather than under-reported. + * + * @param list the calls, in any order + * @param limit how many calls to keep + * @param turnOrder the turns in the order they happened, oldest first + * @param keepTurn a turn that stays whatever the limit says — a turn that is still running + */ +export function trimTurnActivity(list, limit, turnOrder = [], keepTurn) { + if (list.length <= limit) + return [...list]; + const turns = []; + for (const entry of list) + if (!turns.includes(entry.turnId)) + turns.push(entry.turnId); + // The order the caller knows comes first; a turn it does not know keeps the order it + // arrived in, behind the ones it does. + const ranked = [ + ...turnOrder.filter((turnId) => turns.includes(turnId)), + ...turns.filter((turnId) => !turnOrder.includes(turnId)), + ]; + const newest = ranked[ranked.length - 1]; + const dropped = new Set(); + let kept = list.length; + for (const turnId of ranked) { + if (kept <= limit) + break; + if (turnId === newest) + break; + if (keepTurn?.(turnId)) + continue; + const mine = list.filter((entry) => entry.turnId === turnId); + dropped.add(turnId); + kept -= mine.length; + } + return list.filter((entry) => !dropped.has(entry.turnId)); +} +//# sourceMappingURL=activity.js.map \ No newline at end of file diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/auth.js b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/auth.js new file mode 100644 index 0000000..c8caec6 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/auth.js @@ -0,0 +1,120 @@ +/** + * Token signing for relay peers. + * + * The relay never holds a MiniMax account credential. It issues device-scoped tokens + * signed with a shared HMAC key, and the bridge signs its own token the same way. Both + * sides only need to prove they are who the relay paired them as. + * + * This lives in the shared protocol package rather than in the relay because the + * token format is a contract between three runtimes: the relay issues and verifies + * device tokens, the desktop presents a bridge token, and an operator has to mint + * that bridge token from the same secret. It is also deliberately free of any + * Node-only API, so it runs unchanged in the browser, in a Worker and in a script. + */ +const encoder = new TextEncoder(); +function base64UrlEncode(bytes) { + let binary = ''; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} +function base64UrlDecode(value) { + const padded = value.replace(/-/g, '+').replace(/_/g, '/'); + const base64 = padded + '='.repeat((4 - (padded.length % 4)) % 4); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i += 1) { + bytes[i] = binary.charCodeAt(i); + } + // `new Uint8Array(n)` always allocates a plain ArrayBuffer, never a SharedArrayBuffer. + return bytes.buffer; +} +async function hmacKey(secret) { + const subtle = globalThis.crypto.subtle; + return subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); +} +/** Signs a token payload. The result is `base64url(payload).base64url(signature)`. */ +export async function signToken(payload, secret) { + const key = await hmacKey(secret); + const body = base64UrlEncode(encoder.encode(JSON.stringify(payload))); + const signature = await globalThis.crypto.subtle.sign('HMAC', key, encoder.encode(body)); + return `${body}.${base64UrlEncode(new Uint8Array(signature))}`; +} +/** + * Verifies a token. + * + * Returns `null` for anything that is not a well-formed, correctly signed, unexpired + * token. Callers must treat `null` as "not authorized" and never as a partial success. + */ +export async function verifyToken(token, secret) { + if (typeof token !== 'string' || token.length === 0) + return null; + const dot = token.indexOf('.'); + if (dot <= 0 || dot === token.length - 1) + return null; + const body = token.slice(0, dot); + const signature = token.slice(dot + 1); + let valid = false; + try { + const key = await hmacKey(secret); + valid = await globalThis.crypto.subtle.verify('HMAC', key, base64UrlDecode(signature), encoder.encode(body)); + } + catch { + return null; + } + if (!valid) + return null; + let payload; + try { + payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(body))); + } + catch { + return null; + } + if (!payload || payload.v !== 1) + return null; + if (payload.role !== 'bridge' && payload.role !== 'device') + return null; + if (typeof payload.sub !== 'string' || !payload.sub) + return null; + if (typeof payload.exp !== 'number' || !Number.isFinite(payload.exp)) + return null; + if (payload.exp <= Date.now()) + return null; + // A bridge token has to name the account it belongs to. An unbound bridge + // token would be a credential for every room on the relay, so one without + // the claim is not a token at all. + if (payload.role === 'bridge' && (typeof payload.acc !== 'string' || !payload.acc)) { + return null; + } + return payload; +} +/** Builds and signs a token in one step. */ +export async function issueToken(sub, role, ttlMs, secret, options = {}) { + const now = options.now ?? Date.now(); + const expiresAt = now + ttlMs; + const payload = { + v: 1, + sub, + role, + exp: expiresAt, + jti: newTokenId(now), + iat: now, + }; + if (options.accountId) { + payload.acc = options.accountId; + } + if (options.ticket) { + payload.tick = true; + } + return { token: await signToken(payload, secret), expiresAt }; +} +function newTokenId(now) { + const cryptoObj = globalThis.crypto; + if (cryptoObj && typeof cryptoObj.randomUUID === 'function') { + return cryptoObj.randomUUID(); + } + return `${now.toString(36)}${Math.floor(Math.random() * 0xffffff).toString(16)}`; +} +//# sourceMappingURL=auth.js.map \ No newline at end of file diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/frames.js b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/frames.js new file mode 100644 index 0000000..f751943 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/frames.js @@ -0,0 +1,113 @@ +import { PROTOCOL_VERSION } from './types.js'; +/** Thrown when a frame cannot be parsed or violates the contract. */ +export class FrameError extends Error { + code; + constructor(code, message) { + super(message); + this.name = 'FrameError'; + this.code = code; + } +} +let counter = 0; +/** + * Frame id generator. + * + * Uses `crypto.randomUUID` where available (Node, browsers, Workers) and falls back to a + * timestamp + counter + random suffix so the relay and the app never collide. + */ +export function newId(prefix = 'f') { + const cryptoObj = globalThis.crypto; + if (cryptoObj && typeof cryptoObj.randomUUID === 'function') { + return `${prefix}_${cryptoObj.randomUUID()}`; + } + counter = (counter + 1) % 0xffff; + const rand = Math.floor(Math.random() * 0xffffff).toString(16).padStart(6, '0'); + return `${prefix}_${Date.now().toString(36)}${counter.toString(36)}${rand}`; +} +/** Builds a well-formed frame. */ +export function createFrame(options) { + const frame = { + v: PROTOCOL_VERSION, + id: options.id ?? newId(), + ts: options.ts ?? Date.now(), + type: options.type, + source: options.source, + payload: options.payload ?? {}, + }; + if (options.in_reply_to !== undefined) + frame.in_reply_to = options.in_reply_to; + if (options.opId !== undefined) + frame.opId = options.opId; + if (options.seq !== undefined) + frame.seq = options.seq; + return frame; +} +/** Serialises a frame for the wire. */ +export function encodeFrame(frame) { + return JSON.stringify(frame); +} +function isRecord(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} +/** + * Parses and validates a frame. + * + * Validation is deliberately shallow: the contract is shared across three runtimes and a + * strict deep validator would reject legitimate forward-compatible payloads. What is + * enforced is everything the relay relies on to route and order frames. + */ +export function decodeFrame(raw) { + let parsed; + try { + parsed = JSON.parse(raw); + } + catch { + throw new FrameError('MALFORMED_JSON', 'frame is not valid JSON'); + } + return assertFrame(parsed); +} +export function assertFrame(value) { + if (!isRecord(value)) { + throw new FrameError('NOT_AN_OBJECT', 'frame must be an object'); + } + if (value.v !== PROTOCOL_VERSION) { + throw new FrameError('UNSUPPORTED_VERSION', `unsupported protocol version: ${String(value.v)}`); + } + if (typeof value.id !== 'string' || value.id.length === 0) { + throw new FrameError('MISSING_ID', 'frame.id must be a non-empty string'); + } + if (typeof value.type !== 'string' || !value.type) { + throw new FrameError('MISSING_TYPE', 'frame.type must be a non-empty string'); + } + if (value.source !== 'app' && value.source !== 'bridge' && value.source !== 'relay') { + throw new FrameError('BAD_SOURCE', `frame.source must be app|bridge|relay, got ${String(value.source)}`); + } + if (typeof value.ts !== 'number' || !Number.isFinite(value.ts)) { + throw new FrameError('MISSING_TS', 'frame.ts must be a finite number'); + } + if (value.payload === undefined) { + throw new FrameError('MISSING_PAYLOAD', 'frame.payload is required'); + } + if (value.seq !== undefined && (typeof value.seq !== 'number' || !Number.isInteger(value.seq))) { + throw new FrameError('BAD_SEQ', 'frame.seq must be an integer when present'); + } + if (value.from !== undefined && typeof value.from !== 'string') { + throw new FrameError('BAD_FROM', 'frame.from must be a string when present'); + } + if (value.in_reply_to !== undefined && typeof value.in_reply_to !== 'string') { + throw new FrameError('BAD_IN_REPLY_TO', 'frame.in_reply_to must be a string when present'); + } + if (value.opId !== undefined && typeof value.opId !== 'string') { + throw new FrameError('BAD_OP_ID', 'frame.opId must be a string when present'); + } + return value; +} +/** True when the frame is an operation (a request that expects an ack or an err). */ +export function isRequest(frame) { + return frame.type.startsWith('op.'); +} +/** True when the frame is an event (a notification that expects no reply). */ +export function isEvent(frame) { + return frame.type.startsWith('evt.'); +} +//# sourceMappingURL=frames.js.map \ No newline at end of file diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/index.js b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/index.js new file mode 100644 index 0000000..9aa766b --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/index.js @@ -0,0 +1,6 @@ +export * from './types.js'; +export * from './frames.js'; +export * from './pairing.js'; +export * from './auth.js'; +export * from './activity.js'; +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/pairing.js b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/pairing.js new file mode 100644 index 0000000..8a8225c --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/pairing.js @@ -0,0 +1,87 @@ +/** + * Pairing and credential contract shared by the bridge, the relay and the app. + * + * The relay is a dumb pipe, so it never holds a MiniMax account credential. Pairing is a + * one-time code exchange: the bridge asks the relay for a short code, the phone types it, + * and the relay hands the phone a device-scoped token it can revoke from the desktop. + */ +export const PAIRING_CODE_LENGTH = 6; +/** + * How long a pairing code stays usable. + * + * Ninety seconds was long enough for the owner to read a code off their own + * screen and type it, and far too short for anybody else. A code that arrives + * in a chat message has to survive the time it takes to open the app and find + * the field, and a code that dies before it is typed is the same as no code at + * all — the person is left with a client that refuses them. + * + * Five minutes is an ordinary pairing window. The code is still single use and + * still rate limited, so the longer window does not give an attacker a second + * chance with the same code: it only gives the legitimate owner time to reach + * the keyboard. + */ +export const PAIRING_CODE_TTL_MS = 5 * 60_000; +/** The shortest window a desktop may ask for. */ +export const MIN_PAIRING_CODE_TTL_MS = 60_000; +/** + * The longest window a desktop may ask for. + * + * Long enough for a code to survive a slow reader and a detour to the right + * app, short enough that a code left on a screen does not stay useful for the + * rest of the day. + */ +export const MAX_PAIRING_CODE_TTL_MS = 15 * 60_000; +/** + * The window a code actually gets. + * + * A desktop may ask for a longer window — the relay is the only place that can + * enforce one — but it cannot ask for a window the relay did not agree to, and + * a request that is not a number at all falls back to the default rather than + * to an unbounded one. + */ +export function resolvePairingCodeTtlMs(requested) { + if (typeof requested !== 'number' || !Number.isFinite(requested)) { + return PAIRING_CODE_TTL_MS; + } + const whole = Math.trunc(requested); + return Math.min(MAX_PAIRING_CODE_TTL_MS, Math.max(MIN_PAIRING_CODE_TTL_MS, whole)); +} +export const DEVICE_TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; +/** Only digits, so the code is easy to read off a phone screen and type. */ +const PAIRING_CODE_PATTERN = new RegExp(`^\\d{${PAIRING_CODE_LENGTH}}$`); +export function isValidPairingCode(code) { + return PAIRING_CODE_PATTERN.test(code); +} +/** Generates a numeric pairing code with a uniform distribution. */ +export function generatePairingCode(random = Math.random) { + let code = ''; + for (let i = 0; i < PAIRING_CODE_LENGTH; i += 1) { + code += String(Math.floor(random() * 10)); + } + return code; +} +export function describeRelayError(code) { + switch (code) { + case 'UNAUTHORIZED': + return 'This device is not paired with this bridge.'; + case 'PAIRING_CODE_INVALID': + return 'That pairing code is not valid.'; + case 'PAIRING_CODE_EXPIRED': + return 'That pairing code expired. Ask the desktop for a new one.'; + case 'PAIRING_CODE_UNKNOWN': + return 'No bridge is waiting for that pairing code.'; + case 'DEVICE_REVOKED': + return 'This device was revoked from the bridge.'; + case 'BRIDGE_OFFLINE': + return 'The desktop is offline. Start MiniMax Code and try again.'; + case 'RATE_LIMITED': + return 'Too many attempts. Wait a moment and try again.'; + case 'ACCOUNT_OWNED': + return 'This account already belongs to another desktop.'; + case 'BAD_REQUEST': + return 'The request could not be understood.'; + default: + return 'The relay refused the request.'; + } +} +//# sourceMappingURL=pairing.js.map \ No newline at end of file diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/types.js b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/types.js new file mode 100644 index 0000000..5b4fbd5 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/protocol/types.js @@ -0,0 +1,185 @@ +/** + * Wire contract for the MiniMax Bot mobile client. + * + * Three roles sit on this protocol: + * - `app` — the Expo client (phone / tablet / web). + * - `bridge` — the MiniMax Code Mini App plugin that owns the team. + * - `relay` — the Cloudflare Worker + Durable Object that pipes frames between them. + * + * The relay never interprets a payload. It stamps a monotonic `seq` on every event it + * forwards from the bridge and replays from a ring buffer when a client resumes, so the + * phone is always a projection of bridge-owned truth. + */ +export const PROTOCOL_VERSION = 1; +/** + * The WebSocket close codes the relay uses to say why a peer was refused. + * + * The relay accepts the upgrade first and closes the socket with one of these, so + * the peer learns *why* it was refused. Answering the handshake with an HTTP error + * instead would hide the reason behind a generic "connection failed" — a phone that + * had been unpaired cannot tell that apart from a relay that is simply down. + */ +export const CLOSE_CODE = { + /** The credential was not accepted. */ + unauthorized: 4001, + /** Another peer took this peer's place. */ + replaced: 4000, + /** The device was unpaired on the desktop. */ + deviceRevoked: 4003, +}; +export const OP_TYPES = [ + 'op.list_team', + 'op.bot_avatar', + 'op.send_message', + 'op.abort_turn', + 'op.dispatch', + 'op.list_tasks', + 'op.cancel_task', + 'op.list_helpers', + 'op.start_helper', + 'op.stop_helper', + 'op.recall', + 'op.remember', + 'op.forget', + 'op.create_bot', + 'op.update_bot', + 'op.delete_bot', + 'op.set_bot_mode', + 'op.create_group', + 'op.update_group', + 'op.delete_group', + 'op.list_schedules', + 'op.create_schedule', + 'op.delete_schedule', + 'op.get_usage', + 'op.respond_permission', + 'op.answer_question', + 'op.react', + 'op.list_history', + 'op.list_models', + 'op.list_capabilities', +]; +export const EVENT_TYPES = [ + 'evt.team_snapshot', + 'evt.turn_started', + 'evt.message_chunk', + 'evt.message_committed', + 'evt.tool_started', + 'evt.tool_finished', + 'evt.turn_finished', + 'evt.permission_requested', + 'evt.permission_resolved', + 'evt.question_requested', + 'evt.question_answered', + 'evt.task_updated', + 'evt.helper_updated', + 'evt.schedule_updated', + 'evt.bridge_status', + 'evt.reaction_added', +]; +export function isOpType(type) { + return OP_TYPES.includes(type); +} +export function isEventType(type) { + return EVENT_TYPES.includes(type); +} +/** + * Checks a client-chosen message id. + * + * The bridge accepts the id and stores it as the committed message id, so it is + * restricted to a short, unambiguous set of characters and a bounded length. A client + * that sends anything else simply gets an id chosen for it. + */ +export function isValidClientMessageId(value) { + return typeof value === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(value); +} +/** + * Maps an operation to the team tool that implements it. + * + * The bridge prefers this table to reach a tool, and falls back to a name match on + * `mcp__minimax-bot__`. Keeping it here means the client, the relay and the bridge + * agree on which tool backs which operation. + */ +export const OP_TOOL = { + 'op.list_team': 'list_team', + 'op.dispatch': 'dispatch_tasks', + 'op.list_tasks': 'get_tasks', + 'op.cancel_task': 'cancel_tasks', + 'op.list_helpers': 'list_helpers', + 'op.start_helper': 'start_helper', + 'op.stop_helper': 'stop_helper', + 'op.recall': 'recall', + 'op.remember': 'remember', + 'op.forget': 'forget', + 'op.create_bot': 'create_bot', + 'op.update_bot': 'update_bot', + 'op.delete_bot': 'delete_bot', + 'op.create_group': 'create_group', + 'op.update_group': 'update_group', + 'op.delete_group': 'delete_group', + 'op.list_schedules': 'list_schedules', + 'op.create_schedule': 'create_schedule', + 'op.delete_schedule': 'delete_schedule', +}; +/** + * Operations the bridge routes to the team by the chat the turn belongs to. + * + * A turn is addressed to a Bot or to a group, and the chat id says which. The bridge + * hands the turn to the team itself, so the Bot that answers is the Bot the phone + * picked — with that Bot's own instructions, model, effort and session. + */ +export const TEAM_ROUTED_OPS = ['op.send_message', 'op.abort_turn']; +/** + * Operations the bridge answers from its own agent rather than from a team tool. + * + * These are the Host's own questions — a tool call waiting for a decision, a + * questionnaire waiting for an answer — which only the agent that started the turn can + * answer. + */ +export const AGENT_BACKED_OPS = ['op.respond_permission', 'op.answer_question']; +/** + * Operations the bridge answers itself, without a team tool and without the agent. + * + * Together with {@link OP_TOOL}, {@link TEAM_ROUTED_OPS} and {@link AGENT_BACKED_OPS} + * this accounts for every operation, so a consumer can ask "how is this op served?" and + * get an answer. + */ +export const BRIDGE_LOCAL_OPS = [ + 'op.get_usage', + 'op.react', + 'op.list_history', + 'op.bot_avatar', + 'op.set_bot_mode', + 'op.list_models', + 'op.list_capabilities', +]; +/** + * The team tools that run a turn. + * + * `message_bot` hands work to a Bot and runs it in that Bot's own chat; `post_group` + * posts to a group and runs the group's discussion. The bridge calls them itself, on + * the phone's behalf, so the Bot that answers is the Bot the phone picked. + */ +export const TURN_ROUTING_TOOLS = ['message_bot', 'post_group']; +/** + * How the chat a turn belongs to decides which team tool runs it. + * + * `bot:` is a Bot's own private chat and `group:` is a group. The bridge reads the + * prefix of the chat id and calls the matching tool, so the client, the relay and the + * bridge agree on where a turn goes. + */ +export const CHAT_ROUTING = { + 'bot:': 'message_bot', + 'group:': 'post_group', +}; +const _everyOpIsClassified = true; +void _everyOpIsClassified; +/** + * The complete `@minimax-bot` team tool surface: 21 tools. + * + * Every tool the MCP server exposes is either client-invokable ({@link OP_TOOL}) or + * runs a turn ({@link TURN_ROUTING_TOOLS}). A tool that appears in neither would be + * unreachable, and one appearing in both would be ambiguous. + */ +export const TEAM_TOOLS = [...Object.values(OP_TOOL), ...TURN_ROUTING_TOOLS]; +//# sourceMappingURL=types.js.map \ No newline at end of file diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/relay-client.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/relay-client.mjs new file mode 100644 index 0000000..89279d6 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/relay-client.mjs @@ -0,0 +1,496 @@ +/** + * Outbound WebSocket link to the relay. + * + * The bridge dials out, exactly like MiniMax's own Remote Control producer: a Mini App + * can only listen on loopback, so the desktop must be the one to open the connection. + * Reconnection uses the same escalating backoff, and every attempt presents the same + * credential because the relay's bridge token is long-lived. + */ + +const BACKOFF_MS = [1000, 2000, 4000, 8000, 16000, 30000]; +const MAX_QUEUE = 200; + +/** + * The relay address, in the form each transport needs. + * + * The pairing request is an HTTP POST and the frame channel is a WebSocket, but the + * operator is given a single address and the scheme they type must not decide which + * one works: a `wss://` address cannot be fetched — `fetch` refuses a scheme that is + * not HTTP — and an `http://` address is not a WebSocket URL. The two are translated + * into each other instead. + * + * The translations are string operations on purpose: the same rule has to hold in a + * browser, where `URL` is not a real URL parser. + */ + +const TO_HTTP = { + 'http:': 'http:', + 'https:': 'https:', + 'ws:': 'http:', + 'wss:': 'https:', +}; + +const TO_WS = { + 'http:': 'ws:', + 'https:': 'wss:', + 'ws:': 'ws:', + 'wss:': 'wss:', +}; + +/** + * @param {string} url + * @param {Record} schemes + * @returns {string} + */ +function translate(url, schemes) { + const trimmed = String(url).trim().replace(/\/+$/, ''); + const match = /^([a-z][a-z0-9+.-]*:)/i.exec(trimmed); + if (!match) return trimmed; + const scheme = match[1].toLowerCase(); + const target = schemes[scheme]; + if (!target) return trimmed; + return target + trimmed.slice(scheme.length); +} + +/** + * @typedef {object} RelayLogger + * @property {(message: string, meta?: unknown) => void} info + * @property {(message: string, meta?: unknown) => void} warn + * @property {(message: string, meta?: unknown) => void} error + * @property {(message: string, meta?: unknown) => void} debug + */ + +/** + * @typedef {object} RelayClientOptions + * @property {string} [url] Public base URL of the relay. + * @property {string} [accountId] Room the bridge and its phones share. + * @property {string} [bridgeId] Stable id of this desktop. + * @property {string} [token] HMAC token the relay accepts for this bridge. + * @property {string} [name] Display name shown on the phone. + * @property {RelayLogger} [logger] + * @property {(frame: any) => void} [onFrame] + * @property {(connected: boolean) => void} [onStatus] + */ + +export class RelayClient { + /** + * @param {RelayClientOptions} [options] + */ + constructor({ url, accountId, bridgeId, token, name, logger, onFrame, onStatus } = {}) { + this.url = url; + this.accountId = accountId; + this.bridgeId = bridgeId; + this.token = token; + this.name = name ?? 'Desktop'; + this.logger = logger ?? { info() {}, warn() {}, error() {}, debug() {} }; + this.onFrame = onFrame ?? (() => {}); + this.onStatus = onStatus ?? (() => {}); + this.socket = null; + this.closed = false; + this.attempt = 0; + /** + * Whether a ticket was refused and the token was used instead. + * + * A relay whose room predates the ticket does not know what one is, so the + * connection is tried once more with the token. + */ + this.usedTokenFallback = false; + this.queue = []; + this.timer = null; + // Lifecycle identity, not a comparison of settings: A -> B -> A is still new work. + this.epoch = 0; + } + + get connected() { + return this.socket !== null && this.socket.readyState === 1; + } + + get configured() { + return Boolean(this.url && this.accountId && this.bridgeId && this.token); + } + + /** The relay address as an HTTP origin, for the REST calls. */ + httpUrl() { + return translate(this.url ?? '', TO_HTTP); + } + + /** The relay address as a WebSocket origin, for the frame channel. */ + wsUrl() { + return translate(this.url ?? '', TO_WS); + } + + endpoint(ticket) { + const base = this.wsUrl(); + const params = new URLSearchParams({ + role: 'bridge', + subject: this.bridgeId, + name: this.name, + }); + // A ticket stands in for the token for one connection. Without one the token + // rides in the URL, which is the part of a request that proxies and log + // pipelines keep. + if (ticket) params.set('ticket', ticket); + else params.set('token', this.token); + return `${base}/r/${encodeURIComponent(this.accountId)}/ws?${params.toString()}`; + } + + /** + * Asks the relay for a ticket for the next connection. + * + * The request carries the token in a header, which is the one place a + * credential can be kept out of a URL. A relay that does not offer tickets + * leaves the token where it was. + * + * @returns {Promise} + */ + async fetchTicket() { + if (!this.configured) return null; + const base = this.httpUrl(); + try { + const response = await fetch(`${base}/r/${encodeURIComponent(this.accountId)}/ws-ticket`, { + method: 'POST', + headers: { authorization: `Bearer ${this.token}` }, + }); + if (!response.ok) return null; + const body = /** @type {{ticket?: string}} */ (await response.json()); + return typeof body?.ticket === 'string' ? body.ticket : null; + } catch { + return null; + } + } + + start() { + this.resetConnection(); + this.closed = false; + if (!this.configured) { + this.logger.warn('relay is not configured yet'); + return; + } + this.connect(); + } + + /** Invalidates pending HTTP work, socket events and retry callbacks together. */ + resetConnection() { + this.epoch += 1; + if (this.timer) { + clearTimeout(this.timer); + this.timer = null; + } + this.closeSocket(); + } + + /** + * Replaces the connection settings and connects again. + * + * The socket that was open against the old address is closed on purpose. Its close + * event belongs to a socket this client has already let go of, and the guard in the + * close handler makes it a no-op, so it cannot clear the socket that replaced it. + * + * @param {{url: string, accountId: string, bridgeId: string, token: string, name: string}} settings + */ + reconfigure(settings) { + // A token refresh keeps offline frames; a different destination/subject does not. + if (this.identity() !== this.identity(settings)) this.queue = []; + this.url = settings.url; + this.accountId = settings.accountId; + this.bridgeId = settings.bridgeId; + this.token = settings.token; + this.name = settings.name ?? this.name; + // The new relay may be a different relay, and it may well know what a ticket is. + this.usedTokenFallback = false; + this.attempt = 0; + this.start(); + } + + /** @param {{url?: string, accountId?: string, bridgeId?: string}} [settings] */ + identity(settings = this) { + let base = translate(settings.url ?? '', TO_HTTP); + try { base = new URL(base).href.replace(/\/+$/, ''); } catch { /* unconfigured */ } + return JSON.stringify([base, settings.accountId, settings.bridgeId]); + } + + /** + * Closes the socket that is open, if there is one. + * + * The reference is taken before the socket is closed, so the close event that + * follows finds a client that has already let this socket go. + */ + closeSocket() { + const socket = this.socket; + this.socket = null; + if (!socket) return; + try { + socket.close(); + } catch { + /* already gone */ + } + } + + stop() { + this.closed = true; + this.resetConnection(); + this.onStatus(false); + } + + connect() { + if (this.closed) return; + const WebSocketImpl = globalThis.WebSocket; + if (!WebSocketImpl) { + this.logger.error('this runtime has no WebSocket client'); + return; + } + // The socket is opened with a ticket when the relay offers one, so the + // long-lived token does not travel in the URL. + void this.open(WebSocketImpl); + } + + /** + * Opens the socket, with a ticket for the connection when there is one. + * + * @param {typeof WebSocket} WebSocketImpl + */ + async open(WebSocketImpl) { + const epoch = this.epoch; + const ticket = await this.fetchTicket(); + if (this.closed || epoch !== this.epoch) return; + this.openSocket(WebSocketImpl, ticket, epoch); + } + + /** + * Opens the socket, with a ticket for the connection when there is one. + * + * @param {typeof WebSocket} WebSocketImpl + * @param {string | null} ticket + * @param {number} [epoch] + */ + openSocket(WebSocketImpl, ticket, epoch = this.epoch) { + if (this.closed || epoch !== this.epoch) return; + let socket; + try { + socket = new WebSocketImpl(this.endpoint(ticket)); + } catch (error) { + // WebSocket errors can include their URL, which may carry a token or ticket. + this.logger.error('relay connection failed'); + this.scheduleReconnect(); + return; + } + + this.socket = socket; + let opened = false; + const current = () => !this.closed && epoch === this.epoch && this.socket === socket; + + socket.addEventListener('open', () => { + if (!current()) return; + opened = true; + this.attempt = 0; + this.onStatus(true); + if (current()) this.flush(); + }); + + socket.addEventListener('message', (event) => { + if (!current()) return; + const raw = typeof event.data === 'string' ? event.data : String(event.data); + try { + this.onFrame(JSON.parse(raw)); + } catch { + this.logger.warn('relay sent a frame that is not valid JSON'); + } + }); + + socket.addEventListener('close', () => { + // A close event belongs to the socket it came from. If this client has already + // let that socket go - because the relay configuration changed and the socket + // was closed on purpose - the event is about a connection that is no longer + // this client's, and it must not clear the socket that replaced it. + if (!current()) return; + this.socket = null; + this.onStatus(false); + if (this.closed || epoch !== this.epoch) return; + // A connection that was refused before it opened, and that carried a + // ticket, is tried once more with the token: a relay whose room predates + // the ticket does not know what one is. + if (!opened && ticket && !this.usedTokenFallback) { + this.usedTokenFallback = true; + this.logger.info('the relay refused the ticket; trying the token'); + this.openSocket(WebSocketImpl, null, epoch); + return; + } + this.scheduleReconnect(); + }); + + socket.addEventListener('error', () => { + // `close` always follows, so the reconnect is scheduled there. + }); + } + + scheduleReconnect() { + if (this.closed || this.timer) return; + const base = BACKOFF_MS[Math.min(this.attempt, BACKOFF_MS.length - 1)]; + const jitter = Math.floor(Math.random() * 400); + this.attempt += 1; + const delay = base + jitter; + this.logger.info(`reconnecting to the relay in ${delay} ms`); + const epoch = this.epoch; + const timer = setTimeout(() => { + if (this.closed || epoch !== this.epoch || this.timer !== timer) return; + this.timer = null; + this.connect(); + }, delay); + this.timer = timer; + if (typeof this.timer?.unref === 'function') this.timer.unref(); + } + + /** Sends a frame. Frames queued while offline are flushed on reconnect. */ + send(frame) { + const raw = JSON.stringify(frame); + if (this.connected) { + try { + this.socket.send(raw); + return true; + } catch { + /* fall through to the queue */ + } + } + if (this.queue.length >= MAX_QUEUE) { + this.queue.shift(); + } + this.queue.push(raw); + return false; + } + + flush() { + const pending = this.queue; + this.queue = []; + for (const raw of pending) { + try { + this.socket?.send(raw); + } catch { + this.queue.push(raw); + } + } + } + + /** + * Asks the relay for a pairing code. + * + * The code is shown on the desktop and typed into the phone. It is single use and + * short-lived, so it is never persisted by the bridge. + * + * @returns {Promise<{ code: string, expiresAt: number }>} + */ + async createPairingCode() { + if (!this.configured) { + throw new Error('The relay is not configured yet.'); + } + const base = this.httpUrl(); + const response = await fetch(`${base}/r/${encodeURIComponent(this.accountId)}/pair/code`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: `Bearer ${this.token}`, + }, + body: JSON.stringify({ bridgeId: this.bridgeId, bridgeName: this.name }), + }); + if (!response.ok) { + throw new Error(`The relay refused the pairing request (${response.status}).`); + } + const body = /** @type {{ code: string, expiresAt: number }} */ (await response.json()); + return body; + } + + /** Lists the devices paired with this bridge. */ + async listDevices() { + if (!this.configured) return []; + const base = this.httpUrl(); + const response = await fetch(`${base}/r/${encodeURIComponent(this.accountId)}/devices`, { + headers: { authorization: `Bearer ${this.token}` }, + }); + if (!response.ok) return []; + const body = /** @type {{devices?: unknown}} */ (await response.json()); + return Array.isArray(body.devices) ? body.devices : []; + } + + /** Revokes a paired device. */ + async revokeDevice(deviceId) { + if (!this.configured) return false; + const base = this.httpUrl(); + const response = await fetch( + `${base}/r/${encodeURIComponent(this.accountId)}/devices/revoke`, + { + method: 'POST', + headers: { authorization: `Bearer ${this.token}`, 'content-type': 'application/json' }, + body: JSON.stringify({ deviceId }), + }, + ); + return response.ok; + } + + /** + * Asks the relay for a fresh token. + * + * The relay signs it — it holds the shared secret — and every older bridge token + * for this account stops working at the same moment. That is what makes a leaked + * token revocable: it is not enough to change the token, the old one has to die. + * + * @param {{commit?: boolean}} [options] Bridge defers installation until persistence commits. + * @returns {Promise} the new token + */ + async rotateToken({ commit = true } = {}) { + if (!this.configured) throw new Error('the relay is not configured'); + const epoch = this.epoch; + const token = this.token; + const assertCurrent = () => { + if (epoch !== this.epoch || token !== this.token) { + throw new Error('The relay configuration changed during token rotation.'); + } + }; + const base = this.httpUrl(); + let response; + try { + response = await fetch( + `${base}/r/${encodeURIComponent(this.accountId)}/bridge/token`, + { method: 'POST', headers: { authorization: `Bearer ${token}` } }, + ); + } catch { + assertCurrent(); + throw new Error('The relay token rotation request failed.'); + } + assertCurrent(); + if (!response.ok) { + throw new Error(`The relay refused the token rotation (${response.status}).`); + } + let body; + try { + body = /** @type {{token?: string}} */ (await response.json()); + } catch { + assertCurrent(); + // JSON parser errors may quote the response, including its credential. + throw new Error('The relay did not return a valid token.'); + } + assertCurrent(); + if (typeof body?.token !== 'string' || !body.token) throw new Error('The relay did not return a token.'); + if (commit) this.token = body.token; + return body.token; + } + + /** + * When the current token expires, epoch ms — or null when that cannot be read. + * + * The token is signed, not encrypted: its payload is readable, so the bridge can + * tell that the token is about to run out without asking anyone. + * + * @returns {number | null} + */ + tokenExpiresAt() { + if (!this.token) return null; + const [body] = this.token.split('.'); + if (!body) return null; + try { + const padded = body.replace(/-/g, '+').replace(/_/g, '/'); + const payload = JSON.parse(Buffer.from(padded, 'base64').toString('utf8')); + return typeof payload.exp === 'number' ? payload.exp : null; + } catch { + return null; + } + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/server.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/server.mjs new file mode 100644 index 0000000..694345f --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/server.mjs @@ -0,0 +1,355 @@ +/** + * Mini App entry point. + * + * `start(context)` is the contract the Host requires. The bridge listens on the address + * the Host assigns (always loopback) and serves the small `/api` surface the desktop + * surface uses to show a pairing code and manage paired phones. + */ + +import { createServer } from 'node:http'; +import { randomBytes } from 'node:crypto'; + +import { Bridge } from './bridge.mjs'; +import { checkCaller } from './caller.mjs'; +import { isAllowedRelayUrl, loadConfig, relayOrigin } from './config.mjs'; + +const MAX_BODY = 256 * 1024; + +export async function start(context) { + const logger = context.logger; + const config = await loadConfig(context.dataDir); + + const bridge = new Bridge(context, { config, logger }); + + /** + * The secret that proves a mutating request came from the desktop surface. + * + * It is handed out by `GET /api/session`, which only answers a caller whose own + * headers say it is the surface, and it is regenerated every time the bridge + * starts. + */ + const secret = randomBytes(32).toString('hex'); + + /** + * The address the Host assigned to this bridge. + * + * The desktop surface is served from this address, so it is also the only origin + * a legitimate request can come from. + */ + const surface = { host: context.listen.host, port: context.listen.port }; + + /** + * Writes the configuration and hands it to the live bridge. + * + * @param {import('./config.mjs').BridgeConfig} next + * @param {(current: import('./config.mjs').BridgeConfig) => import('./config.mjs').BridgeConfig} [update] + */ + const setConfig = (next, update = () => next) => + bridge.commitConfig(update, { reconfigure: true }); + + const server = createServer((request, response) => { + // The bridge's own configuration is the live one: a rotated token changes it, + // and the surface must not answer with a token the relay has already revoked. + handle(request, response, { + bridge, + context, + secret, + surface, + getConfig: () => bridge.config, + setConfig, + }).catch((error) => { + logger.error(`request failed: ${error instanceof Error ? error.message : error}`); + json(response, 500, { error: 'internal' }); + }); + }); + + await bridge.start(); + + /** @type {Promise} */ + const listening = new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(context.listen.port, context.listen.host, () => { + server.off('error', reject); + resolve(); + }); + }); + await listening; + + logger.info('bridge listening', { host: context.listen.host, port: context.listen.port }); + + const onAbort = () => { + bridge.stop(); + server.close(); + }; + context.signal.addEventListener('abort', onAbort, { once: true }); + + return { + dispose() { + context.signal.removeEventListener('abort', onAbort); + onAbort(); + }, + }; +} + +/** + * Handles one request. + * + * Exported so the surface can be tested without a socket: the routes that matter + * here are the ones that decide whether a request may change the bridge's + * configuration. + * + * @param {import('node:http').IncomingMessage} request + * @param {import('node:http').ServerResponse} response + * @param {any} deps + */ +export async function handle(request, response, deps) { + const url = new URL(request.url, 'http://127.0.0.1'); + const path = url.pathname; + + // The surface binds loopback, so every process on the machine — and every page + // in the user's browser — can reach the port. The caller's own headers say + // whether this is the desktop surface, and a request that fails to say so is + // not answered at all: not with the account details, and not with the secret. + const caller = checkCaller(request, deps.surface); + if (caller.ok === false) { + return json(response, 403, { error: 'forbidden', message: caller.message }); + } + + // Everything that can change state is restricted to the desktop surface. + // + // `POST /api/config` in particular can point the bridge at another relay, which + // would hand that relay the bridge token and every frame that follows — so a + // request from a page the user did not open must never be honoured. + if (request.method !== 'GET' && !isTrustedRequest(request, deps)) { + return json(response, 403, { error: 'forbidden', message: 'This request did not come from the bridge surface.' }); + } + + if (request.method === 'GET' && path === '/api/health') { + return json(response, 200, { ok: true }); + } + + if (request.method === 'GET' && path === '/api/session') { + // The secret is the last thing that goes wrong: a page that cannot be shown to + // be the surface never gets this far, and the surface is the only thing that + // can read the answer. + return json(response, 200, { secret: deps.secret }); + } + + if (request.method === 'GET' && path === '/api/status') { + // The account id and the relay address are what an attacker aims with, so they + // are not handed to a caller that has not shown it holds the surface secret. + if (!hasSecret(request, deps)) { + return json(response, 403, { error: 'forbidden', message: 'This request did not come from the bridge surface.' }); + } + const { bridge, getConfig } = deps; + const config = getConfig(); + return json(response, 200, { + bridgeId: config.bridgeId, + bridgeName: config.bridgeName, + relayConfigured: bridge.relay.configured, + relayConnected: bridge.relay.connected, + relayUrl: config.relayUrl, + accountId: config.accountId, + // The surface is behind the surface secret and is read at the desktop, so it is + // where the detail behind a failure belongs. The room gets a fixed sentence. + diagnostics: bridge.desktopDiagnostics(), + pending: bridge.pending.list().length, + }); + } + + if (request.method === 'POST' && path === '/api/pair/code') { + const { bridge } = deps; + try { + const result = await bridge.relay.createPairingCode(); + return json(response, 200, result); + } catch (error) { + return json(response, 400, { + error: 'PAIRING_UNAVAILABLE', + message: error instanceof Error ? error.message : String(error), + }); + } + } + + if (request.method === 'POST' && path === '/api/rotate-token') { + const { bridge } = deps; + try { + await bridge.rotateToken(); + return json(response, 200, { ok: true }); + } catch (error) { + return json(response, 502, { + error: 'rotation_failed', + message: + error instanceof Error ? error.message : 'The relay refused the token rotation.', + }); + } + } + + if (request.method === 'GET' && path === '/api/devices') { + if (!hasSecret(request, deps)) { + return json(response, 403, { error: 'forbidden', message: 'This request did not come from the bridge surface.' }); + } + const { bridge } = deps; + return json(response, 200, { devices: await bridge.relay.listDevices() }); + } + + if (request.method === 'DELETE' && path.startsWith('/api/devices/')) { + const { bridge } = deps; + let deviceId; + try { + deviceId = decodeURIComponent(path.slice('/api/devices/'.length)); + } catch { + return json(response, 400, { error: 'bad_request', message: 'Malformed device id.' }); + } + const revoked = await bridge.revokeDevice(deviceId); + return json(response, revoked ? 200 : 404, { revoked }); + } + + if (request.method === 'GET' && path === '/api/snapshot') { + if (!hasSecret(request, deps)) { + return json(response, 403, { error: 'forbidden', message: 'This request did not come from the bridge surface.' }); + } + const { bridge } = deps; + const team = await bridge.readTeam(); + return json(response, 200, { + ...team, + diagnostics: bridge.desktopDiagnostics(), + tools: bridge.tools.catalog, + }); + } + + if (request.method === 'POST' && path === '/api/config') { + const body = await readJson(request); + if (body === null) { + return json(response, 413, { error: 'too_large', message: 'The request body is too large.' }); + } + const { getConfig, setConfig } = deps; + const observed = getConfig(); + const update = (current) => configurationUpdate(current, body, observed); + try { + // The snapshot permits simple route consumers to accept a complete config; + // the live server also receives the updater to revalidate/derive inside its + // shared commit queue, after any earlier rotation or surface write. + const next = update(getConfig()); + const committed = await setConfig(next, update); + return json(response, 200, { ok: true, config: { ...(committed ?? next), bridgeToken: undefined } }); + } catch (error) { + if (error?.code !== 'INVALID_CONFIG') throw error; + return json(response, 400, { error: 'bad_request', message: error.message }); + } + } + + return json(response, 404, { error: 'not_found' }); +} + +/** + * Apply only supplied settings to the latest configuration. A target change may + * never inherit a credential for the previous relay/account. + * + * @param {import('./config.mjs').BridgeConfig} current + * @param {Record} body + * @returns {import('./config.mjs').BridgeConfig} + */ +function configurationUpdate(current, body, observed = current) { + const nonempty = (value, fallback) => typeof value === 'string' && value.length > 0 ? value : fallback; + const relayUrl = nonempty(body.relayUrl, current.relayUrl); + if (!isAllowedRelayUrl(relayUrl)) { + throw Object.assign(new Error('That relay address is not one the bridge may use.'), { code: 'INVALID_CONFIG' }); + } + const accountId = nonempty(body.accountId, current.accountId); + const supplied = nonempty(body.bridgeToken, null); + // A surface form may include the unchanged token it observed before waiting + // behind a rotation. It is not a request to restore that now-revoked token. + const unchangedCredential = supplied === observed.bridgeToken && + relayOrigin(nonempty(body.relayUrl, observed.relayUrl)) === relayOrigin(observed.relayUrl) && + nonempty(body.accountId, observed.accountId) === observed.accountId && + nonempty(body.bridgeId, observed.bridgeId) === observed.bridgeId; + const suppliedToken = unchangedCredential ? null : supplied; + const targetChanged = relayOrigin(relayUrl) !== relayOrigin(current.relayUrl) || accountId !== current.accountId; + if (targetChanged && !suppliedToken) { + throw Object.assign(new Error('A different relay or account needs a token minted for it.'), { code: 'INVALID_CONFIG' }); + } + return { + ...current, + relayUrl, + accountId, + bridgeName: typeof body.bridgeName === 'string' ? body.bridgeName : current.bridgeName, + // The token's subject is pinnable so the operator can paste a token minted for it. + bridgeId: nonempty(body.bridgeId, current.bridgeId), + bridgeToken: suppliedToken ?? current.bridgeToken, + }; +} + +/** + * Whether the caller holds the bridge's per-process secret. + * + * @param {import('node:http').IncomingMessage} request + * @param {any} deps + * @returns {boolean} + */ +function hasSecret(request, deps) { + const presented = request.headers['x-bridge-secret']; + return typeof presented === 'string' && presented === deps.secret; +} + +/** + * Whether a mutating request came from the bridge's own desktop surface. + * + * The bridge binds loopback, so a page in the user's browser can still reach it — + * `POST /api/config` in particular can point the bridge at another relay, which + * would hand that relay the bridge token and every frame that follows. Two + * independent checks, because either alone is bypassable: + * + * - The body must be `application/json`. That content type is not CORS-safelisted, + * so a cross-site request from a page the user opened always triggers a preflight, + * and the bridge answers no preflight — the browser blocks the request outright. + * - The request must carry the per-process secret. The secret is handed out by + * `GET /api/session`, which only answers a caller whose own headers show it is + * the surface, so a hostile page can never obtain it. + */ +function isTrustedRequest(request, deps) { + const contentType = request.headers['content-type'] ?? ''; + if (!contentType.toLowerCase().startsWith('application/json')) return false; + + return hasSecret(request, deps); +} + +function json(response, status, value) { + const body = JSON.stringify(value); + response.writeHead(status, { + 'content-type': 'application/json; charset=utf-8', + 'cache-control': 'no-store', + 'content-length': Buffer.byteLength(body), + // The surface answers JSON and nothing else, so the browser has no reason to + // guess, and it has no reason to run anything either. + 'x-content-type-options': 'nosniff', + 'content-security-policy': "default-src 'none'; frame-ancestors 'none'", + }); + response.end(body); +} + +/** + * Reads a JSON body. + * + * Returns `null` when the body is too large, so the caller can answer 413 instead + * of silently treating an oversized body as an empty one. + * + * @param {import('node:http').IncomingMessage} request + * @returns {Promise | null>} + */ +async function readJson(request) { + try { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > MAX_BODY) return null; + chunks.push(chunk); + } + const text = Buffer.concat(chunks).toString('utf8'); + if (!text) return {}; + const parsed = JSON.parse(text); + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { + return {}; + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/miniapp/node/tools.mjs b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/tools.mjs new file mode 100644 index 0000000..d1a63b5 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/miniapp/node/tools.mjs @@ -0,0 +1,661 @@ +/** + * Access to the team's MCP tools. + * + * The bridge never hardcodes a tool's parameter names: it reads the live catalogue from + * the Host (`context.tool.list()`), which reports each tool's `inputSchema`, and maps the + * operation payload onto that schema. When a required property cannot be satisfied the + * bridge refuses with `INVALID_ARGUMENTS` and returns the schema so the client can ask + * for exactly the missing field instead of guessing. + */ + +import { randomUUID } from 'node:crypto'; + +const TEAM_SERVER_HINT = 'minimax-bot'; + +/** + * What the room is told when the team tools cannot be read. + * + * A fixed sentence, because this travels to every phone in the account and the Host's + * own error text can name internal paths, tool identifiers and host-side detail. + */ +const TEAM_UNREADABLE = 'The @minimax-bot team tools could not be read. Open MiniMax Bot on this desktop to try again.'; + +/** Candidate property names for each operation field, most likely first. */ +const ALIASES = { + botId: ['botId', 'bot_id', 'target_bot', 'to', 'target', 'targetBotId', 'bot'], + botIds: ['botIds', 'bot_ids', 'bots', 'to', 'targets'], + text: ['text', 'message', 'content', 'prompt'], + prompt: ['prompt', 'text', 'message', 'task', 'content', 'instruction'], + title: ['title', 'name', 'subject'], + taskId: ['taskId', 'task_id', 'id'], + helperId: ['helperId', 'helper_id', 'id'], + scheduleId: ['scheduleId', 'schedule_id', 'id', 'schedule'], + turnId: ['turnId', 'turn_id', 'id', 'sessionId'], + task: ['task', 'prompt', 'message', 'content', 'instruction', 'description'], + id: ['id', 'memoryId', 'entryId', 'scheduleId', 'groupId', 'botId'], + groupId: ['groupId', 'group_id', 'group', 'id'], + query: ['query', 'q', 'search', 'text'], + limit: ['limit', 'count', 'max', 'top'], + days: ['days', 'span', 'range'], + name: ['name', 'title'], + memberIds: ['memberIds', 'member_ids', 'members', 'bots'], + admin: ['admin', 'adminBotId', 'admin_bot_id'], + notice: ['notice', 'description'], + replyMode: ['replyMode', 'reply_mode', 'mode'], + skin: ['skin', 'look', 'avatar'], + color: ['color', 'colour'], + model: ['model', 'modelId', 'model_id'], + role: ['role', 'title', 'description'], + instructions: ['instructions', 'prompt', 'systemPrompt', 'persona'], + main: ['main', 'isMain', 'is_main'], + kind: ['kind', 'type', 'scheduleKind', 'schedule_kind'], + spec: ['spec', 'time', 'at', 'everyMinutes', 'every_minutes', 'cron', 'schedule'], + targetBotId: ['targetBotId', 'target_bot_id', 'target_bot', 'botId', 'bot_id', 'to'], + enabled: ['enabled', 'active'], + chatId: ['chatId', 'chat_id'], + mentions: ['mentions', 'mentionIds', 'at'], + attachments: ['attachments', 'files'], + requestId: ['requestId', 'request_id', 'id'], + decision: ['decision', 'action'], + answers: ['answers', 'answer'], + messageId: ['messageId', 'message_id', 'id'], + emoji: ['emoji', 'reaction'], + role_: ['role', 'title'], + key: ['key', 'topic', 'topicKey'], + replaceId: ['replaceId', 'replace_id', 'entryId'], + batchId: ['batchId', 'batch_id'], + waitMs: ['waitMs', 'wait_ms'], + resultOffset: ['resultOffset', 'result_offset'], + scope: ['scope'], + pinned: ['pinned'], + source: ['source'], +}; + +/** + * Properties the team fills in itself. + * + * Every team tool advertises `caller_bot_id`, and some of them list it as required, + * but the server is the one that knows which Bot is calling. The bridge never sends + * it, so it is never treated as a missing argument. + */ +const SERVER_SUPPLIED = ['caller_bot_id']; + +/** + * The first of several candidate fields that carries text. + * + * @param {...unknown} values + * @returns {string | undefined} + */ +function firstText(...values) { + for (const value of values) { + if (typeof value === 'string' && value.trim()) return value.trim(); + } + return undefined; +} + +/** + * The first of several candidate fields that carries a number. + * + * @param {...unknown} values + * @returns {number | undefined} + */ +function firstNumber(...values) { + for (const value of values) { + if (typeof value === 'number' && Number.isFinite(value)) return value; + } + return undefined; +} + +/** + * A list of Bot ids from any of the shapes the operation payload may use. + * + * @param {unknown} value + * @returns {string[]} + */ +function toBotList(value) { + if (Array.isArray(value)) { + return value.map((entry) => String(entry ?? '').trim()).filter(Boolean); + } + if (typeof value === 'string' && value.trim()) return [value.trim()]; + return []; +} + +/** + * Builds the arguments for the task dispatcher. + * + * The dispatcher does not take a Bot and a prompt: it takes a *list* of tasks, each + * with its own recipient and message, plus an idempotency key. That list is the one + * thing a flat property mapping cannot express, so the dispatch is built here. + * + * @param {Record} payload + * @returns {Record} + */ +function buildDispatchArguments(payload) { + const botIds = toBotList(payload.botIds ?? payload.bot_ids ?? payload.bots ?? payload.to); + if (botIds.length === 0) { + throw new ToolArgumentError('A dispatch needs at least one Bot to hand the work to.', { + field: 'botIds', + missing: ['botIds'], + required: ['request_id', 'tasks'], + properties: ['botIds', 'prompt', 'title'], + }); + } + const prompt = firstText(payload.prompt, payload.text, payload.message, payload.task); + if (!prompt) { + throw new ToolArgumentError('A dispatch needs the text the Bot is to work on.', { + field: 'prompt', + missing: ['prompt'], + required: ['request_id', 'tasks'], + properties: ['botIds', 'prompt', 'title'], + }); + } + const title = firstText(payload.title, payload.name, payload.subject); + const requestId = firstText(payload.requestId, payload.request_id); + return { + request_id: requestId ?? `phone-${randomUUID()}`, + tasks: botIds.map((to) => ({ to, message: prompt, ...(title ? { title } : {}) })), + }; +} + +/** + * Builds the arguments for a task query. + * + * The team's task tool answers for one batch or one task at a time; there is no + * "list everything" call, and it refuses a query that names both. + * + * @param {Record} payload + * @returns {Record} + */ +function buildTaskQueryArguments(payload) { + /** @type {Record} */ + const args = {}; + const taskId = firstText(payload.taskId, payload.task_id); + const batchId = firstText(payload.batchId, payload.batch_id); + if (taskId) args.task_id = taskId; + if (batchId) args.batch_id = batchId; + const waitMs = firstNumber(payload.waitMs, payload.wait_ms); + if (waitMs !== undefined) args.wait_ms = waitMs; + const resultOffset = firstNumber(payload.resultOffset, payload.result_offset); + if (resultOffset !== undefined) args.result_offset = resultOffset; + return args; +} + +/** + * Builds the arguments for the memory tool. + * + * A memory is either filed under a topic or replaces an existing entry, so the + * tool takes a topic key and an entry id under their own names. The target Bot is + * the tool's `target_bot`, not a `bot_id`. + * + * @param {Record} payload + * @returns {Record} + */ +function buildRememberArguments(payload) { + /** @type {Record} */ + const args = {}; + const text = firstText(payload.text, payload.message, payload.content); + if (text) args.text = text; + const botId = firstText(payload.botId, payload.bot_id, payload.targetBotId, payload.target_bot); + if (botId) args.target_bot = botId; + const key = firstText(payload.key, payload.topic, payload.topicKey); + if (key) args.key = key; + const replaceId = firstText(payload.replaceId, payload.replace_id, payload.entryId); + if (replaceId) args.replace_id = replaceId; + const scope = firstText(payload.scope); + if (scope) args.scope = scope; + if (typeof payload.pinned === 'boolean') args.pinned = payload.pinned; + const source = firstText(payload.source); + if (source) args.source = source; + return args; +} + +/** + * The text of a tool's own error message. + * + * A tool that ran and failed says so in its content. Keeping that text is what lets + * the phone tell the user what the team actually said. + * + * @param {unknown} content + * @returns {string | undefined} + */ +function errorText(content) { + if (!Array.isArray(content)) return undefined; + for (const part of content) { + if (part && typeof part.text === 'string' && part.text.trim()) return part.text.trim(); + } + return undefined; +} + +/** + * The tools whose arguments are not a flat property mapping. + * + * @type {Record) => Record>} + */ +const TOOL_ARG_BUILDERS = { + dispatch_tasks: buildDispatchArguments, + get_tasks: buildTaskQueryArguments, + cancel_tasks: buildTaskQueryArguments, + remember: buildRememberArguments, +}; + +/** + * Which team tool backs each operation. + * + * The table lives in the shared protocol package — the same one the client and the relay + * read — so the three runtimes cannot drift apart. It is vendored into the plugin + * artifact by `scripts/sync-protocol.mjs`, because a Mini App runs with no module + * resolution. + */ +export { OP_TOOL } from './protocol/index.js'; + +export class ToolArgumentError extends Error { + constructor(message, details) { + super(message); + this.name = 'ToolArgumentError'; + this.code = 'INVALID_ARGUMENTS'; + this.details = details; + } +} + +/** + * Whether a value fits a JSON Schema property. + * + * An unknown type keyword never blocks a call: the live schema may use constructs + * this check does not model, and refusing on them would break working tools. + */ +function matchesType(value, schema) { + if (!schema || typeof schema !== 'object') return true; + + const declared = schema.type; + if (declared) { + const types = Array.isArray(declared) ? declared : [declared]; + const ok = types.some((type) => { + switch (type) { + case 'string': + return typeof value === 'string'; + case 'number': + return typeof value === 'number' && Number.isFinite(value); + case 'integer': + return Number.isInteger(value); + case 'boolean': + return typeof value === 'boolean'; + case 'array': + return Array.isArray(value); + case 'object': + return value !== null && typeof value === 'object' && !Array.isArray(value); + default: + return true; + } + }); + if (!ok) return false; + } + + if (Array.isArray(schema.enum) && !schema.enum.includes(value)) return false; + + // The range a tool declares is part of its contract: the memory tool refuses a + // page of more than twenty entries, and the caller should hear that from the + // bridge — with the range in the message — rather than from the team's own error. + if (typeof value === 'number') { + if (typeof schema.minimum === 'number' && value < schema.minimum) return false; + if (typeof schema.maximum === 'number' && value > schema.maximum) return false; + } + if (typeof value === 'string') { + if (typeof schema.minLength === 'number' && value.length < schema.minLength) return false; + if (typeof schema.maxLength === 'number' && value.length > schema.maxLength) return false; + } + + return true; +} + +/** + * Maps an operation payload onto a tool's input schema. + * + * When the schema is unknown the payload is passed through untouched, which is correct + * for tools that take a single argument bag. + * + * A payload field is only ever written onto a property that accepts its type: the + * alias list is walked in order and the first candidate whose type fits wins. If a + * candidate exists but the value does not fit it, that is a real mismatch and is + * reported — the field is never silently dropped, because a dropped field turns + * into a tool call with the wrong arguments. + * + * A few tools take arguments that are not properties of the payload at all — the task + * dispatcher takes a list of tasks, the memory tool a topic key. Those are built by + * the tool's own builder, and the schema is then used to check what they produced. + * + * @param {Record} payload + * @param {any} schema + * @param {string} [toolName] + */ +export function buildToolArguments(payload, schema, toolName) { + const builder = toolName ? TOOL_ARG_BUILDERS[toolName] : undefined; + if (builder) { + const args = builder(payload ?? {}); + assertArgumentTypes(schema, args); + assertRequirements(schema, args, {}); + return args; + } + + const properties = schema && typeof schema === 'object' ? schema.properties : undefined; + if (!properties || typeof properties !== 'object') { + return { ...payload }; + } + + const used = new Set(); + /** @type {Record} */ + const args = {}; + /** @type {string[]} */ + const unmapped = []; + + for (const [key, value] of Object.entries(payload)) { + if (value === undefined) continue; + const candidates = ALIASES[key] ?? [key]; + const target = candidates.find( + (candidate) => + candidate in properties && !used.has(candidate) && matchesType(value, properties[candidate]), + ); + if (target) { + args[target] = value; + used.add(target); + continue; + } + + // A candidate exists but rejects the value's type. Say so instead of dropping it. + const mismatched = candidates.find( + (candidate) => candidate in properties && !used.has(candidate), + ); + if (mismatched) { + throw new ToolArgumentError( + `"${key}" does not fit "${mismatched}" (${describeType(properties[mismatched])}${describeRange(properties[mismatched])}).`, + { + field: key, + target: mismatched, + expected: `${describeType(properties[mismatched])}${describeRange(properties[mismatched])}`, + missing: [], + required: Array.isArray(schema.required) ? schema.required : [], + properties: Object.keys(properties), + }, + ); + } + + unmapped.push(key); + } + + // Properties the schema wants but the payload did not carry are left out on purpose: + // the tool decides whether they are required. + + assertRequirements(schema, args, { unmapped }); + + return args; +} + +/** + * Checks that every value a builder produced fits the property it was written to. + * + * @param {any} schema + * @param {Record} args + */ +function assertArgumentTypes(schema, args) { + const properties = schema && typeof schema === 'object' ? schema.properties : undefined; + if (!properties || typeof properties !== 'object') return; + for (const [key, value] of Object.entries(args)) { + const property = properties[key]; + if (!property) continue; + if (matchesType(value, property)) continue; + throw new ToolArgumentError( + `"${key}" does not fit "${key}" (${describeType(property)}${describeRange(property)}).`, + { + field: key, + target: key, + expected: `${describeType(property)}${describeRange(property)}`, + missing: [], + required: Array.isArray(schema.required) ? schema.required : [], + properties: Object.keys(properties), + }, + ); + } +} + +/** + * Checks what the schema requires, including the requirements it states conditionally. + * + * The team's task tool says "a task id or a batch id, not both" and its memory tool + * says "a topic key or the entry to replace". Neither is a plain `required` list, so + * without this the call would leave and come back with the server's own wording. + * + * @param {any} schema + * @param {Record} args + * @param {{unmapped?: string[]}} [extra] + */ +function assertRequirements(schema, args, extra = {}) { + const properties = schema && typeof schema === 'object' ? schema.properties : undefined; + const required = Array.isArray(schema?.required) ? schema.required : []; + const missing = required.filter( + (property) => !(property in args) && !SERVER_SUPPLIED.includes(property), + ); + if (missing.length > 0) { + throw new ToolArgumentError( + `Missing required argument${missing.length > 1 ? 's' : ''}: ${missing.join(', ')}`, + { + missing, + required, + unmapped: extra.unmapped ?? [], + properties: properties ? Object.keys(properties) : [], + }, + ); + } + + for (const [keyword, mode] of [['anyOf', 'any'], ['oneOf', 'one']]) { + const branches = schema?.[keyword]; + if (!Array.isArray(branches)) continue; + const satisfied = branches.filter((branch) => + requiredNames(branch).every((name) => name in args), + ); + const names = branchNames(branches); + if (mode === 'any' && satisfied.length === 0) { + throw new ToolArgumentError( + `One of ${names.join(', ')} is required.`, + { + missing: names, + required, + unmapped: extra.unmapped ?? [], + properties: properties ? Object.keys(properties) : [], + }, + ); + } + if (mode === 'one' && satisfied.length !== 1) { + throw new ToolArgumentError( + `Exactly one of ${names.join(', ')} is required.`, + { + missing: satisfied.length === 0 ? names : [], + required, + unmapped: extra.unmapped ?? [], + properties: properties ? Object.keys(properties) : [], + }, + ); + } + } +} + +/** The properties a schema branch requires. */ +function requiredNames(branch) { + return Array.isArray(branch?.required) ? branch.required : []; +} + +/** Every property named by a set of schema branches. */ +function branchNames(branches) { + const names = new Set(); + for (const branch of branches) { + for (const name of requiredNames(branch)) names.add(name); + } + return [...names]; +} + +/** A short human description of a schema property, for error messages. */ +function describeType(property) { + if (!property || typeof property !== 'object') return 'unknown'; + const declared = property.type; + if (Array.isArray(declared)) return declared.join(' | '); + if (typeof declared === 'string') return declared; + if (Array.isArray(property.enum)) return `one of ${property.enum.join(', ')}`; + return 'unknown'; +} + +/** The range a schema property accepts, for error messages. */ +function describeRange(property) { + if (!property || typeof property !== 'object') return ''; + const { minimum, maximum, minLength, maxLength } = property; + if (typeof minimum === 'number' && typeof maximum === 'number') return ` between ${minimum} and ${maximum}`; + if (typeof maximum === 'number') return ` at most ${maximum}`; + if (typeof minimum === 'number') return ` at least ${minimum}`; + if (typeof maxLength === 'number') return ` of at most ${maxLength} characters`; + if (typeof minLength === 'number') return ` of at least ${minLength} characters`; + return ''; +} + +export class ToolGateway { + /** + * @param {import('./contracts.d.ts').HostContext} context the Mini App context + */ + constructor(context) { + this.context = context; + /** @type {Array<{name: string, server: string, tool: string, description?: string, inputSchema?: unknown}>} */ + this.catalog = []; + this.partial = false; + this.available = false; + /** + * The sentence the room is told. + * + * It is a fixed sentence and not the Host's own error text: this travels to every + * phone in the account, and the Host's message can name internal paths, tool + * identifiers and host-side detail that the phone's users did not need to see. + * The detail is kept in `detail` for the desktop. + * + * @type {string | undefined} + */ + this.reason = undefined; + /** + * The detail behind the sentence, for the desktop's own logs and surface. + * + * @type {string | undefined} + */ + this.detail = undefined; + } + + /** Reads the live tool catalogue. Never throws: an unavailable team is a state, not a crash. */ + async refresh() { + const tool = this.context.tool; + if (!tool || typeof tool.list !== 'function') { + this.available = false; + this.reason = 'This Mini App was not granted tool access.'; + this.detail = undefined; + this.catalog = []; + return this; + } + try { + const result = await tool.list(); + this.catalog = (result.tools ?? []).filter((entry) => this.isTeamTool(entry)); + this.partial = result.partial === true; + this.available = this.catalog.length > 0; + this.reason = this.available + ? undefined + : 'The @minimax-bot team is not running. Open MiniMax Bot on this desktop to link it.'; + this.detail = undefined; + } catch (error) { + this.available = false; + this.catalog = []; + this.detail = error instanceof Error ? error.message : String(error); + this.reason = TEAM_UNREADABLE; + // The detail is written down here, on the desktop, where the person who can do + // something about it will find it. + this.context.logger?.warn?.('the team tool catalogue could not be read', { message: this.detail }); + } + return this; + } + + /** @param {Partial} entry + * @returns {entry is import('./protocol/types.js').ToolDescriptor} */ + isTeamTool(entry) { + if (!entry || typeof entry.server !== 'string' || typeof entry.name !== 'string' + || typeof entry.tool !== 'string' || !entry.tool) return false; + const server = entry.server.toLowerCase(); + const name = entry.name.toLowerCase(); + // Both identities must agree. A helper server is not the canonical team, + // even when its name contains "minimax-bot" or it exports the same tool. + return server === TEAM_SERVER_HINT + && name === `mcp__${TEAM_SERVER_HINT}__${entry.tool.toLowerCase()}`; + } + + /** Finds a uniquely identified team tool; ambiguous catalogues fail closed. */ + find(toolName) { + const matches = this.catalog.filter((entry) => entry.tool === toolName); + return matches.length === 1 ? matches[0] : undefined; + } + + /** + * Calls a team tool by its short name. + * + * @returns {Promise<{ok: boolean, content?: unknown, structuredContent?: unknown, isError?: boolean, code?: string, disposition?: string, details?: unknown, message?: string}>} + */ + async call(toolName, payload, options = {}) { + const descriptor = this.find(toolName); + if (!descriptor) { + return { ok: false, code: 'TOOL_NOT_AVAILABLE', disposition: 'not_dispatched' }; + } + + let args; + try { + args = buildToolArguments(payload, descriptor.inputSchema, toolName); + } catch (error) { + if (error instanceof ToolArgumentError) { + return { + ok: false, + code: error.code, + disposition: 'not_dispatched', + message: error.message, + details: error.details, + }; + } + throw error; + } + + // The Host's tool API is positional: `call(name, arguments, options?)`. Passing an + // object instead leaves the Host with an undefined tool name, and it answers + // "Mini App tool name or arguments are invalid" for every call. + const result = await this.context.tool.call( + descriptor.name, + args, + options.signal ? { signal: options.signal } : undefined, + ); + + if (!result || result.kind !== 'mcp') { + return { + ok: false, + code: 'MCP_TOOL_FAILED', + disposition: 'unknown_after_dispatch', + message: `The team tool ${toolName} did not return an MCP result.`, + }; + } + + if (result.isError === true) { + // The tool ran and reported its own failure. That is a completed call with an + // error payload, not a refused call, so the bridge reports it as one — with the + // tool's own wording, which is the difference between "something went wrong" + // and "the query must not be empty". + return { + ok: true, + isError: true, + content: result.content, + structuredContent: result.structuredContent, + message: errorText(result.content) ?? `The ${toolName} tool reported an error.`, + }; + } + + return { + ok: true, + isError: false, + content: result.content, + structuredContent: result.structuredContent, + }; + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/package.json b/plugins/nattiini45/minimax-bot-bridge/package.json new file mode 100644 index 0000000..2442d50 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/package.json @@ -0,0 +1,16 @@ +{ + "name": "minimax-bot-bridge", + "version": "0.1.0", + "private": true, + "description": "Pairs your phone with the MiniMax Bot team on your desktop: a pairing code, the paired phones, and the relay configuration, with the team's conversations and tool activity carried over a relay you operate.", + "license": "MIT", + "author": "nattiini45", + "type": "module", + "mcode": { + "schemaVersion": 2, + "miniApp": "./miniapp/miniapp.json" + }, + "scripts": { + "test": "node --test" + } +} diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/activity-retention.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/activity-retention.test.mjs new file mode 100644 index 0000000..ca961ae --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/activity-retention.test.mjs @@ -0,0 +1,151 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; + +const chatId = 'bot:bot_b1'; + +function makeBridge() { + const bridge = new Bridge({ + dataDir: '.', + logger: { info() {}, warn() {}, error() {}, debug() {} }, + }); + // No runtime, disk writes, or relay connection is needed for in-memory retention. + bridge.runModeFor = () => 'hosted'; + bridge.relay.send = () => true; + return bridge; +} + +function message(bridge, chat, turnId, ts = 0) { + bridge.pushHistory(chat, { messageId: `msg_${turnId}`, chatId: chat, turnId, role: 'assistant', text: 'Done', ts }); +} + +function calls(bridge, chat, turnId, count, { finish = true } = {}) { + for (let index = 0; index < count; index += 1) { + const payload = { chatId: chat, turnId, callId: `${turnId}:${index}`, toolName: 'bash' }; + bridge.handleEvent('evt.tool_started', payload); + if (finish) bridge.handleEvent('evt.tool_finished', { ...payload, ok: true }); + } +} + +function turns(bridge, chat = chatId) { + return [...new Set((bridge.activity.get(chat) ?? []).map((entry) => entry.turnId))]; +} + +test('stored activity drops whole oldest turns without waiting for a history read', () => { + const bridge = makeBridge(); + for (let index = 1; index <= 3; index += 1) { + message(bridge, chatId, `turn_${index}`, index); + calls(bridge, chatId, `turn_${index}`, 160); + } + + assert.equal(bridge.activity.get(chatId).length, 320); + assert.deepEqual(turns(bridge), ['turn_2', 'turn_3']); +}); + +test('the newest oversized turn is retained whole, not sliced at the soft target', () => { + const bridge = makeBridge(); + calls(bridge, chatId, 'old', 20); + calls(bridge, chatId, 'newest', 450); + + assert.equal(bridge.activity.get(chatId).length, 450); + assert.deepEqual(turns(bridge), ['newest']); +}); + +test('running turns stay whole above the target and are trimmed once they finish', () => { + const bridge = makeBridge(); + const controller = new AbortController(); + bridge.runner.handleAgentEvent(chatId, { type: 'turn.started', turnId: 'running' }, controller); + calls(bridge, chatId, 'running', 450); + // All current tools finished, but the turn itself is still running. + calls(bridge, chatId, 'newest', 450); + + assert.equal(bridge.activity.get(chatId).length, 900); + bridge.runner.handleAgentEvent(chatId, { type: 'turn.finished', turnId: 'running', status: 'finished' }, controller); + + assert.equal(bridge.activity.get(chatId).length, 450); + assert.deepEqual(turns(bridge), ['newest']); +}); + +test('history readback preserves the same running turns as stored activity', () => { + const bridge = makeBridge(); + const controller = new AbortController(); + message(bridge, chatId, 'running', 1); + bridge.runner.handleAgentEvent(chatId, { type: 'turn.started', turnId: 'running' }, controller); + calls(bridge, chatId, 'running', 450); + message(bridge, chatId, 'newest', 2); + calls(bridge, chatId, 'newest', 450); + + const read = bridge.trimActivity(bridge.activity.get(chatId), bridge.history.get(chatId)); + + assert.equal(read.length, 900); + assert.deepEqual(read, bridge.activity.get(chatId)); +}); + +test('a tool that never reports back is not pinned forever after its turn ends', () => { + const bridge = makeBridge(); + calls(bridge, chatId, 'unfinished', 450, { finish: false }); + calls(bridge, chatId, 'newest', 450); + assert.equal(bridge.activity.get(chatId).length, 900); + + bridge.handleEvent('evt.turn_finished', { chatId, turnId: 'unfinished', status: 'error' }); + + assert.equal(bridge.activity.get(chatId).length, 450); + assert.deepEqual(turns(bridge), ['newest']); +}); + +test('activity is evicted together with its chat history', () => { + const bridge = makeBridge(); + for (let index = 0; index < 60; index += 1) { + const chat = `bot:bot_${index}`; + message(bridge, chat, `turn_${index}`); + calls(bridge, chat, `turn_${index}`, 1); + } + + assert.equal(bridge.history.size, 50); + assert.equal(bridge.activity.size, 50); + assert.deepEqual([...bridge.activity.keys()], [...bridge.history.keys()]); + assert.equal(bridge.activity.has('bot:bot_0'), false); +}); + +test('activity-only chats share the same tracked-chat ceiling as history-only chats', () => { + const bridge = makeBridge(); + for (let index = 0; index < 30; index += 1) { + message(bridge, `bot:history_${index}`, `history_${index}`); + } + for (let index = 0; index < 60; index += 1) { + calls(bridge, `bot:activity_${index}`, `activity_${index}`, 1); + } + + const known = new Set([...bridge.history.keys(), ...bridge.activity.keys()]); + assert.equal(known.size, 50); + assert.equal(bridge.activity.has('bot:activity_0'), false); + assert.equal(bridge.activity.has('bot:activity_59'), true); +}); + +test('chat eviction preserves an older running conversation over settled chats', () => { + const bridge = makeBridge(); + const controller = new AbortController(); + message(bridge, chatId, 'running'); + bridge.runner.handleAgentEvent(chatId, { type: 'turn.started', turnId: 'running' }, controller); + calls(bridge, chatId, 'running', 1); + for (let index = 0; index < 60; index += 1) { + message(bridge, `bot:other_${index}`, `other_${index}`); + calls(bridge, `bot:other_${index}`, `other_${index}`, 1); + } + + assert.equal(bridge.history.size, 50); + assert.equal(bridge.history.has(chatId), true); + assert.equal(bridge.activity.has(chatId), true); + assert.deepEqual(turns(bridge), ['running']); +}); + +test('ending a turn settles missing tool finishes without changing reported successes', () => { + const bridge = makeBridge(); + calls(bridge, chatId, 'turn_1', 1); + bridge.handleEvent('evt.tool_started', { chatId, turnId: 'turn_1', callId: 'missing', toolName: 'read' }); + + bridge.handleEvent('evt.turn_finished', { chatId, turnId: 'turn_1', status: 'finished' }); + + assert.deepEqual(bridge.activity.get(chatId).map((entry) => entry.status), ['ok', 'unknown']); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/agent.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/agent.test.mjs new file mode 100644 index 0000000..d6b16b3 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/agent.test.mjs @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { AgentRunner } from '../miniapp/node/agent.mjs'; +import { BotRouter } from '../miniapp/node/bot-router.mjs'; +import { PendingSet } from '../miniapp/node/pending.mjs'; + +const chatId = 'bot:bot_b1'; +const turnId = 'turn_hosted'; +const sessionId = 'sess_hosted'; + +function makeRunner(run) { + const events = []; + const finished = []; + const pending = new PendingSet(); + const context = { + agent: { + run, + async answer(input) { + return { sessionId: input.sessionId, status: 'finished' }; + }, + }, + }; + const router = new BotRouter(context, { + roster: async () => ({ bots: [{ botId: 'bot_b1', name: 'Writer' }] }), + }); + const runner = new AgentRunner(context, { + router, + pending, + onEvent: (type, payload) => events.push({ type, ...payload }), + onTurnFinished: (info) => finished.push(info), + }); + return { runner, pending, events, finished }; +} + +function question() { + return { + requestId: 'question_1', + turnId, + mode: 'questionnaire', + request: { title: 'Which one?', steps: [{ stepId: 'step_1', title: 'Pick one', options: [] }] }, + }; +} + +function assertClean(runner) { + assert.equal(runner.activeRuns, 0); + assert.equal(runner.aborts.size, 0); + assert.equal(runner.toolCalls.size, 0); +} + +test('a hosted result-only question is registered, emitted in its chat, and answerable', async () => { + const pendingQuestion = question(); + const { runner, pending, events } = makeRunner(async () => ({ + sessionId, turnId, status: 'waiting_for_user', pendingQuestion, + })); + + await runner.run({ chatId, text: 'Ask me' }); + + assert.equal(pending.size, 1); + const item = pending.get(pendingQuestion.requestId); + assert.equal(item.kind, 'question'); + assert.equal(item.sessionId, sessionId); + assert.deepEqual(item.request, { + requestId: pendingQuestion.requestId, + sessionId, + turnId, + chatId, + mode: pendingQuestion.mode, + title: pendingQuestion.request.title, + steps: pendingQuestion.request.steps, + }); + assert.deepEqual(events.filter((event) => event.type === 'evt.question_requested'), [ + { type: 'evt.question_requested', request: item.request }, + ]); + await runner.answer(pendingQuestion.requestId, []); + assert.equal(pending.size, 0); +}); + +test('a result-only question supplies the turn id when the Host omits it from the result', async () => { + const { runner, pending, events } = makeRunner(async () => ({ + sessionId, status: 'waiting_for_user', pendingQuestion: question(), + })); + + const result = await runner.run({ chatId, text: 'Ask me' }); + + assert.equal(result.turnId, turnId); + assert.equal(pending.get('question_1').request.turnId, turnId); + assert.equal(events.find((event) => event.type === 'evt.turn_finished').turnId, turnId); +}); + +test('a hosted question reported by event and result is emitted and registered only once', async () => { + const pendingQuestion = question(); + const { runner, pending, events } = makeRunner(async (_input, { onEvent }) => { + onEvent({ type: 'question.requested', sessionId, ...pendingQuestion }); + return { sessionId, turnId, status: 'waiting_for_user', pendingQuestion }; + }); + let added = 0; + const add = pending.add.bind(pending); + pending.add = (...args) => { + added += 1; + return add(...args); + }; + + await runner.run({ chatId, text: 'Ask me' }); + + assert.equal(added, 1); + assert.equal(events.filter((event) => event.type === 'evt.question_requested').length, 1); +}); + +test('a hosted rejection cleans event-keyed state and finishes the observed turn', async () => { + const failure = new Error('Host disconnected'); + const { runner, events, finished } = makeRunner(async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', turnId }); + onEvent({ type: 'tool.started', turnId, name: 'bash' }); + throw failure; + }); + + await assert.rejects(runner.run({ chatId, text: 'Work' }), (error) => error === failure); + + assertClean(runner); + const ended = events.filter((event) => event.type === 'evt.turn_finished'); + assert.equal(ended.length, 1); + assert.equal(ended[0].turnId, turnId); + assert.equal(ended[0].chatId, chatId); + assert.equal(ended[0].status, 'error'); + assert.equal(ended[0].error.message, failure.message); + assert.equal(finished.length, 1); + assert.equal(finished[0].error, failure); +}); + +test('a hosted success without a result turn id cleans and finishes the event turn', async () => { + const { runner, events } = makeRunner(async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', turnId }); + onEvent({ type: 'tool.started', turnId, name: 'bash' }); + return { sessionId, status: 'finished', text: 'Done' }; + }); + + const result = await runner.run({ chatId, text: 'Work' }); + + assertClean(runner); + assert.equal(result.turnId, turnId); + const ended = events.filter((event) => event.type === 'evt.turn_finished'); + assert.equal(ended.length, 1); + assert.equal(ended[0].turnId, turnId); + assert.equal(ended[0].status, 'finished'); +}); + +test('aborting a hosted turn aborts its signal, cleans tools, and finishes as aborted', { timeout: 2_000 }, async () => { + let started; + const ready = new Promise((resolve) => { started = resolve; }); + /** @type {AbortSignal | undefined} */ + let signal; + const { runner, events } = makeRunner(async (_input, options) => { + signal = options.signal; + options.onEvent({ type: 'turn.started', turnId }); + options.onEvent({ type: 'tool.started', turnId, name: 'bash' }); + started(); + await new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => reject(new DOMException('Stopped', 'AbortError')), { once: true }); + }); + }); + + const running = runner.run({ chatId, text: 'Work' }); + const rejected = assert.rejects(running, { name: 'AbortError' }); + await ready; + assert.equal(await runner.abort(turnId), true); + assert.ok(signal); + assert.equal(signal.aborted, true); + await rejected; + + assertClean(runner); + const ended = events.filter((event) => event.type === 'evt.turn_finished'); + assert.equal(ended.length, 1); + assert.equal(ended[0].turnId, turnId); + assert.equal(ended[0].status, 'aborted'); + assert.equal(ended[0].reason, 'cancelled'); + assert.equal(await runner.abort(turnId), false); +}); + +test('a hosted CANCELLED rejection is reported as aborted with the observed turn id', async () => { + const failure = Object.assign(new Error('Stopped'), { code: 'CANCELLED' }); + const { runner, events } = makeRunner(async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', turnId }); + throw failure; + }); + + await assert.rejects(runner.run({ chatId, text: 'Work' }), (error) => error === failure); + + assertClean(runner); + const ended = events.find((event) => event.type === 'evt.turn_finished'); + assert.equal(ended.turnId, turnId); + assert.equal(ended.status, 'aborted'); +}); + +test('a hosted terminal event is not emitted again when the Host subsequently rejects', async () => { + const failure = new Error('Host disconnected'); + const { runner, events } = makeRunner(async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', turnId }); + onEvent({ type: 'turn.finished', turnId, status: 'error', error: { message: failure.message } }); + throw failure; + }); + + await assert.rejects(runner.run({ chatId, text: 'Work' }), (error) => error === failure); + + assertClean(runner); + assert.equal(events.filter((event) => event.type === 'evt.turn_finished').length, 1); +}); + +test('a hosted failure before events releases its slot without inventing a turn id', async () => { + const failure = new Error('Host unavailable'); + const { runner, events, finished } = makeRunner(async () => { throw failure; }); + + await assert.rejects(runner.run({ chatId, text: 'Work' }), (error) => error === failure); + + assertClean(runner); + assert.equal(events.find((event) => event.type === 'evt.turn_finished').turnId, undefined); + assert.equal(finished.length, 1); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/approval-claim.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/approval-claim.test.mjs new file mode 100644 index 0000000..e4ed682 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/approval-claim.test.mjs @@ -0,0 +1,378 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { MAX_CONCURRENT_RUNS } from '../miniapp/node/agent.mjs'; +import { createFakeTeam, TEAM_TOOL_DESCRIPTORS } from './fake-team.mjs'; + +/** + * Answering a request that is waiting. + * + * A turn that stops for a permission check is held by the desktop, and the phone + * answers it later. The answer is Host work on a privileged action, so it must reach + * the Host once and only once: two answers to the same request would be two replies + * about the same action. + */ + +/** + * Puts a request into the bridge's hands, the way the Host does when it asks about a + * run the bridge started. + * + * A Bot's own approval lives in the Bot's session, which the team owns, so it is + * answered in MiniMax Bot and not from the phone. + * + * @param {any} bridge + * @param {{requestId: string, toolName?: string, reason?: string, mode?: string, request?: any}} request + */ +function holdRequest(bridge, request) { + bridge.runner.handleAgentEvent( + 'bot:bot_b1', + { type: 'permission.requested', sessionId: 'sess-1', turnId: 'turn-1', ...request }, + new AbortController(), + ); +} + +async function makeBridge() { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-approval-')); + /** The answers the Host was given. */ + const agentReplies = []; + /** The frames the bridge sent to the room. */ + const sent = []; + const team = createFakeTeam(); + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { + async list() { + return { tools: TEAM_TOOL_DESCRIPTORS, partial: false }; + }, + async call(name, args) { + return team.call(name, args); + }, + }, + agent: { + async run(input, options) { + options?.onEvent?.({ + type: 'permission.requested', + sessionId: 'sess-1', + requestId: 'req-1', + toolName: 'shell', + reason: 'writes a file', + }); + return { sessionId: 'sess-1', turnId: 'turn-1', status: 'finished' }; + }, + async reply(payload) { + agentReplies.push(payload); + }, + async answer() { + return { sessionId: 'sess-1', status: 'finished' }; + }, + }, + }; + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = (frame) => { + sent.push(frame); + return true; + }; + + await bridge.start(); + + return { + bridge, + agentReplies, + sent, + async dispose() { + bridge.stop(); + await rm(dataDir, { recursive: true, force: true }); + }, + }; +} + +/** + * Runs a promise against a deadline. + * + * A test that waits forever is a test that hangs the suite, so every wait here has + * one. + */ +async function within(promise, ms = 2_000) { + let timer; + const deadline = new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`the promise did not settle within ${ms}ms`)), ms); + }); + try { + return await Promise.race([promise, deadline]); + } finally { + clearTimeout(timer); + } +} + +test('a double-tapped approve button reaches the Host once', async () => { + const { bridge, agentReplies, dispose } = await makeBridge(); + try { + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'do it' }); + holdRequest(bridge, { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + assert.equal(bridge.pending.size, 1); + + // The Host is slow, so the second tap arrives while the first answer is still in + // flight. That is what a double tap produces. + let release = () => {}; + /** @type {Promise} */ + const held = new Promise((resolve) => { + release = () => resolve(); + }); + bridge.context.agent.reply = async (payload) => { + agentReplies.push(payload); + await held; + }; + + const first = bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }); + const second = bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'deny' }); + + // The second answer is refused straight away, without waiting for the Host. + await assert.rejects( + () => within(second), + (error) => { + assert.equal(/** @type {{code?: string}} */ (error).code, 'REQUEST_NOT_FOUND'); + return true; + }, + ); + + release(); + await within(first); + + assert.equal(agentReplies.length, 1); + assert.equal(agentReplies[0].decision, 'allowOnce'); + } finally { + await dispose(); + } +}); + +test('a second answer to a request that is already answered is refused', async () => { + const { bridge, agentReplies, dispose } = await makeBridge(); + try { + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'do it' }); + holdRequest(bridge, { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + + await bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }); + assert.equal(agentReplies.length, 1); + + await assert.rejects( + () => bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }), + (error) => { + assert.equal(/** @type {{code?: string}} */ (error).code, 'REQUEST_NOT_FOUND'); + return true; + }, + ); + assert.equal(agentReplies.length, 1); + } finally { + await dispose(); + } +}); + +test('a request is given back when the Host does not take the answer', async () => { + const { bridge, dispose } = await makeBridge(); + try { + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'do it' }); + holdRequest(bridge, { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + + bridge.context.agent.reply = async () => { + throw new Error('the host is not there'); + }; + await assert.rejects(() => bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'deny' })); + + // The request is still waiting for an answer, so the phone may give one. + assert.equal(bridge.pending.size, 1); + + const answers = []; + bridge.context.agent.reply = async (payload) => { + answers.push(payload); + }; + await bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'deny' }); + assert.equal(answers.length, 1); + assert.equal(bridge.pending.size, 0); + } finally { + await dispose(); + } +}); + +test('the run ceiling refuses new work but admits one completion of an existing permission', async () => { + const { bridge, agentReplies, sent, dispose } = await makeBridge(); + let release = () => {}; + /** @type {Promise} */ + const held = new Promise((resolve) => { release = () => resolve(); }); + let answering; + try { + holdRequest(bridge, { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + const request = bridge.pending.get('req-1'); + assert.ok(request); + let starts = 0; + bridge.context.agent.run = async () => { + starts += 1; + return { sessionId: 'new-session', turnId: 'new-turn', status: 'finished' }; + }; + bridge.context.agent.reply = async (payload) => { + agentReplies.push(payload); + await held; + }; + + // This is the bridge's admission ceiling, not a claim that a Desktop Host's + // separately declared four-run limit can be filled with 32 hosted turns. + bridge.runner.activeRuns = MAX_CONCURRENT_RUNS; + await assert.rejects( + () => within(bridge.runner.runHosted({ chatId: 'bot:bot_b1', text: 'new work' })), + { code: 'BUSY' }, + ); + assert.equal(starts, 0); + assert.equal(bridge.pending.get('req-1'), request); + assert.notEqual(request.claimed, true); + + answering = bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }); + assert.equal(request.claimed, true); + assert.equal(agentReplies.length, 1); + assert.deepEqual(agentReplies[0], { sessionId: 'sess-1', requestId: 'req-1', decision: 'allowOnce' }); + assert.equal(bridge.runner.activeRuns, MAX_CONCURRENT_RUNS + 1); + await assert.rejects( + () => within(bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'deny' })), + { code: 'REQUEST_NOT_FOUND' }, + ); + + release(); + await within(answering); + assert.equal(bridge.runner.activeRuns, MAX_CONCURRENT_RUNS); + assert.equal(bridge.pending.size, 0); + assert.equal(sent.filter(frame => frame.type === 'evt.permission_resolved' && frame.payload.requestId === 'req-1').length, 1); + await assert.rejects( + () => bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }), + { code: 'REQUEST_NOT_FOUND' }, + ); + assert.equal(agentReplies.length, 1); + } finally { + release(); + await answering?.catch(() => {}); + bridge.runner.activeRuns = 0; + await dispose(); + } +}); + +test('completion accounting returns its slot after success and an actual pending-request failure', async () => { + const { bridge, sent, dispose } = await makeBridge(); + try { + holdRequest(bridge, { requestId: 'success', toolName: 'shell', reason: 'writes a file' }); + bridge.context.agent.reply = async () => { + assert.equal(bridge.runner.activeRuns, 1); + }; + await bridge.dispatch('op.respond_permission', { requestId: 'success', decision: 'allowOnce' }); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(bridge.pending.get('success'), undefined); + + // A different request really is waiting, and the Host rejects before accepting + // its decision. This must not accidentally test an already-resolved id. + holdRequest(bridge, { requestId: 'retry', toolName: 'shell', reason: 'writes another file' }); + const request = bridge.pending.get('retry'); + assert.ok(request); + const failure = Object.assign(new Error('The desktop agent is unavailable.'), { + code: 'AGENT_UNAVAILABLE', retryable: true, + }); + let attempts = 0; + bridge.context.agent.reply = async () => { + attempts += 1; + assert.equal(bridge.runner.activeRuns, 1); + throw failure; + }; + await assert.rejects( + () => within(bridge.dispatch('op.respond_permission', { requestId: 'retry', decision: 'deny' })), + error => error === failure, + ); + assert.equal(attempts, 1); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(bridge.pending.get('retry'), request); + assert.equal(request.claimed, false); + assert.equal(sent.filter(frame => frame.type === 'evt.permission_resolved' && frame.payload.requestId === 'retry').length, 0); + + bridge.context.agent.reply = async () => { + attempts += 1; + assert.equal(bridge.runner.activeRuns, 1); + }; + await bridge.dispatch('op.respond_permission', { requestId: 'retry', decision: 'deny' }); + assert.equal(attempts, 2); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(bridge.pending.size, 0); + assert.equal(sent.filter(frame => frame.type === 'evt.permission_resolved' && frame.payload.requestId === 'retry').length, 1); + } finally { + await dispose(); + } +}); + +test('a request that is being answered is not offered to a phone that reconnects', async () => { + const { bridge, sent, dispose } = await makeBridge(); + try { + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'do it' }); + holdRequest(bridge, { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + + // The answer is in flight. + let release = () => {}; + /** @type {Promise} */ + const held = new Promise((resolve) => { + release = () => resolve(); + }); + bridge.context.agent.reply = async () => { + await held; + }; + const answering = bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + // A phone reconnects while the answer is in flight. + sent.length = 0; + bridge.replayPending(); + + const reAsked = sent.filter((frame) => frame.type === 'evt.permission_requested'); + assert.equal(reAsked.length, 0); + + release(); + await within(answering); + } finally { + await dispose(); + } +}); + +test('the question path is answered once as well', async () => { + const { bridge, sent, dispose } = await makeBridge(); + try { + // A question is waiting. + bridge.runner.pending.add('question', { requestId: 'q-1', title: 'Which one?' }, 'sess-1'); + assert.equal(bridge.pending.size, 1); + + const answers = []; + bridge.context.agent.answer = async (payload) => { + answers.push(payload); + return { sessionId: 'sess-1', status: 'finished' }; + }; + + await bridge.dispatch('op.answer_question', { requestId: 'q-1', answers: [{ questionId: 'a', value: 'b' }] }); + assert.equal(answers.length, 1); + + // A second answer for the same question is refused. + await assert.rejects( + () => bridge.dispatch('op.answer_question', { requestId: 'q-1', answers: [] }), + (error) => { + assert.equal(/** @type {{code?: string}} */ (error).code, 'REQUEST_NOT_FOUND'); + return true; + }, + ); + assert.equal(answers.length, 1); + assert.equal(bridge.runner.activeRuns, 0); + } finally { + await dispose(); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/bot-model.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/bot-model.test.mjs new file mode 100644 index 0000000..7095a76 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/bot-model.test.mjs @@ -0,0 +1,226 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ModelPicker } from '../miniapp/node/bot-model.mjs'; + +/** + * The model a Bot's turn runs on. + * + * The roster names a Bot's model as `providerId/modelId` and its effort as a level, + * while the Host's agent takes a *selection*: the catalog entry, plus the effort to run + * it at. Getting this wrong is not a detail: a run that hands the Host a name it does + * not take is refused before the turn starts, so the phone would see a Bot that never + * answers. + * + * The resolution mirrors the product's own: the model the Bot names, at the Bot's + * effort, and nothing at all on a Host that does not let a run pick its model. + */ + +/** The catalog the Host offers, as the official engine reads it. */ +const CATALOG = { + default: { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variants: ['thinking'], + thinking: { default: 'medium', efforts: ['low', 'medium', 'high'] }, + }, + models: [ + { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variants: ['thinking'], + thinking: { default: 'medium', efforts: ['low', 'medium', 'high'] }, + }, + { + providerId: 'minimax', + modelId: 'MiniMax-M2.1', + displayName: 'MiniMax M2.1', + variants: ['thinking', ''], + thinking: { default: 'low', efforts: ['low', 'high'] }, + }, + ], +}; + +/** + * A Host that lets a run pick its model. + * + * @param {object} [options] + * @param {() => Promise} [options.models] + */ +function hostWithModels({ models = async () => CATALOG } = {}) { + let asked = 0; + return { + agent: { + models: () => { + asked += 1; + return models(); + }, + }, + asked: () => asked, + }; +} + +test("a Bot's own model, at its own effort, is the selection the run takes", async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + const selection = await picker.selectionFor({ model: 'minimax/MiniMax-M2.1', effort: 'high' }); + + assert.deepEqual(selection, { + providerId: 'minimax', + modelId: 'MiniMax-M2.1', + variant: 'thinking', + thinking: { effort: 'high' }, + }); +}); + +test('a Bot on the default model keeps the default model, at its effort', async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + const selection = await picker.selectionFor({ model: 'default', effort: 'low' }); + + assert.deepEqual(selection, { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variant: 'thinking', + thinking: { effort: 'low' }, + }); +}); + +test('a Bot with no model of its own runs on the default one', async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + const selection = await picker.selectionFor({ model: undefined, effort: 'default' }); + + assert.deepEqual(selection, { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variant: 'thinking', + thinking: { effort: 'medium' }, + }); +}); + +test('a model the catalog does not know leaves the session as it is', async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + const selection = await picker.selectionFor({ model: 'someone/else', effort: 'high' }); + + assert.equal(selection, undefined); +}); + +test('an effort the model does not offer does not cost the turn', async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + // The first catalog model offers no 'max' effort; the Bot still runs, on its model. + const selection = await picker.selectionFor({ model: 'minimax/MiniMax-M3.1-Flash', effort: 'max' }); + + assert.deepEqual(selection, { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variant: 'thinking', + thinking: { effort: 'medium' }, + }); +}); + +test('thinking turned off is a model of its own', async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + const selection = await picker.selectionFor({ model: 'minimax/MiniMax-M2.1', effort: 'off' }); + + assert.deepEqual(selection, { providerId: 'minimax', modelId: 'MiniMax-M2.1', variant: '' }); +}); + +test('a Host that does not let a run pick its model gets no model at all', async () => { + const picker = new ModelPicker({ agent: { run: async () => ({}) } }); + + const selection = await picker.selectionFor({ model: 'minimax/MiniMax-M2.1', effort: 'high' }); + + assert.equal(selection, undefined); +}); + +test('the catalog is asked for once, not once per turn', async () => { + const host = hostWithModels(); + const picker = new ModelPicker(host); + + await picker.selectionFor({ model: 'minimax/MiniMax-M2.1', effort: 'high' }); + await picker.selectionFor({ model: 'minimax/MiniMax-M2.1', effort: 'low' }); + + assert.equal(host.asked(), 1); +}); + +test('a failed catalog lookup is not served from the cache', async () => { + let calls = 0; + const host = hostWithModels({ + models: async () => { + calls += 1; + if (calls === 1) throw new Error('the catalog is not available'); + return CATALOG; + }, + }); + const picker = new ModelPicker(host); + + await assert.rejects(() => picker.selectionFor({ model: 'minimax/MiniMax-M2.1' })); + const selection = await picker.selectionFor({ model: 'minimax/MiniMax-M2.1' }); + + assert.equal(selection?.modelId, 'MiniMax-M2.1'); + assert.equal(calls, 2); +}); + +/** + * The catalog as the phone needs it. + * + * The phone offers a list of models and a list of efforts per model. A free text field + * would offer a model the Host does not have, and an effort the model does not have, so + * both lists are read from the Host's own catalog. + */ +test('the phone is told which models there are, and what each of them can work at', async () => { + const picker = new ModelPicker(hostWithModels()); + + const catalog = await picker.describe(); + + assert.equal(catalog.supported, true); + assert.deepEqual(catalog.default, { + id: 'minimax/MiniMax-M3.1-Flash', + name: 'MiniMax-M3.1-Flash', + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + efforts: ['low', 'medium', 'high'], + defaultEffort: 'medium', + }); + assert.deepEqual(catalog.models[1], { + id: 'minimax/MiniMax-M2.1', + name: 'MiniMax M2.1', + providerId: 'minimax', + modelId: 'MiniMax-M2.1', + // This model can also run with thinking turned off, so the phone may offer that. + efforts: ['low', 'high', 'off'], + defaultEffort: 'low', + }); +}); + +test('a Host that does not let a run pick its model says so, instead of sending nothing', async () => { + const picker = new ModelPicker({ agent: { run: async () => ({}) } }); + + const catalog = await picker.describe(); + + assert.deepEqual(catalog, { supported: false, models: [] }); +}); + +test('a catalog that cannot be read leaves the phone without a list, not with a wrong one', async () => { + const picker = new ModelPicker( + hostWithModels({ + models: async () => { + throw new Error('the catalog is not available'); + }, + }), + ); + + const catalog = await picker.describe(); + + assert.deepEqual(catalog, { supported: false, models: [] }); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/bot-routing.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/bot-routing.test.mjs new file mode 100644 index 0000000..4c5044f --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/bot-routing.test.mjs @@ -0,0 +1,690 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { createFakeAgent } from './fake-agent.mjs'; +import { createFakeTeam, TEAM_TOOL_DESCRIPTORS } from './fake-team.mjs'; + +/** + * Talking to the Bot the phone picked. + * + * The defect these pin down: selecting a Bot on the phone did not talk to that Bot. + * The turn went to the Host's general assistant, which has no Bot to become, so every + * Bot answered alike and none of them was the one on the screen. + * + * There are two places a Bot's turn can run, and the phone chooses: + * + * - **hosted** — the desktop runs the turn under the bridge, in the Bot's own session, + * with the Bot's own instructions, model and effort. The Host only lets the Mini App + * that owns a run answer its questions, so this is where a permission check or a + * questionnaire the Bot raises can be answered from the phone. + * - **team** — the team's own engine runs the turn. The Bot answers there, and the + * desktop Bot app keeps the whole exchange. + * + * A group is always the team's: who speaks first, who the admin is and who hands over + * to whom are the team's rules. + */ + +const BOTS = [ + { + bot_id: 'bot_b1', + name: 'Writer', + role: 'Long-form drafting', + main: false, + model: 'minimax/MiniMax-M3.1-Flash', + effort: 'default', + instructions: 'Writes the documents the team ships.', + }, + { + bot_id: 'bot_b2', + name: 'Reviewer', + role: 'Second pair of eyes', + main: false, + model: 'minimax/MiniMax-M3.1-Flash', + effort: 'high', + instructions: 'Checks the work before it ships.', + }, +]; + +const GROUPS = [ + { + group_id: 'grp_g1', + name: 'Team', + notice: 'The whole team.', + admin: 'Writer', + members: ['Writer', 'Reviewer'], + reply_mode: 'admin', + }, +]; + +/** + * The team, with two Bots that answer differently and in their own sessions. + * + * @param {object} [options] + */ +function makeTeam(options = {}) { + return createFakeTeam({ + bots: BOTS, + groups: GROUPS, + replies: { + bot_b1: 'The Writer answers.', + bot_b2: 'The Reviewer answers.', + grp_g1: 'The team answers.', + }, + ...options, + }); +} + +/** + * @param {{team?: any, agent?: any, desktop?: any, mode?: 'team' | 'hosted'}} [options] + */ +async function makeBridge({ team = makeTeam(), agent = createFakeAgent(), desktop, mode } = {}) { + // The runtime keeps one directory per Mini App, so the Bot app's data sits next to + // the bridge's, and the code that reads the desktop reads a real layout. + const root = await mkdtemp(join(tmpdir(), 'bridge-routing-')); + const dataDir = join(root, 'plugin-data', 'liveboards', 'minimax-bot-bridge'); + await mkdir(dataDir, { recursive: true }); + /** The frames the bridge sent to the room. */ + const sent = []; + + if (desktop) { + const botDir = join(root, 'plugin-data', 'liveboards', 'minimax-bot'); + await mkdir(botDir, { recursive: true }); + await writeFile( + join(botDir, 'state.json'), + JSON.stringify({ bots: [], groups: [], sessions: desktop.sessions ?? {} }), + 'utf8', + ); + } + + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { + async list() { + return { tools: TEAM_TOOL_DESCRIPTORS, partial: false }; + }, + async call(name, args) { + return team.call(name, args); + }, + }, + agent, + }; + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = (frame) => { + sent.push(frame); + return true; + }; + + await bridge.start(); + + // A Bot's turn runs in the team unless the phone says otherwise, so a test that + // exercises the hosted path asks for it by name. + if (mode) { + for (const bot of BOTS) { + await bridge.dispatch('op.set_bot_mode', { botId: bot.bot_id, mode }); + } + } + + return { + bridge, + sent, + team, + agent, + async dispose() { + bridge.stop(); + await rm(root, { recursive: true, force: true }); + }, + }; +} + +/** The tool calls the team was asked for, by their short name. */ +function teamCalls(team, tool) { + return team.calls.filter((call) => call.name.endsWith(tool)); +} + +function deferred() { + /** @type {(value?: any) => void} */ + let resolve = () => {}; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +async function within(promise) { + let timer; + try { + return await Promise.race([promise, new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error('The fake Host did not settle within 2 seconds.')), 2_000); + })]); + } finally { + clearTimeout(timer); + } +} + +function waitingQuestionnaireAgent() { + const pendingQuestion = { + requestId: 'q-1', + mode: 'questionnaire', + request: { + schemaVersion: 2, + id: 'questionnaire-1', + title: 'Which one?', + steps: [ + { id: 's1', question: 'Which one?', selectionMode: 'single', allowOther: true, + options: [{ id: 'a', label: 'A' }, { id: 'b', label: 'B' }] }, + { id: 's2', question: 'Anything else?', selectionMode: 'multiple', allowOther: true, + options: [{ id: 'detail', label: 'Include details' }] }, + { id: 's3', question: 'Optional follow-up?', selectionMode: 'single', allowOther: true, options: [] }, + ], + }, + }; + return { + pendingQuestion, + agent: createFakeAgent({ + sessionId: 'question-session', + pendingQuestion, + script: [ + { type: 'turn.started' }, + { type: 'question.requested', ...pendingQuestion }, + // A questionnaire ends its Host turn, but the result marker keeps the + // question answerable after that run releases its single-session slot. + { type: 'turn.finished', status: 'finished' }, + ], + }), + }; +} + +// -- the hosted path --------------------------------------------------------- + +test('a hosted turn runs the Bot in its own session, with its own instructions', async () => { + const agent = createFakeAgent({ text: 'The Writer answers.' }); + const { bridge, agent: fake, dispose } = await makeBridge({ + agent, + mode: 'hosted', + desktop: { sessions: { 'bot:bot_b1': { bot_b1: 'sess_writer' } } }, + }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Draft the notes' }); + + assert.equal(fake.runs.length, 1); + const brief = String(fake.runs[0].input.text); + // The brief carries what makes the Bot this Bot. + assert.match(brief, /Writer/); + assert.match(brief, /Long-form drafting/); + assert.match(brief, /Writes the documents the team ships\./); + assert.match(brief, /Draft the notes/); + // And the turn runs in the phone's own conversation: it does not continue the session + // the desktop last used, because that session belongs to whatever the user was doing + // on the desktop, and a phone message dropped into it is answered in the middle of + // that conversation. + assert.equal(fake.runs[0].input.sessionId, undefined); + await dispose(); +}); + +test('the second turn of a phone conversation continues the first', async () => { + const agent = createFakeAgent({ sessionId: 'sess_phone' }); + const { bridge, agent: fake, dispose } = await makeBridge({ + agent, + mode: 'hosted', + desktop: { sessions: { 'bot:bot_b1': { bot_b1: 'sess_writer' } } }, + }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'one' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'two' }); + + // The conversation the phone has with this Bot is its own, and it grows. + assert.equal(fake.runs[0].input.sessionId, undefined); + assert.equal(fake.runs[1].input.sessionId, 'sess_phone'); + await dispose(); +}); + +test('a hosted turn runs on the Bot’s own model, at its own effort', async () => { + const agent = createFakeAgent({ + text: 'The Writer answers.', + catalog: { + default: { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variants: ['thinking'], + thinking: { default: 'medium', efforts: ['low', 'medium', 'high'] }, + }, + models: [ + { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variants: ['thinking'], + thinking: { default: 'medium', efforts: ['low', 'medium', 'high'] }, + }, + ], + }, + }); + const { bridge, agent: fake, dispose } = await makeBridge({ agent, mode: 'hosted' }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b2', text: 'Review this' }); + + // The roster names the model as text; the run takes a selection out of the Host's + // catalog, at the effort the Bot works at. + assert.deepEqual(fake.runs[0].input.model, { + providerId: 'minimax', + modelId: 'MiniMax-M3.1-Flash', + variant: 'thinking', + thinking: { effort: 'high' }, + }); + await dispose(); +}); + +test('a hosted turn on a Host without per-turn models keeps the session’s model', async () => { + const { bridge, agent: fake, dispose } = await makeBridge({ mode: 'hosted' }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Draft the notes' }); + + assert.equal(fake.runs[0].input.model, undefined); + await dispose(); +}); + +test('two Bots run as themselves', async () => { + const { bridge, agent: fake, dispose } = await makeBridge({ mode: 'hosted' }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'one' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b2', text: 'two' }); + + assert.equal(fake.runs.length, 2); + assert.match(String(fake.runs[0].input.text), /Writer/); + assert.match(String(fake.runs[1].input.text), /Reviewer/); + await dispose(); +}); + +test('a hosted turn publishes the reply in the chat it belongs to', async () => { + const agent = createFakeAgent({ + script: [{ type: 'message.committed', text: 'The Writer answers.' }], + text: 'The Writer answers.', + }); + const { bridge, sent, dispose } = await makeBridge({ agent, mode: 'hosted' }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'one' }); + + const answers = sent.filter( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'assistant', + ); + assert.equal(answers.length, 1); + assert.equal(answers[0].payload.message.text, 'The Writer answers.'); + assert.equal(answers[0].payload.message.chatId, 'bot:bot_b1'); + // The author is the Bot the turn was addressed to. + assert.equal(answers[0].payload.message.authorId, 'bot_b1'); + await dispose(); +}); + +test('a hosted turn raises a permission check the phone can answer while its run is waiting', async () => { + const permissionRaised = deferred(); + const decision = deferred(); + const agent = createFakeAgent({ + sessionId: 'permission-session', + script: [ + { type: 'turn.started' }, + { + type: 'permission.requested', + requestId: 'perm-1', + toolName: 'shell', + toolInput: '{"command":"ls"}', + reason: 'lists the working directory', + }, + ], + }); + const scriptedRun = agent.run.bind(agent); + const recordReply = agent.reply.bind(agent); + agent.run = async (input, options) => { + const result = await scriptedRun(input, options); + const signal = options?.signal; + let abort; + try { + const status = await new Promise(resolve => { + abort = () => resolve('aborted'); + if (signal?.aborted) abort(); + else signal?.addEventListener('abort', abort, { once: true }); + decision.promise.then(() => resolve('finished')); + permissionRaised.resolve(); + }); + options?.onEvent?.({ type: 'turn.finished', sessionId: result.sessionId, turnId: result.turnId, status }); + return { ...result, status }; + } finally { + signal?.removeEventListener('abort', abort); + } + }; + agent.reply = async input => { + assert.deepEqual(input, { sessionId: 'permission-session', requestId: 'perm-1', decision: 'allowOnce' }); + await recordReply(input); + decision.resolve(); + }; + const { bridge, sent, agent: fake, dispose } = await makeBridge({ agent, mode: 'hosted' }); + let settled = false; + const running = bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'look around' }).then(result => { + settled = true; + return result; + }); + try { + await within(permissionRaised.promise); + const asked = sent.find(frame => frame.type === 'evt.permission_requested'); + assert.ok(asked, 'the phone was told a permission check was raised'); + assert.equal(asked.payload.request.requestId, 'perm-1'); + assert.equal(asked.payload.request.chatId, 'bot:bot_b1'); + assert.equal(asked.payload.request.sessionId, 'permission-session'); + assert.equal(asked.payload.request.toolName, 'shell'); + assert.equal(bridge.pending.size, 1); + assert.equal(bridge.runner.activeRuns, 1); + assert.equal(settled, false); + assert.equal(sent.filter(frame => frame.type === 'evt.turn_finished').length, 0); + + await bridge.dispatch('op.respond_permission', { requestId: 'perm-1', decision: 'allowOnce' }); + await within(running); + assert.deepEqual(fake.replies, [{ sessionId: 'permission-session', requestId: 'perm-1', decision: 'allowOnce' }]); + assert.equal(settled, true); + assert.equal(bridge.pending.size, 0); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(sent.filter(frame => frame.type === 'evt.turn_finished' && frame.payload.status === 'finished').length, 1); + assert.equal(sent.filter(frame => frame.type === 'evt.permission_resolved').length, 1); + await assert.rejects( + () => bridge.dispatch('op.respond_permission', { requestId: 'perm-1', decision: 'allowOnce' }), + { code: 'REQUEST_NOT_FOUND' }, + ); + assert.equal(fake.replies.length, 1); + } finally { + bridge.stop(); + await within(running).catch(() => {}); + await dispose(); + } +}); + +test('a hosted questionnaire survives its finished turn and preserves every answer step', async () => { + const { agent, pendingQuestion } = waitingQuestionnaireAgent(); + const { bridge, sent, agent: fake, dispose } = await makeBridge({ agent, mode: 'hosted' }); + try { + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'which one?' }); + const asked = sent.find(frame => frame.type === 'evt.question_requested'); + assert.ok(asked); + assert.equal(asked.payload.request.requestId, 'q-1'); + assert.equal(asked.payload.request.chatId, 'bot:bot_b1'); + assert.equal(asked.payload.request.sessionId, 'question-session'); + assert.equal(asked.payload.request.mode, 'questionnaire'); + assert.equal(asked.payload.request.title, pendingQuestion.request.title); + assert.deepEqual(asked.payload.request.steps, pendingQuestion.request.steps); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(bridge.pending.size, 1); + assert.equal(sent.filter(frame => frame.type === 'evt.turn_finished' && frame.payload.status === 'finished').length, 1); + assert.equal(sent.filter(frame => frame.type === 'evt.question_answered').length, 0); + + const answers = [ + { stepId: 's1', selectedOptionIds: ['a'], selectedOther: false }, + { stepId: 's2', selectedOptionIds: [], selectedOther: true, otherText: 'Use footnotes' }, + { stepId: 's3', selectedOptionIds: [], selectedOther: false, skipped: true }, + ]; + await bridge.dispatch('op.answer_question', { requestId: 'q-1', answers }); + // Options, free text, and an explicit skip all reach the Host, not only the + // first step or the label of its selected option. + assert.deepEqual(fake.answers, [{ sessionId: 'question-session', requestId: 'q-1', answers }]); + assert.equal(bridge.pending.size, 0); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(sent.filter(frame => frame.type === 'evt.question_answered' && frame.payload.requestId === 'q-1').length, 1); + await assert.rejects( + () => bridge.dispatch('op.answer_question', { requestId: 'q-1', answers }), + { code: 'REQUEST_NOT_FOUND' }, + ); + assert.equal(fake.answers.length, 1); + } finally { + await dispose(); + } +}); + +test('a phone that answers in the older shape preserves options, free text and skipped steps', async () => { + const { agent } = waitingQuestionnaireAgent(); + const { bridge, agent: fake, dispose } = await makeBridge({ agent, mode: 'hosted' }); + try { + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'which one?' }); + assert.equal(bridge.pending.size, 1); + await bridge.dispatch('op.answer_question', { + requestId: 'q-1', + answers: [ + { stepId: 's1', value: 'A', optionId: 'a' }, + { stepId: 's2', value: 'Use footnotes' }, + { stepId: 's3', skipped: true }, + ], + }); + assert.deepEqual(fake.answers, [{ + sessionId: 'question-session', requestId: 'q-1', answers: [ + { stepId: 's1', selectedOptionIds: ['a'], selectedOther: false }, + { stepId: 's2', selectedOptionIds: [], selectedOther: true, otherText: 'Use footnotes' }, + { stepId: 's3', selectedOptionIds: [], selectedOther: false, skipped: true }, + ], + }]); + assert.equal(bridge.pending.size, 0); + assert.equal(bridge.runner.activeRuns, 0); + } finally { + await dispose(); + } +}); + +test('a hosted turn publishes the tool activity it really saw', async () => { + const agent = createFakeAgent({ + script: [ + { type: 'tool.started', name: 'bash' }, + { type: 'tool.finished', name: 'bash', ok: true }, + ], + }); + const { bridge, sent, dispose } = await makeBridge({ agent, mode: 'hosted' }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'run it' }); + + assert.equal(sent.filter((frame) => frame.type === 'evt.tool_started').length, 1); + assert.equal(sent.filter((frame) => frame.type === 'evt.tool_finished').length, 1); + await dispose(); +}); + +test('a hosted turn keeps the session it ran in', async () => { + const agent = createFakeAgent({ sessionId: 'sess_writer' }); + const { bridge, dispose } = await makeBridge({ agent, mode: 'hosted' }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'one' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'two' }); + + assert.equal(bridge.runner.sessionFor('bot:bot_b1'), 'sess_writer'); + await dispose(); +}); + +// -- the team path ----------------------------------------------------------- + +test('a team turn reaches the Bot the chat names', async () => { + const { bridge, team, dispose } = await makeBridge(); + + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'team' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Draft the notes' }); + + const asked = teamCalls(team, 'message_bot'); + assert.equal(asked.length, 1); + // The Bot is addressed by its own id: that is what the team resolves. + assert.equal(asked[0].arguments.to, 'bot_b1'); + assert.equal(asked[0].arguments.message, 'Draft the notes'); + await dispose(); +}); + +test('a group turn reaches the group, and only the group', async () => { + const { bridge, team, agent: fake, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'group:grp_g1', text: 'Ship it' }); + + const posted = teamCalls(team, 'post_group'); + assert.equal(posted.length, 1); + assert.equal(posted[0].arguments.group, 'grp_g1'); + assert.equal(posted[0].arguments.message, 'Ship it'); + // A group is not a Bot, so no Bot is addressed, and the group is not run locally. + assert.equal(teamCalls(team, 'message_bot').length, 0); + assert.equal(fake.runs.length, 0); + await dispose(); +}); + +test('a mention is named the way the team reads it', async () => { + const { bridge, team, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { + chatId: 'group:grp_g1', + text: 'go', + mentions: ['bot_b2'], + }); + + const posted = teamCalls(team, 'post_group'); + // The team matches a mention against a member's name, so an id would mean nothing. + assert.equal(posted[0].arguments.message, '@Reviewer\ngo'); + await dispose(); +}); + +test('a team turn that the team cannot start is refused, not answered by the Host', async () => { + const { bridge, agent: fake, dispose } = await makeBridge(); + + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'team' }); + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_ghost', mode: 'team' }); + await assert.rejects( + () => bridge.dispatch('op.send_message', { chatId: 'bot:bot_ghost', text: 'anyone there?' }), + (error) => { + assert.equal(/** @type {{code?: string}} */ (error).code, 'TEAM_ERROR'); + return true; + }, + ); + // The bridge did not fall back to the general assistant to have something to say. + assert.equal(fake.runs.length, 0); + await dispose(); +}); + +test('a team turn that the team fails is reported as a failure', async () => { + const team = makeTeam({ + onTask(task) { + if (task.kind === 'bot') task.status = 'failed'; + }, + }); + const { bridge, sent, dispose } = await makeBridge({ team }); + + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'team' }); + await assert.rejects( + () => bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'do it' }), + (error) => { + assert.equal(/** @type {{code?: string}} */ (error).code, 'TURN_FAILED'); + return true; + }, + ); + + // A failed turn is finished, with an error, and no reply was made up. + const finished = sent.find((frame) => frame.type === 'evt.turn_finished'); + assert.equal(finished.payload.status, 'error'); + assert.equal( + sent.filter( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'assistant', + ).length, + 0, + ); + await dispose(); +}); + +test('a team turn can be stopped', async () => { + // A team that keeps the turn running, so the phone has something to stop. + const { bridge, sent, team, dispose } = await makeBridge({ team: makeTeam({ hold: true }) }); + + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'team' }); + // The turn is in flight while the phone learns its id from the event. + const running = bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'long one' }); + await new Promise((resolve) => setTimeout(resolve, 40)); + const started = sent.find((frame) => frame.type === 'evt.turn_started'); + assert.ok(started, 'the phone was told the turn started'); + + const stopped = await bridge.dispatch('op.abort_turn', { turnId: started.payload.turnId }); + assert.equal(stopped.aborted, true); + + // The team's work is not a Host run the bridge owns, so stopping it means telling + // the team to stop. + const cancelled = teamCalls(team, 'cancel_tasks'); + assert.equal(cancelled.length, 1); + assert.equal(cancelled[0].arguments.task_id, started.payload.turnId); + + // The turn ends as stopped, not as a failure: the phone is told the turn is over. + await running.catch(() => {}); + const finished = sent.find((frame) => frame.type === 'evt.turn_finished'); + assert.equal(finished.payload.status, 'aborted'); + await dispose(); +}); + +test('a team turn that is waiting for the user says where the answer is given', async () => { + const team = makeTeam({ + onTask(task) { + if (task.kind === 'bot') task.status = 'waiting_for_user'; + }, + }); + const { bridge, sent, dispose } = await makeBridge({ team }); + + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'team' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'which one?' }); + + // The turn is over until the user acts, and the phone is told what to do about it. + const notes = sent.filter( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'system', + ); + assert.equal(notes.length, 1); + assert.match(notes[0].payload.message.text, /MiniMax Bot/); + await dispose(); +}); + +test('the bridge invents nothing a run did not report', async () => { + const { bridge, sent, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'do it' }); + + // A run that reported nothing about tools or approvals produced no tool events and + // no approval requests. A phone that showed them would be showing something that + // never happened. + assert.equal(sent.filter((frame) => frame.type === 'evt.tool_started').length, 0); + assert.equal(sent.filter((frame) => frame.type === 'evt.permission_requested').length, 0); + assert.equal(sent.filter((frame) => frame.type === 'evt.question_requested').length, 0); + await dispose(); +}); + +test('a chat that belongs to neither a Bot nor a group is refused', async () => { + const { bridge, agent: fake, dispose } = await makeBridge(); + + await assert.rejects( + () => bridge.dispatch('op.send_message', { chatId: 'general', text: 'hello?' }), + (error) => { + assert.equal(/** @type {{code?: string}} */ (error).code, 'INVALID_ARGUMENTS'); + return true; + }, + ); + assert.equal(fake.runs.length, 0); + await dispose(); +}); + +test('the run mode is remembered, and the roster carries it', async () => { + const { bridge, dispose } = await makeBridge(); + + // A Bot's turn runs in the team unless the phone says otherwise, so the exchange + // is in the desktop Bot app's own conversation. + assert.equal(bridge.runModeFor('bot:bot_b1'), 'team'); + + const result = await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'hosted' }); + assert.equal(result.mode, 'hosted'); + assert.equal(bridge.runModeFor('bot:bot_b1'), 'hosted'); + // A group is always the team's, whatever a Bot is set to. + assert.equal(bridge.runModeFor('group:grp_g1'), 'team'); + + const team = await bridge.dispatch('op.list_team', {}); + const writer = /** @type {any} */ (team).bots.find((bot) => bot.botId === 'bot_b1'); + assert.equal(writer.runMode, 'hosted'); + // Another Bot keeps the default. + const reviewer = /** @type {any} */ (team).bots.find((bot) => bot.botId === 'bot_b2'); + assert.equal(reviewer.runMode, 'team'); + await dispose(); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/bounds.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/bounds.test.mjs new file mode 100644 index 0000000..e7bb273 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/bounds.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { createFakeTeam, TEAM_TOOL_DESCRIPTORS } from './fake-team.mjs'; + +/** + * The state a client can make the bridge hold. + * + * Every map the bridge keeps is keyed by something a phone chose: a chat id, a message + * id, an operation id. A phone that keeps inventing new ones makes the desktop's + * memory grow, and the desktop is the one that pays for it. These tests pin the + * ceilings. + */ + +/** + * @param {{agent?: boolean}} options + */ +async function makeBridge({ agent = false } = {}) { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-bounds-')); + const team = createFakeTeam({ + // A roster wide enough for a client that keeps inventing conversations. + bots: Array.from({ length: 80 }, (_, index) => ({ + bot_id: `bot_${index}`, + name: `Bot ${index}`, + role: '', + main: false, + model: 'default', + effort: 'default', + })), + resolveUnknown: true, + }); + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { + async list() { + return { tools: TEAM_TOOL_DESCRIPTORS, partial: false }; + }, + async call(name, args) { + return team.call(name, args); + }, + }, + }; + + if (agent) { + let counter = 0; + context.agent = { + async run() { + counter += 1; + return { sessionId: `sess-${counter}`, turnId: `turn-${counter}`, status: 'finished', text: 'Hello' }; + }, + async reply() {}, + async answer() { + return { sessionId: 'sess-1', status: 'finished' }; + }, + }; + } + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = () => true; + + await bridge.start(); + + return { + bridge, + async dispose() { + bridge.stop(); + await rm(dataDir, { recursive: true, force: true }); + }, + }; +} + +test('the bridge keeps a bounded number of reacted messages', async () => { + const { bridge, dispose } = await makeBridge(); + try { + for (let i = 0; i < 600; i += 1) { + bridge.react({ messageId: `m${i}`, emoji: '👍' }); + } + assert.equal(bridge.reactions.size, 500); + // The oldest reaction is the one that goes: it is the one least likely to be read. + assert.equal(bridge.reactions.has('m0'), false); + assert.equal(bridge.reactions.has('m599'), true); + } finally { + await dispose(); + } +}); + +test('a reaction may not carry an unbounded emoji', async () => { + const { bridge, dispose } = await makeBridge(); + try { + // The emoji is stored verbatim and broadcast to every device in the room. + assert.throws(() => bridge.react({ messageId: 'm1', emoji: 'x'.repeat(64) }), /emoji/i); + // A short one still works. + assert.equal(bridge.react({ messageId: 'm1', emoji: '👍' }).reaction.emoji, '👍'); + } finally { + await dispose(); + } +}); + +test('the bridge keeps a bounded number of chat histories', async () => { + const { bridge, dispose } = await makeBridge(); + try { + for (let i = 0; i < 80; i += 1) { + bridge.pushHistory(`chat-${i}`, { + messageId: `m${i}`, + chatId: `chat-${i}`, + role: 'user', + text: 'hi', + ts: i, + }); + } + assert.equal(bridge.history.size, 50); + assert.equal(bridge.history.has('chat-0'), false); + assert.equal(bridge.history.has('chat-79'), true); + } finally { + await dispose(); + } +}); + +test('the operation dedupe cache is bounded', async () => { + const { bridge, dispose } = await makeBridge(); + try { + for (let i = 0; i < 600; i += 1) { + await bridge.handleFrame({ + v: 1, + id: `f${i}`, + ts: Date.now(), + type: 'op.list_history', + source: 'app', + payload: { chatId: 'c' }, + opId: `op-${i}`, + }); + } + // The time to live alone does not bound it: the key is built from an operation id + // the client chose. + assert.equal(bridge.opCache.size, 500); + } finally { + await dispose(); + } +}); + +test('the runner keeps a bounded number of Bot sessions', async () => { + const { bridge, dispose } = await makeBridge({ agent: true }); + try { + // The team owns the conversations; the bridge only remembers which session + // answered last, and the chat id is chosen by the phone. + for (let i = 0; i < 80; i += 1) { + await bridge.runner.run({ chatId: `bot:bot_${i}`, text: 'hi' }); + } + assert.equal(bridge.runner.sessions.size, 50); + } finally { + await dispose(); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/bridge-config-scope.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/bridge-config-scope.test.mjs new file mode 100644 index 0000000..d2d8917 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/bridge-config-scope.test.mjs @@ -0,0 +1,128 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import { syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { loadConfig, saveConfig } from '../miniapp/node/config.mjs'; +import { start } from '../miniapp/node/server.mjs'; + +// The scratch root lives in the operating system's temporary directory, so the suite +// runs the same way in the workspace, in a published package and in an installed one. +const ROOT = join(tmpdir(), 'minimax-bot-bridge-tests', 'bridge-authority'); +const A = { relayUrl: 'https://relay-a.example', accountId: 'a', bridgeId: 'desktop', bridgeToken: 'OLD', bridgeName: 'Desk' }; +const B = { ...A, relayUrl: 'https://relay-b.example', accountId: 'b', bridgeToken: 'B' }; +const settle = () => new Promise(setImmediate); +function deferred() { + /** @type {(value?: any) => void} */ let resolve = () => {}; + const promise = new Promise(done => { resolve = done; }); return { promise, resolve }; +} +async function session(t) { + await fs.mkdir(ROOT, { recursive: true }); + const dataDir = await fs.mkdtemp(join(ROOT, 'http-')); await saveConfig(dataDir, A); + /** @type {Bridge | undefined} */ + let bridge; + let listener, starts = 0; + const realStart = Bridge.prototype.start; + t.mock.method(Bridge.prototype, 'start', async function () { bridge = this; starts += 1; return realStart.call(this); }); + t.mock.method(http, 'createServer', callback => { listener = callback; + return { once() {}, off() {}, listen(_port, _host, done) { done(); }, close() {} }; }); + syncBuiltinESMExports(); + const sockets = []; + const oldSocket = globalThis.WebSocket; + globalThis.WebSocket = /** @type {any} */ (class { + readyState = 0; + constructor(url) { this.url = url; sockets.push(this); } + addEventListener() {} close() {} + }); + const rotation = deferred(), requested = deferred(); + t.mock.method(globalThis, 'fetch', async url => { + if (String(url).endsWith('/bridge/token')) { requested.resolve(); return rotation.promise; } + return new Response('{}', { status: 404 }); + }); + const app = await start({ dataDir, logger: { info() {}, warn() {}, error() {}, debug() {} }, + listen: { host: '127.0.0.1', port: 54321 }, signal: new AbortController().signal, + tool: { list: async () => ({ tools: [] }) } }); + await settle(); assert.equal(starts, 1); assert.equal(sockets.length, 1); + assert.ok(bridge, 'the real start() must capture its live Bridge instance'); + t.after(() => { app.dispose(); globalThis.WebSocket = oldSocket; t.mock.restoreAll(); syncBuiltinESMExports(); }); + const tokens = []; bridge.onToken = token => { tokens.push(token); }; + const call = async (method, path, body = null, secret = undefined) => { + const done = deferred(); + const request = { method, url: path, headers: { host: '127.0.0.1:54321', 'content-type': 'application/json', 'x-bridge-secret': secret }, + async *[Symbol.asyncIterator]() { if (body !== null) yield Buffer.from(JSON.stringify(body)); } }; + const response = { status: 0, writeHead(status) { this.status = status; }, end(raw) { done.resolve({ status: this.status, body: JSON.parse(raw) }); } }; + listener(request, response); return done.promise; + }; + const secret = (await call('GET', '/api/session')).body.secret; + return { bridge, dataDir, tokens, sockets, requested: requested.promise, + complete: () => rotation.resolve(new Response(JSON.stringify({ token: 'NEW' }))), + post: body => call('POST', '/api/config', body, secret), rotate: () => call('POST', '/api/rotate-token', {}, secret) }; +} +/** @type {Array<[string, Partial]>} */ +const sameAuthoritySaves = [ + ['unchanged complete configuration', A], + ['name-only configuration', { bridgeName: 'Renamed' }], + ['equivalent WebSocket-scheme configuration', { relayUrl: 'wss://relay-a.example/' }], +]; +for (const [name, update] of sameAuthoritySaves) { + test(`real start and HTTP ${name} save preserve a valid same-authority rotation`, async t => { + const state = await session(t); + const epoch = state.bridge.relay.epoch; + const rotating = state.rotate(); await state.requested; + assert.equal((await state.post(update)).status, 200); + await state.bridge.setBotMode({ botId: 'bot', mode: 'hosted' }); + state.complete(); + assert.equal((await rotating).status, 200); + assert.equal(state.bridge.relay.epoch, epoch); + assert.equal(state.bridge.config.bridgeToken, 'NEW'); assert.equal(state.bridge.relay.token, 'NEW'); + const saved = await loadConfig(state.dataDir); + assert.equal(saved.bridgeToken, 'NEW'); assert.equal(saved.botModes.bot, 'hosted'); + assert.deepEqual(state.tokens, ['NEW']); + assert.equal(state.sockets.length, 1, 'cosmetic saves do not reconnect or promise an immediate remote rename'); + state.bridge.relay.start(); await settle(); + assert.equal(new URL(state.sockets.at(-1).url).searchParams.get('name'), update.bridgeName ?? A.bridgeName); + }); +} + +test('a queued unchanged token field cannot roll a completed rotation back to its revoked predecessor', async t => { + const state = await session(t); + const entered = deferred(), release = deferred(); + const write = fs.writeFile; let blocked = false; + t.mock.method(fs, 'writeFile', async (path, contents, options) => { + if (!blocked && String(path) === join(state.dataDir, 'bridge-config.json')) { + blocked = true; entered.resolve(); await release.promise; + } + return write(path, contents, options); + }); + syncBuiltinESMExports(); t.after(() => release.resolve()); + const rotating = state.rotate(); await state.requested; state.complete(); await entered.promise; + const saving = state.post({ ...A, bridgeName: 'Queued name' }); + await settle(); release.resolve(); + assert.equal((await rotating).status, 200); assert.equal((await saving).status, 200); + const saved = await loadConfig(state.dataDir); + assert.equal(saved.bridgeToken, 'NEW'); assert.equal(saved.bridgeName, 'Queued name'); +}); + +test('an explicit same-room credential replacement still fences an older pending rotation', async t => { + const state = await session(t); + const rotating = state.rotate(); await state.requested; + assert.equal((await state.post({ bridgeToken: 'EXPLICIT' })).status, 200); + state.complete(); assert.equal((await rotating).status, 502); + assert.equal(state.bridge.config.bridgeToken, 'EXPLICIT'); + assert.equal(state.bridge.relay.token, 'EXPLICIT'); + assert.equal((await loadConfig(state.dataDir)).bridgeToken, 'EXPLICIT'); + assert.deepEqual(state.tokens, []); +}); + +test('real HTTP A to B to A still invalidates the original rotation and preserves the final authority', async t => { + const state = await session(t); + const rotating = state.rotate(); await state.requested; + assert.equal((await state.post(B)).status, 200); assert.equal((await state.post(A)).status, 200); + state.complete(); + assert.equal((await rotating).status, 502); + assert.equal((await loadConfig(state.dataDir)).bridgeToken, 'OLD'); + assert.deepEqual(state.tokens, []); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/bridge-room-hardening.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/bridge-room-hardening.test.mjs new file mode 100644 index 0000000..edb78af --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/bridge-room-hardening.test.mjs @@ -0,0 +1,339 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, readFile, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { BotRouter } from '../miniapp/node/bot-router.mjs'; +import { AgentRunner, MAX_CONCURRENT_RUNS } from '../miniapp/node/agent.mjs'; +import { saveConfig } from '../miniapp/node/config.mjs'; +import { TEAM_TOOL_DESCRIPTORS, createFakeTeam } from './fake-team.mjs'; + +// The scratch root lives in the operating system's temporary directory, so the suite +// runs the same way in the workspace, in a published package and in an installed one. +const ROOT = join(tmpdir(), 'minimax-bot-bridge-tests', 'bridge-rooms'); +const A = { relayUrl: 'https://a.example', accountId: 'a', bridgeId: 'desktop', bridgeToken: 'A', bridgeName: 'Desk' }; +const B = { ...A, relayUrl: 'https://b.example', accountId: 'b', bridgeToken: 'B' }; +const settle = () => new Promise(setImmediate); +function deferred() { + /** @type {(value?: any) => void} */ let resolve = () => {}; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} +class Socket { + static instances = []; + readyState = 0; + sent = []; + handlers = new Map(); + constructor(url) { this.url = url; Socket.instances.push(this); } + addEventListener(type, callback) { this.handlers.set(type, callback); } + send(raw) { this.sent.push(JSON.parse(raw)); } + close() { this.readyState = 3; this.handlers.get('close')?.({}); } + open() { this.readyState = 1; this.handlers.get('open')?.({}); } +} +async function fixture(t, agent = {}) { + await mkdir(ROOT, { recursive: true }); + const root = await mkdtemp(join(ROOT, 'room-')); + const dataDir = join(root, 'minimax-bot-bridge'); + await saveConfig(dataDir, A); + Socket.instances = []; + const oldSocket = globalThis.WebSocket; + globalThis.WebSocket = /** @type {any} */ (Socket); + t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status: 404 })); + const team = createFakeTeam(); + const context = { dataDir, agent, logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { list: async () => ({ tools: TEAM_TOOL_DESCRIPTORS }), call: team.call } }; + const bridge = new Bridge(context, { config: { ...A } }); + await bridge.start(); await settle(); + Socket.instances.at(-1).open(); await settle(); + t.after(() => { bridge.stop(); globalThis.WebSocket = oldSocket; }); + const switchRoom = async (config) => { + await bridge.commitConfig(() => ({ ...config }), { reconfigure: true }); + await settle(); Socket.instances.at(-1).open(); await settle(); + return Socket.instances.at(-1); + }; + return { bridge, dataDir, root, switchRoom, team }; +} +function frame(type, payload = {}, opId = type) { return { type, payload, id: opId, opId, from: 'same-phone' }; } + +test('A operations and callbacks cannot emit results or approvals into B, and B cannot claim A requests', async t => { + const finish = deferred(), started = deferred(); + /** @type {{signal: AbortSignal, onEvent: (event: unknown) => void} | undefined} */ + let options; + let starts = 0; + const replies = []; + const { bridge, switchRoom } = await fixture(t, { + run: async (_input, callbacks) => { + options = callbacks; + starts += 1; + callbacks.onEvent({ type: 'turn.started', sessionId: 'A-session', turnId: 'A-turn' }); + callbacks.onEvent({ type: 'permission.requested', sessionId: 'A-child', turnId: 'A-child-turn', requestId: 'A-permission', toolName: 'shell', reason: 'A-private' }); + started.resolve(); await finish.promise; + callbacks.onEvent({ type: 'permission.requested', sessionId: 'A-session', turnId: 'A-turn', requestId: 'A-late', toolName: 'shell', reason: 'A-private' }); + callbacks.onEvent({ type: 'message.committed', sessionId: 'A-session', turnId: 'A-turn', text: 'A-private answer' }); + return { sessionId: 'A-session', turnId: 'A-turn', status: 'finished', text: 'A-private answer' }; + }, reply: async input => { replies.push(input); }, + }); + await bridge.setBotMode({ botId: 'bot_b1', mode: 'hosted' }); + const oldRunner = bridge.runner; + const running = bridge.handleFrame(frame('op.send_message', { chatId: 'bot:bot_b1', text: 'A-private work' }, 'A-op')); + await started.promise; + const queued = bridge.handleFrame(frame('op.send_message', { chatId: 'bot:bot_b1', text: 'A-private queued work' }, 'A-queued')); + const socketB = await switchRoom(B); + assert.ok(options); + assert.equal(options.signal.aborted, true); + assert.notEqual(bridge.runner, oldRunner); + assert.equal(bridge.pending.size, 0); + await bridge.handleFrame(frame('op.respond_permission', { requestId: 'A-permission', decision: 'allowOnce' }, 'B-answer')); + assert.deepEqual(replies, []); + assert.equal(socketB.sent.find(f => f.type === 'err' && f.payload.opId === 'B-answer')?.payload.code, 'REQUEST_NOT_FOUND'); + const returnedA = await switchRoom(A); + finish.resolve(); await Promise.all([running, queued]); + assert.equal(starts, 1, 'an old queued turn cannot start even after returning to the same room'); + bridge.replayPending(); await settle(); + assert.equal(returnedA.sent.some(f => f.payload?.opId === 'A-op' || f.payload?.opId === 'A-queued'), false); + assert.equal(JSON.stringify(socketB.sent).includes('A-private'), false); + assert.equal(socketB.sent.some(f => f.payload?.opId === 'A-op' || f.payload?.request?.requestId === 'A-late'), false); + assert.equal(bridge.runner.sessions.size, 0); + assert.equal(bridge.router.phoneSessions.size, 0); +}); + +test('room ledgers, batches, history, reactions and dedupe are partitioned, including late tool persistence and A to B to A', async t => { + const { bridge, switchRoom, dataDir } = await fixture(t); + bridge.pushHistory('bot:bot_b1', { messageId: 'A-message', chatId: 'bot:bot_b1', role: 'assistant', text: 'A-private', ts: 1 }); + bridge.react({ messageId: 'A-message', emoji: 'yes' }); + bridge.ledger.recordPermission({ requestId: 'A-permission', toolName: 'shell', deviceId: 'phone', decision: 'deny' }); + bridge.ledger.rememberBatch('A-batch'); await bridge.ledger.persist(); + const ledgerA = bridge.ledger; + const ready = deferred(), finish = deferred(); let calls = 0; + const originalCall = bridge.tools.call.bind(bridge.tools); + bridge.tools.call = async (name, payload) => { + if (name !== 'create_schedule') return originalCall(name, payload); + calls += 1; ready.resolve(); await finish.promise; + return { ok: true, structuredContent: { private: 'A-private result', batch_id: 'A-late-batch' } }; + }; + const running = bridge.handleFrame(frame('op.create_schedule', {}, 'shared-key')); + await ready.promise; + const socketB = await switchRoom(B); + assert.notEqual(bridge.ledger, ledgerA); + assert.equal(bridge.ledger.report().totals.tools, 0); + assert.deepEqual(bridge.ledger.batches(), []); + assert.equal(bridge.history.size, 0); assert.equal(bridge.reactions.size, 0); assert.equal(bridge.opCache.size, 0); + finish.resolve(); await running; + assert.equal(bridge.ledger.report().totals.tools, 0); + assert.equal(JSON.stringify(socketB.sent).includes('A-private'), false); + bridge.tools.call = async (name, payload) => { + if (name !== 'create_schedule') return originalCall(name, payload); + calls += 1; return { ok: true, structuredContent: { id: 'B-result' } }; + }; + await bridge.handleFrame(frame('op.create_schedule', {}, 'shared-key')); + assert.equal(calls, 2, 'the same device/key in B must not reuse A results'); + assert.equal(bridge.ledger.report().totals.tools, 1); + await switchRoom(A); + assert.ok(bridge.ledger.entries.some(entry => entry.requestId === 'A-permission')); + assert.ok(bridge.ledger.batches().includes('A-late-batch')); + assert.equal(bridge.pending.size, 0); + assert.equal(bridge.opCache.size, 0); + await bridge.ledger.persist(); + bridge.stop(); + const restarted = new Bridge({ ...bridge.context, dataDir }, { config: A }); + await restarted.ledger.init(); + assert.ok(restarted.ledger.entries.some(entry => entry.requestId === 'A-permission')); + restarted.stop(); +}); + +test('late A persistence after A to B to A cannot overwrite records newly admitted in A', async t => { + const { bridge, switchRoom, dataDir } = await fixture(t); + const ready = deferred(), finish = deferred(); + const originalCall = bridge.tools.call.bind(bridge.tools); + bridge.tools.call = async (name, payload) => { + if (name !== 'create_schedule') return originalCall(name, payload); + ready.resolve(); await finish.promise; + return { ok: true, structuredContent: { id: 'old-A-private result' } }; + }; + const old = bridge.handleFrame(frame('op.create_schedule', {}, 'old-A')); + await ready.promise; await switchRoom(B); const returnedA = await switchRoom(A); + bridge.ledger.recordPermission({ requestId: 'new-A-record', toolName: 'shell', deviceId: 'phone', decision: 'deny' }); + await bridge.ledger.persist(); + finish.resolve(); await old; + assert.equal(returnedA.sent.some(f => f.payload?.opId === 'old-A'), false); + const reopened = new Bridge({ ...bridge.context, dataDir }, { config: A }); + await reopened.ledger.init(); + assert.ok(reopened.ledger.entries.some(entry => entry.requestId === 'new-A-record')); + assert.equal(reopened.ledger.report().totals.tools, 1); + reopened.stop(); +}); + +test('explicit team idempotency keys stay stable within a room without adopting another room\'s cached receipt', async t => { + const { bridge, switchRoom } = await fixture(t); + bridge.tools.catalog.push({ name: 'mcp__minimax-bot__dispatch_tasks', server: 'minimax-bot', tool: 'dispatch_tasks' }); + const call = bridge.context.tool.call; + const receipts = new Map(); const requests = []; + bridge.context.tool.call = async (name, args) => { + if (!name.endsWith('__dispatch_tasks')) return call(name, args); + requests.push(args.request_id); + assert.match(args.request_id, /^[\w.-]{1,128}$/); + // The official dispatcher keys its retained receipts by caller + request_id, + // not relay room. Keep that actual shared-Desktop contract in this control. + if (!receipts.has(args.request_id)) receipts.set(args.request_id, { + batch_id: `batch-${receipts.size + 1}`, request_id: args.request_id, + tasks: [{ task_id: `task-${receipts.size + 1}`, status: 'done', bot_id: 'bot_b1' }], + }); + return { kind: 'mcp', structuredContent: { value: receipts.get(args.request_id) } }; + }; + const payload = { botIds: ['bot_b1'], prompt: 'same work', requestId: 'same-client-key' }; + const first = await bridge.dispatch('op.dispatch', payload); + const retry = await bridge.dispatch('op.dispatch', payload); + assert.equal(first.batchId, retry.batchId); + await switchRoom(B); + const other = await bridge.dispatch('op.dispatch', payload); + assert.notEqual(other.batchId, first.batchId, 'B cannot adopt the globally cached A receipt'); + await switchRoom(A); + const again = await bridge.dispatch('op.dispatch', payload); + assert.equal(again.batchId, first.batchId); + assert.equal(requests[0], requests[1]); assert.equal(requests[0], requests[3]); + assert.notEqual(requests[0], requests[2]); +}); + +test('token-only and cosmetic saves keep valid same-room work, pending claims, session maps and caches', async t => { + const { bridge } = await fixture(t); + const original = { runner: bridge.runner, ledger: bridge.ledger, epoch: bridge.lifecycleEpoch, relayEpoch: bridge.relay.epoch }; + bridge.pending.add('permission', { requestId: 'same-room' }, 'same-session'); + bridge.runner.rememberSession('bot:bot_b1', 'same-session'); + bridge.opCache.set('same-key', { promise: Promise.resolve({ ok: true, value: 'same-room' }), expiresAt: Date.now() + 10000 }); + await bridge.commitConfig(current => ({ ...current, bridgeName: 'Renamed' }), { reconfigure: true }); + assert.equal(bridge.relay.epoch, original.relayEpoch, 'renaming must not reset the authority connection'); + await bridge.commitConfig(current => ({ ...current, bridgeToken: 'A-renewed' }), { reconfigure: true }); + assert.equal(bridge.runner, original.runner); assert.equal(bridge.ledger, original.ledger); + assert.equal(bridge.lifecycleEpoch, original.epoch); + assert.ok(bridge.pending.get('same-room')); assert.ok(bridge.opCache.has('same-key')); + assert.equal(bridge.runner.sessionFor('bot:bot_b1'), 'same-session'); +}); + +test('simultaneous first hosted messages in one chat reuse one session within the declared four-run Host cap', async () => { + const first = deferred(), finish = deferred(); const calls = []; let active = 0; + const router = new BotRouter({ agent: { run: async (input, { onEvent }) => { + calls.push(input); active += 1; assert.ok(active <= 4); + const sessionId = input.sessionId ?? `session-${calls.length}`; + const turnId = `turn-${calls.length}`; + onEvent?.({ type: 'turn.started', sessionId, turnId }); + if (calls.length === 1) { first.resolve(); await finish.promise; } + active -= 1; + return { sessionId, turnId, status: 'finished' }; + } } }, { roster: async () => ({ bots: [{ botId: 'b', name: 'Bot' }] }) }); + const one = router.hosted({ chatId: 'bot:b', botId: 'b', text: 'first' }); + await first.promise; + const two = router.hosted({ chatId: 'bot:b', botId: 'b', text: 'second' }); + try { + await settle(); assert.equal(calls.length, 1, 'the second blank-chat run must wait for session establishment'); + } finally { finish.resolve(); await Promise.all([one, two]); } + assert.equal(calls[0].sessionId, undefined); assert.equal(calls[1].sessionId, 'session-1'); + assert.equal(router.phoneSessions.get('bot:b'), 'session-1'); + assert.equal(router.hostedQueues.size, 0); +}); + +test('different hosted chats may run in parallel while per-chat waiting work is bounded and cannot start after stop', async () => { + const finish = deferred(); const calls = []; + const router = new BotRouter({ agent: { run: async input => { + calls.push(input); await finish.promise; + return { sessionId: input.sessionId ?? `session-${calls.length}`, status: 'finished' }; + } } }, { roster: async () => ({ bots: [{ botId: 'b', name: 'Bot' }, { botId: 'other', name: 'Other' }] }) }); + const runner = new AgentRunner(router.context, { router }); + const work = [runner.run({ chatId: 'bot:b', text: 'first' }), runner.run({ chatId: 'bot:other', text: 'parallel' })]; + await settle(); assert.equal(calls.length, 2); + for (let i = 2; i < MAX_CONCURRENT_RUNS; i += 1) work.push(runner.run({ chatId: 'bot:b', text: `queued ${i}` })); + await assert.rejects(runner.run({ chatId: 'bot:b', text: 'overflow' }), { code: 'BUSY' }); + runner.stop(); finish.resolve(); await Promise.allSettled(work); + assert.equal(calls.length, 2, 'stale queued runs must never call Host'); + assert.equal(router.hostedQueues.size, 0); assert.equal(runner.activeRuns, 0); +}); + +test('configured paired rooms read existing Desktop messages and activity without bridge admission records', async t => { + const { bridge, root, switchRoom } = await fixture(t); + await mkdir(join(root, 'minimax-bot', 'messages'), { recursive: true }); + const path = join(root, 'minimax-bot', 'messages', 'bot_bot_b1.json'); + const transcript = JSON.stringify([ + { id: 'desktop-user', chatId: 'bot:bot_b1', role: 'user', text: 'Existing Desktop question', ts: 1 }, + { id: 'desktop-reply', chatId: 'bot:bot_b1', role: 'bot', taskId: 'desktop-task', text: 'Existing Desktop answer', ts: 2, + parts: [{ type: 'tool', name: 'read', state: 'ok' }, { type: 'text', text: 'Existing Desktop answer' }] }, + ]); + await writeFile(path, transcript, 'utf8'); + assert.equal(bridge.ledger.historyRefs.length, 0); + const first = await bridge.historyFor('bot:bot_b1'); + assert.deepEqual(first.messages.map(message => message.messageId), ['desktop-user', 'desktop-reply']); + assert.equal(first.messages[0].origin, 'desktop'); + assert.equal(first.activity.length, 1); + assert.equal(first.activity[0].toolName, 'read'); + assert.equal(first.activity[0].turnId, 'desktop-task'); + + bridge.ledger.rememberTurn({ chatId: 'bot:bot_b1', taskId: 'desktop-task' }); + const readback = await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' }); + assert.deepEqual(readback.messages, first.messages, 'registering a task must not hide the Desktop user message'); + assert.deepEqual(readback.activity, first.activity); + bridge.pushHistory('bot:bot_b1', { messageId: 'private-hosted', chatId: 'bot:bot_b1', role: 'assistant', text: 'A-private hosted reply', ts: 3 }); + assert.equal((await bridge.historyFor('bot:bot_b1')).messages.length, 3); + await switchRoom(B); + const nextRoom = await bridge.historyFor('bot:bot_b1'); + assert.deepEqual(nextRoom.messages, first.messages, 'pairing authorizes the same official Desktop history'); + assert.deepEqual(nextRoom.activity, first.activity); + assert.equal(nextRoom.messages.some(message => message.messageId === 'private-hosted'), false); + assert.equal(await readFile(path, 'utf8'), transcript, 'Desktop history remains read-only'); +}); + +test('configured team history uses the official Desktop user record once, without a cached optimistic duplicate', async t => { + const { bridge, root } = await fixture(t); + const text = 'The current phone message'; + await bridge.sendMessage({ chatId: 'bot:bot_b1', text }); + await mkdir(join(root, 'minimax-bot', 'messages'), { recursive: true }); + await writeFile(join(root, 'minimax-bot', 'messages', 'bot_bot_b1.json'), JSON.stringify([ + { id: 'desktop-incoming', chatId: 'bot:bot_b1', role: 'relay', text, ts: 1 }, + ]), 'utf8'); + const history = await bridge.historyFor('bot:bot_b1'); + assert.deepEqual(history.messages.filter(message => message.text === text).map(message => message.messageId), ['desktop-incoming']); +}); + +test('explicit Desktop task selectors reach Host authorization while automatic known-batch reconstruction stays room-scoped', async t => { + const { bridge, team, switchRoom } = await fixture(t); + team.seedTask({ task_id: 'desktop-one', batch_id: 'desktop-batch-one', status: 'running' }); + team.seedTask({ task_id: 'desktop-two', batch_id: 'desktop-batch-two', status: 'running' }); + const originalCall = bridge.context.tool.call; + const calls = []; + bridge.context.tool.call = async (name, args) => { calls.push({ name, args }); return originalCall(name, args); }; + assert.deepEqual((await bridge.dispatch('op.list_tasks', {})).tasks, []); + assert.equal(calls.filter(call => call.name.endsWith('__get_tasks')).length, 0); + + const one = await bridge.dispatch('op.list_tasks', { taskId: 'desktop-one' }); + assert.equal(one.tasks[0].taskId, 'desktop-one'); + const two = await bridge.dispatch('op.list_tasks', { batchId: 'desktop-batch-two' }); + assert.equal(two.tasks[0].taskId, 'desktop-two'); + const cancelledOne = await bridge.dispatch('op.cancel_task', { taskId: 'desktop-one' }); + assert.equal(cancelledOne.tasks[0].state, 'cancelled'); + assert.ok(calls.some(call => call.name.endsWith('__cancel_tasks') && call.args.task_id === 'desktop-one')); + assert.ok((await bridge.dispatch('op.list_tasks', {})).tasks.length > 0); + + await switchRoom(B); + const before = calls.filter(call => call.name.endsWith('__get_tasks')).length; + assert.deepEqual((await bridge.dispatch('op.list_tasks', {})).tasks, []); + assert.equal(calls.filter(call => call.name.endsWith('__get_tasks')).length, before); + assert.equal((await bridge.dispatch('op.list_tasks', { taskId: 'desktop-two' })).tasks[0].taskId, 'desktop-two'); + const cancelledTwo = await bridge.dispatch('op.cancel_task', { batchId: 'desktop-batch-two' }); + assert.equal(cancelledTwo.tasks[0].state, 'cancelled'); + assert.ok(calls.some(call => call.name.endsWith('__cancel_tasks') && call.args.batch_id === 'desktop-batch-two')); + await assert.rejects(bridge.dispatch('op.cancel_task', { taskId: 'missing-desktop-task' }), { + code: 'TOOL_REPORTED_ERROR', message: 'no such task', + }); + assert.ok(calls.some(call => call.name.endsWith('__cancel_tasks') && call.args.task_id === 'missing-desktop-task')); +}); + +test('unattributed legacy bridge audit records remain private and are never assigned to a newly configured room', async t => { + const { bridge, dataDir, switchRoom } = await fixture(t); + const legacy = JSON.stringify({ entries: [{ kind: 'permission', requestId: 'legacy-private' }], batchIds: ['legacy-batch'] }); + await writeFile(join(dataDir, 'ledger.json'), legacy, 'utf8'); + await switchRoom(B); + assert.equal(bridge.ledger.entries.some(entry => entry.requestId === 'legacy-private'), false); + assert.deepEqual(bridge.ledger.batches(), []); + assert.equal(await readFile(join(dataDir, 'ledger.json'), 'utf8'), legacy); + await switchRoom(A); + assert.equal(bridge.ledger.entries.some(entry => entry.requestId === 'legacy-private'), false); + assert.equal(await readFile(join(dataDir, 'ledger.json'), 'utf8'), legacy); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/bridge.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/bridge.test.mjs new file mode 100644 index 0000000..8c796d7 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/bridge.test.mjs @@ -0,0 +1,1207 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { createFakeTeam } from './fake-team.mjs'; + +/** + * `Bridge.dispatch` returns whatever the matching operation produces, so the inferred type + * is the union of every branch. Each call site narrows it to the shape it asserts on. + * + * @typedef {{bots: any[], groups: any[], diagnostics: any}} TeamResult + * @typedef {{messageId: string, turnId?: string, sessionId: string}} SendResult + * @typedef {{messages: any[]}} HistoryResult + * @typedef {{buckets: any[], byBot: any[], totals: {turns: number, chars: number, tools: number, durationMs: number, errors: number}}} UsageResult + */ + +/** + * The team tools the bridge talks to. + * + * The schemas are the ones the real `@minimax-bot` team tools advertise, copied from + * the tool catalogue rather than invented: the whole point of mapping an operation + * payload onto the live schema is that the two agree. + */ +const TEAM_TOOLS = [ + { name: 'mcp__minimax-bot__list_team', server: 'minimax-bot', tool: 'list_team' }, + { + name: 'mcp__minimax-bot__message_bot', + server: 'minimax-bot', + tool: 'message_bot', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + request_id: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,128}$' }, + to: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['to', 'message'], + additionalProperties: false, + }, + }, + { + name: 'mcp__minimax-bot__post_group', + server: 'minimax-bot', + tool: 'post_group', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + request_id: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,128}$' }, + group: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['group', 'message'], + additionalProperties: false, + }, + }, + { + name: 'mcp__minimax-bot__dispatch_tasks', + server: 'minimax-bot', + tool: 'dispatch_tasks', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + request_id: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,128}$' }, + tasks: { + type: 'array', + minItems: 1, + maxItems: 32, + items: { + type: 'object', + additionalProperties: false, + required: ['to', 'message'], + properties: { + to: { type: 'string' }, + message: { type: 'string', minLength: 1, maxLength: 20000 }, + title: { type: 'string', maxLength: 80 }, + }, + }, + }, + }, + required: ['request_id', 'tasks'], + additionalProperties: false, + }, + }, + { + name: 'mcp__minimax-bot__get_tasks', + server: 'minimax-bot', + tool: 'get_tasks', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + task_id: { type: 'string' }, + batch_id: { type: 'string' }, + wait_ms: { type: 'integer', minimum: 0, maximum: 20000 }, + result_offset: { type: 'integer', minimum: 0 }, + }, + required: [], + additionalProperties: false, + oneOf: [{ required: ['task_id'] }, { required: ['batch_id'] }], + }, + }, + { + name: 'mcp__minimax-bot__cancel_tasks', + server: 'minimax-bot', + tool: 'cancel_tasks', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + task_id: { type: 'string' }, + batch_id: { type: 'string' }, + }, + required: [], + additionalProperties: false, + oneOf: [{ required: ['task_id'] }, { required: ['batch_id'] }], + }, + }, + { + name: 'mcp__minimax-bot__remember', + server: 'minimax-bot', + tool: 'remember', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + text: { type: 'string', minLength: 1, maxLength: 1200 }, + key: { type: 'string', pattern: '^[a-z0-9][a-z0-9._-]{0,63}$' }, + scope: { type: 'string', enum: ['self', 'team', 'bot'] }, + target_bot: { type: 'string' }, + replace_id: { type: 'string', pattern: '^[a-f0-9]{8}$' }, + expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + pinned: { type: 'boolean' }, + source: { type: 'string' }, + }, + required: ['text'], + additionalProperties: false, + anyOf: [{ required: ['key'] }, { required: ['replace_id'] }], + }, + }, + { name: 'mcp__minimax-bot__list_helpers', server: 'minimax-bot', tool: 'list_helpers' }, + { name: 'mcp__minimax-bot__list_schedules', server: 'minimax-bot', tool: 'list_schedules' }, +]; + +/** + * One event the fake desktop emits during a turn. + * + * @typedef {{type: string, name?: string, ok?: boolean, text?: string, status?: string}} AgentEvent + */ + +/** The events the fake desktop emits during a turn, in the order it emits them. */ +/** @type {AgentEvent[]} */ +const DEFAULT_AGENT_SCRIPT = [ + { type: 'turn.started' }, + { type: 'tool.started', name: 'bash' }, + { type: 'tool.finished', name: 'bash', ok: true }, + { type: 'text.delta', text: 'Hello' }, + { type: 'message.committed', text: 'Hello' }, + { type: 'turn.finished', status: 'finished', text: 'Hello' }, +]; + +/** + * A one pixel PNG, used as the artwork a look points at. + * + * The bytes are the point, not the picture: what travels to the phone has to be the + * desktop's own bytes. + */ +const TINY_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=', + 'base64', +); + +/** + * Writes the desktop's own Bot data into a test root. + * + * The runtime keeps one directory per Mini App, so the Bot app's data sits next to the + * bridge's, and the artwork sits in the runtime's cache of official plugins. The tests + * lay the desktop out the same way, so the code that reads it reads a real layout. + * + * @param {string} root + * @param {{bots?: any[], groups?: any[], skins?: Record, sessions?: Record, messages?: Record}} desktop + */ +async function writeDesktop(root, desktop) { + const botDir = join(root, 'plugin-data', 'liveboards', 'minimax-bot'); + await mkdir(botDir, { recursive: true }); + await writeFile( + join(botDir, 'state.json'), + JSON.stringify({ + bots: desktop.bots ?? [], + groups: desktop.groups ?? [], + sessions: desktop.sessions ?? {}, + }), + 'utf8', + ); + + // The desktop keeps each conversation in a file of its own, named after the + // conversation. + for (const [chatId, messages] of Object.entries(desktop.messages ?? {})) { + const file = join(botDir, 'messages', `${chatId.replace(/[^a-zA-Z0-9_-]/g, '_')}.json`); + await mkdir(join(botDir, 'messages'), { recursive: true }); + await writeFile(file, JSON.stringify(messages), 'utf8'); + } + + const plugin = join(root, 'plugin-cache', 'official', 'sha256-tree-v1-test'); + await mkdir(join(plugin, 'miniapp', 'client', 'skins'), { recursive: true }); + await writeFile( + join(plugin, 'miniapp', 'miniapp.json'), + JSON.stringify({ + schemaVersion: 1, + mcpEndpoints: [{ server: 'minimax-bot', path: '/mcp/minimax-bot' }], + surface: { path: '/bot' }, + }), + 'utf8', + ); + for (const [skin, bytes] of Object.entries(desktop.skins ?? {})) { + await writeFile(join(plugin, 'miniapp', 'client', 'skins', `${skin}.png`), bytes); + } +} + +/** + * @param {{tools?: typeof TEAM_TOOLS, agent?: boolean, agentScript?: AgentEvent[], desktop?: {bots?: any[], groups?: any[], skins?: Record, sessions?: Record, messages?: Record}}} options + */ +async function makeBridge({ + tools = TEAM_TOOLS, + agent = true, + agentScript = DEFAULT_AGENT_SCRIPT, + desktop, +} = {}) { + // The runtime keeps one directory per Mini App, so the bridge's data and the Bot + // app's data are siblings under one root. + const root = await mkdtemp(join(tmpdir(), 'bridge-test-')); + const dataDir = join(root, 'plugin-data', 'liveboards', 'minimax-bot-bridge'); + await mkdir(dataDir, { recursive: true }); + const sent = []; + const agentRuns = []; + const agentReplies = []; + + if (desktop) await writeDesktop(root, desktop); + + // The team answers the way the real one does: a durable receipt when a turn is + // admitted, and the settled result when it is asked for. + const team = createFakeTeam({ + bots: [ + { + bot_id: 'bot_b1', + name: 'Writer', + role: 'Long-form drafting', + main: false, + model: 'minimax/MiniMax-M3.1-Flash', + effort: 'default', + }, + ], + groups: [ + { + group_id: 'grp_g1', + name: 'Team', + notice: 'The whole team.', + admin: 'Writer', + members: ['Writer'], + reply_mode: 'admin', + }, + ], + replies: { bot_b1: 'Hello' }, + }); + const toolCalls = team.calls; + + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { + async list() { + return { tools, partial: false }; + }, + async call(name, args) { + return team.call(name, args); + }, + }, + }; + + if (agent) { + context.agent = { + async run(input, options) { + agentRuns.push(input); + for (const event of agentScript) { + options?.onEvent?.({ sessionId: 'sess-1', turnId: 'turn-1', ...event }); + // The tool takes a moment, so the duration the bridge measures is a real + // measurement rather than zero. + if (event.type === 'tool.started') { + await new Promise((resolve) => setTimeout(resolve, 12)); + } + } + return { sessionId: 'sess-1', turnId: 'turn-1', status: 'finished', text: 'Hello' }; + }, + async reply(input) { + agentReplies.push(input); + }, + async answer() { + return { sessionId: 'sess-1', status: 'finished' }; + }, + }; + } + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = (frame) => { + sent.push(frame); + return true; + }; + + await bridge.start(); + + return { + bridge, + sent, + toolCalls, + team, + agentRuns, + agentReplies, + async dispose() { + bridge.stop(); + await bridge.ledger.writeQueue; + await rm(root, { recursive: true, force: true }); + }, + }; +} + +test('the bridge links the team on start', async () => { + const { bridge, dispose } = await makeBridge(); + assert.equal(bridge.tools.available, true); + assert.equal(bridge.tools.catalog.length, TEAM_TOOLS.length); + assert.equal(bridge.diagnostics().teamToolsAvailable, true); + assert.equal(bridge.diagnostics().agentAvailable, true); + await dispose(); +}); + +test('list_team returns the roster and the diagnostics', async () => { + const { bridge, dispose } = await makeBridge(); + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + + assert.equal(result.bots.length, 1); + assert.equal(result.bots[0].name, 'Writer'); + assert.equal(result.groups.length, 1); + assert.equal(result.diagnostics.toolCount, TEAM_TOOLS.length); + await dispose(); +}); + +test('the roster is mapped into the shape the protocol speaks', async () => { + const { bridge, dispose } = await makeBridge(); + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + + // The team tool answers in its own shape; the phone must only ever see the + // protocol's shape, whatever the tool said. + assert.equal(result.bots[0].botId, 'bot_b1'); + assert.equal(result.bots[0].isMain, false); + assert.equal(result.bots[0].role, 'Long-form drafting'); + assert.equal(result.bots[0].model, 'minimax/MiniMax-M3.1-Flash'); + + assert.equal(result.groups[0].groupId, 'grp_g1'); + assert.equal(result.groups[0].notice, 'The whole team.'); + assert.equal(result.groups[0].replyMode, 'admin'); + // The tool names the admin and the members; the protocol carries ids. + assert.equal(result.groups[0].admin, 'bot_b1'); + assert.deepEqual(result.groups[0].memberIds, ['bot_b1']); + await dispose(); +}); + +test('a main Bot is marked as the main one', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.tools.call = async () => ({ + ok: true, + content: [ + { + type: 'text', + text: JSON.stringify({ + bots: [{ bot_id: 'bot_b1', name: 'Writer', main: true }], + groups: [], + }), + }, + ], + }); + + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + assert.equal(result.bots[0].isMain, true); + await dispose(); +}); + +test('a member the roster does not know is kept rather than dropped', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.tools.call = async () => ({ + ok: true, + content: [ + { + type: 'text', + text: JSON.stringify({ + bots: [{ bot_id: 'bot_b1', name: 'Writer' }], + groups: [{ group_id: 'grp_g1', name: 'Team', admin: 'Ghost', members: ['Writer', 'Ghost'] }], + }), + }, + ], + }); + + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + assert.deepEqual(result.groups[0].memberIds, ['bot_b1', 'Ghost']); + assert.equal(result.groups[0].admin, 'Ghost'); + await dispose(); +}); + +/** + * Puts a request into the bridge's hands, the way the Host does when it asks about a + * run the bridge started. + * + * A Bot's own approval lives in the Bot's session, which the team owns, so it is + * answered in MiniMax Bot and not from the phone. What the bridge holds is the Host's + * question about the bridge's own run. + * + * @param {any} bridge + * @param {'permission' | 'question'} kind + * @param {{requestId: string, toolName?: string, reason?: string, mode?: string, request?: any}} request + */ +function holdRequest(bridge, kind, request) { + bridge.runner.handleAgentEvent( + 'bot:bot_b1', + { + type: kind === 'permission' ? 'permission.requested' : 'question.requested', + sessionId: 'sess-1', + turnId: 'turn-1', + ...request, + }, + new AbortController(), + ); +} + +test('send_message runs a turn and commits the user message', async () => { + const { bridge, sent, toolCalls, dispose } = await makeBridge(); + const result = /** @type {SendResult} */ (await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi there' })); + + // The turn runs in the team, where the desktop Bot app keeps the exchange. + const asked = toolCalls.find((call) => call.name.endsWith('message_bot')); + assert.ok(asked, 'the team was asked to run the turn'); + assert.equal(asked.arguments.to, 'bot_b1'); + assert.ok(result.messageId); + + const committed = sent.filter( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'user', + ); + assert.equal(committed.length, 1); + assert.equal(committed[0].payload.message.text, 'Hi there'); + await dispose(); +}); + +test('a hosted turn runs on the desktop, in the Bot’s own session, with the Bot’s brief', async () => { + const { bridge, agentRuns, dispose } = await makeBridge(); + // The phone can ask for a Bot’s turn to run on the desktop instead, so that a + // permission check or a questionnaire the Bot raises can be answered from the phone. + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'hosted' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi there' }); + + assert.equal(agentRuns.length, 1); + assert.match(String(agentRuns[0].text), /Writer/); + assert.match(String(agentRuns[0].text), /Hi there/); + await dispose(); +}); + +test('a group message reaches the group, and a mention is named the way the team reads it', async () => { + const { bridge, toolCalls, dispose } = await makeBridge(); + await bridge.dispatch('op.send_message', { chatId: 'group:grp_g1', text: 'go', mentions: ['bot_b1'] }); + + // A group turn is posted to the group, not to a Bot. + const posted = toolCalls.find((call) => call.name.endsWith('post_group')); + assert.ok(posted, 'the team was asked to post to the group'); + assert.equal(posted.arguments.group, 'grp_g1'); + // The team reads a mention as a name, so an id would mean nothing to it. + assert.equal(posted.arguments.message, '@Writer\ngo'); + await dispose(); +}); + +test('a mention of a Bot the team does not know is dropped rather than sent as an id', async () => { + const { bridge, toolCalls, dispose } = await makeBridge(); + await bridge.dispatch('op.send_message', { chatId: 'group:grp_g1', text: 'go', mentions: ['ghost_bot'] }); + + const posted = toolCalls.find((call) => call.name.endsWith('post_group')); + assert.ok(posted); + assert.equal(posted.arguments.message, 'go'); + await dispose(); +}); + +test('send_message keeps the id the phone chose, so the bubble is replaced in place', async () => { + const { bridge, sent, dispose } = await makeBridge(); + const result = /** @type {SendResult} */ ( + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi there', clientMessageId: 'local_abc' }) + ); + + assert.equal(result.messageId, 'local_abc'); + const committed = sent.find( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'user', + ); + assert.equal(committed.payload.message.messageId, 'local_abc'); + await dispose(); +}); + +test('send_message ignores an id the phone may not choose', async () => { + const { bridge, sent, dispose } = await makeBridge(); + const result = /** @type {SendResult} */ ( + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi', clientMessageId: 'has space' }) + ); + + assert.notEqual(result.messageId, 'has space'); + assert.ok(result.messageId.startsWith('msg_')); + const committed = sent.find( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'user', + ); + assert.equal(committed.payload.message.messageId, result.messageId); + await dispose(); +}); + +test("the team's reply is published as a chunk and a commit, under the Bot that answered", async () => { + const { bridge, sent, dispose } = await makeBridge(); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi' }); + + const chunks = sent.filter((frame) => frame.type === 'evt.message_chunk'); + assert.equal(chunks.length, 1); + assert.equal(chunks[0].payload.text, 'Hello'); + + const assistant = sent.filter( + (frame) => frame.type === 'evt.message_committed' && frame.payload.message.role === 'assistant', + ); + assert.equal(assistant.length, 1); + // The author is the Bot the team ran, not a guess from the chat id. + assert.equal(assistant[0].payload.message.authorId, 'bot_b1'); + await dispose(); +}); + +test('the turn lifecycle is published', async () => { + const { bridge, sent, dispose } = await makeBridge(); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi' }); + + assert.ok(sent.some((frame) => frame.type === 'evt.turn_started')); + const finished = sent.find((frame) => frame.type === 'evt.turn_finished'); + assert.equal(finished.payload.status, 'finished'); + await dispose(); +}); + +test('the conversation survives a reload, with the team replies in it', async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { + messages: { + 'bot:bot_b1': [ + { id: 'msg_d1', chatId: 'bot:bot_b1', ts: 1, role: 'relay', text: 'Hi' }, + { id: 'msg_d2', chatId: 'bot:bot_b1', ts: 2, role: 'bot', botId: 'bot_b1', text: 'Hello' }, + ], + }, + }, + }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi' }); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + const roles = history.messages.map((/** @type {any} */ message) => message.role); + // A phone that opens the chat later must see the whole thread, not only what it sent. + assert.ok(roles.includes('user')); + assert.ok(roles.includes('assistant')); + // And it is the desktop's own thread: the reply the desktop holds, with the desktop's + // own id, so that a reaction on it means the same thing on both sides. + const reply = history.messages.find((/** @type {any} */ message) => message.role === 'assistant'); + assert.equal(reply.messageId, 'msg_d2'); + assert.equal(reply.authorId, 'bot_b1'); + await dispose(); +}); + +test('every tool the team runs is published with how long it took', async () => { + const { bridge, sent, dispose } = await makeBridge(); + const controller = new AbortController(); + + // A Host run the bridge owns reports its tool calls; the translation is what the + // phone sees. + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.started', name: 'bash', turnId: 'turn-1' }, controller); + // The call takes a moment, so the bridge has a real duration to report. + await new Promise((resolve) => setTimeout(resolve, 2)); + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.finished', name: 'bash', ok: true, turnId: 'turn-1' }, controller); + + const started = sent.find((frame) => frame.type === 'evt.tool_started'); + assert.ok(started); + assert.equal(started.payload.toolName, 'bash'); + assert.equal(started.payload.turnId, 'turn-1'); + assert.equal(typeof started.payload.callId, 'string'); + + const finished = sent.find((frame) => frame.type === 'evt.tool_finished'); + assert.ok(finished); + assert.equal(finished.payload.toolName, 'bash'); + assert.equal(finished.payload.ok, true); + // The finish belongs to the call it started. + assert.equal(finished.payload.callId, started.payload.callId); + // The host does not report a duration, so the bridge times the call itself. The + // call really took time, so the number has to be more than zero. + assert.equal(typeof finished.payload.usage.durationMs, 'number'); + assert.ok(finished.payload.usage.durationMs > 0); + await dispose(); +}); + +test('the bridge does not invent tool activity for a turn that reported none', async () => { + const { bridge, sent, dispose } = await makeBridge({ agentScript: [{ type: 'turn.started' }] }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi' }); + + // A run that reported nothing about tools produced no tool events. A phone that + // showed a tool call here would be showing one that never happened. + assert.equal(sent.filter((frame) => frame.type === 'evt.tool_started').length, 0); + assert.equal(sent.filter((frame) => frame.type === 'evt.tool_finished').length, 0); + await dispose(); +}); + +test('two calls of the same tool in one turn are told apart', async () => { + const { bridge, sent, dispose } = await makeBridge(); + const controller = new AbortController(); + + // A Host run the bridge owns reports its tool calls; the translation is what the + // phone sees. + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.started', name: 'bash', turnId: 'turn-1' }, controller); + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.started', name: 'bash', turnId: 'turn-1' }, controller); + // The calls take a moment, so the bridge has a real duration to report. + await new Promise((resolve) => setTimeout(resolve, 2)); + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.finished', name: 'bash', ok: true, turnId: 'turn-1' }, controller); + await new Promise((resolve) => setTimeout(resolve, 2)); + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.finished', name: 'bash', ok: true, turnId: 'turn-1' }, controller); + + const started = sent.filter((frame) => frame.type === 'evt.tool_started'); + const finished = sent.filter((frame) => frame.type === 'evt.tool_finished'); + assert.equal(started.length, 2); + assert.equal(finished.length, 2); + // The two calls have different ids, and each finish belongs to one of them. + assert.notEqual(started[0].payload.callId, started[1].payload.callId); + assert.equal(finished[0].payload.callId, started[0].payload.callId); + assert.equal(finished[1].payload.callId, started[1].payload.callId); + // Both durations were measured. + assert.ok(finished[0].payload.usage.durationMs > 0); + assert.ok(finished[1].payload.usage.durationMs > 0); + await dispose(); +}); + +test('a tool that never reported back leaves nothing behind', async () => { + const { bridge, sent, dispose } = await makeBridge(); + const controller = new AbortController(); + + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.started', name: 'bash', turnId: 'turn-1' }, controller); + // The turn ends without the tool reporting. + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'turn.finished', status: 'finished', turnId: 'turn-1' }, controller); + // A late finish for that tool has no start to measure. + bridge.runner.handleAgentEvent('bot:bot_b1', { type: 'tool.finished', name: 'bash', ok: true, turnId: 'turn-1' }, controller); + + const finished = sent.filter((frame) => frame.type === 'evt.tool_finished'); + assert.equal(finished.length, 1); + // The bookkeeping was cleared with the turn, so there is no duration to report. + assert.equal(finished[0].payload.usage, undefined); + await dispose(); +}); + +test('the same opId is executed once', async () => { + const { bridge, agentRuns, toolCalls, dispose } = await makeBridge(); + const frame = { v: 1, id: 'f1', ts: 1, type: 'op.send_message', source: 'app', payload: { chatId: 'bot:bot_b1', text: 'Hi' }, opId: 'op-1' }; + + await bridge.handleFrame(frame); + await bridge.handleFrame(frame); + + // The turn was run once, not twice. + assert.equal(toolCalls.filter((call) => call.name.endsWith('message_bot')).length, 1); + // And it ran in the team, not in the Host's assistant. + assert.equal(agentRuns.length, 0); + await dispose(); +}); + +test('an unknown operation is refused', async () => { + const { bridge, sent, dispose } = await makeBridge(); + await bridge.handleFrame({ v: 1, id: 'f1', ts: 1, type: 'op.nope', source: 'app', payload: {} }); + + const error = sent.find((frame) => frame.type === 'err'); + assert.ok(error); + assert.equal(error.payload.code, 'UNKNOWN_OP'); + await dispose(); +}); + +test('a task list without a selector covers the batches the bridge knows', async () => { + const { bridge, toolCalls, team, dispose } = await makeBridge(); + bridge.ledger.rememberBatch('batch_1'); + team.seedTask({ task_id: 't1', batch_id: 'batch_1', status: 'running' }); + + const result = /** @type {any} */ (await bridge.dispatch('op.list_tasks', {})); + + assert.equal(result.tasks.length, 1); + assert.equal(result.tasks[0].taskId, 't1'); + assert.deepEqual(result.batchIds, ['batch_1']); + // The team's task tool answers for one batch at a time, so the list is never + // complete, and the answer says so rather than pretending otherwise. + assert.equal(result.partial, true); + assert.equal(toolCalls[0].arguments.batch_id, 'batch_1'); + await dispose(); +}); + +test('a task list without a selector and without a batch is empty', async () => { + const { bridge, sent, dispose } = await makeBridge(); + await bridge.handleFrame({ v: 1, id: 'f1', ts: 1, type: 'op.list_tasks', source: 'app', payload: {} }); + + const ack = sent.find((frame) => frame.type === 'ack'); + assert.ok(ack); + assert.deepEqual(ack.payload.result.tasks, []); + assert.equal(ack.payload.result.partial, true); + await dispose(); +}); + +test('a task list that names a batch asks the team about that batch', async () => { + const { bridge, toolCalls, team, dispose } = await makeBridge(); + team.seedTask({ task_id: 't9', batch_id: 'batch_9', status: 'running' }); + const result = /** @type {any} */ (await bridge.dispatch('op.list_tasks', { batchId: 'batch_9' })); + + assert.equal(result.tool, 'get_tasks'); + assert.equal(toolCalls[0].arguments.batch_id, 'batch_9'); + await dispose(); +}); + +test('a dispatched batch is remembered, so the task list can be rebuilt', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.context.tool.call = async (name, args) => ({ + kind: 'mcp', + isError: false, + content: [], + structuredContent: { + batch_id: 'batch_7', + request_id: 'phone-1', + complete: false, + tasks: [{ task_id: 't7', kind: 'bot', bot_id: 'bot_b1', status: 'queued', created_at: 10 }], + }, + }); + + const result = /** @type {any} */ (await bridge.dispatch('op.dispatch', { botIds: ['bot_b1'], prompt: 'go' })); + + assert.equal(result.batchId, 'batch_7'); + assert.deepEqual(bridge.ledger.batches(), ['batch_7']); + // The task list is in the protocol's shape, not the tool's. + assert.equal(result.tasks[0].taskId, 't7'); + assert.equal(result.tasks[0].state, 'queued'); + assert.deepEqual(result.tasks[0].botIds, ['bot_b1']); + assert.equal(result.tasks[0].createdAt, 10); + await dispose(); +}); + +test('the helper list is in the shape the protocol speaks', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.tools.call = async () => ({ + ok: true, + content: [], + // The real team tool answers with a bare array of helpers. + structuredContent: [ + { helper_id: 'hlp_1', bot: 'Writer', title: 'Research', status: 'done', seconds: 42, report: 'done' }, + ], + }); + + const result = /** @type {any} */ (await bridge.dispatch('op.list_helpers', {})); + + assert.equal(result.helpers.length, 1); + assert.equal(result.helpers[0].helperId, 'hlp_1'); + assert.equal(result.helpers[0].state, 'done'); + assert.equal(result.helpers[0].seconds, 42); + assert.equal(result.helpers[0].detail, 'done'); + await dispose(); +}); + +test('the schedule list is in the shape the protocol speaks', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.tools.call = async () => ({ + ok: true, + content: [], + // The real team tool answers with a bare array of schedules. + structuredContent: [ + { + schedule_id: 'sch_1', + name: 'Standup', + bot_id: 'bot_b1', + kind: 'daily', + time: '09:00', + prompt: 'summarise', + enabled: true, + next_run: '2026-10-29T09:00:00.000Z', + last_run: null, + }, + ], + }); + + const result = /** @type {any} */ (await bridge.dispatch('op.list_schedules', {})); + + assert.equal(result.schedules.length, 1); + assert.equal(result.schedules[0].scheduleId, 'sch_1'); + assert.equal(result.schedules[0].spec, '09:00'); + assert.equal(result.schedules[0].targetBotId, 'bot_b1'); + assert.equal(result.schedules[0].nextRunAt, Date.parse('2026-10-29T09:00:00.000Z')); + await dispose(); +}); + +test('a memory the phone asks for is filed under a topic of its own', async () => { + const { bridge, toolCalls, dispose } = await makeBridge(); + + const result = /** @type {any} */ (await bridge.dispatch('op.remember', { botId: 'bot_b1', text: 'note' })); + + // The team's memory tool refuses a memory with neither a topic nor an entry to + // replace, so the bridge gives the note a topic. + assert.match(result.key, /^[a-z0-9][a-z0-9._-]{0,63}$/); + assert.equal(toolCalls[0].arguments.key, result.key); + assert.equal(toolCalls[0].arguments.target_bot, 'bot_b1'); + assert.equal(toolCalls[0].arguments.text, 'note'); + await dispose(); +}); + +test('a memory the phone files under a topic keeps that topic', async () => { + const { bridge, toolCalls, dispose } = await makeBridge(); + + await bridge.dispatch('op.remember', { botId: 'bot_b1', text: 'note', key: 'launch.date' }); + + assert.equal(toolCalls[0].arguments.key, 'launch.date'); + await dispose(); +}); + +test('history accumulates the messages of a chat', async () => { + const { bridge, dispose } = await makeBridge(); + // A turn the bridge runs itself is not in the desktop's transcript, so the bridge + // keeps it: the phone's own thread is the one it is looking at. + await bridge.dispatch('op.set_bot_mode', { botId: 'bot_b1', mode: 'hosted' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'one' }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'two' }); + + const history = /** @type {HistoryResult} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + const mine = history.messages.filter((message) => message.role === 'user'); + assert.equal(mine.length, 2); + assert.equal(mine[0].text, 'one'); + assert.equal(mine[1].text, 'two'); + // The Bot's replies are part of the thread as well. + assert.ok(history.messages.some((message) => message.role === 'assistant')); + // The thread reads in the order it happened, which is the order the phone renders. + const times = history.messages.map((message) => message.ts); + assert.deepEqual(times, [...times].sort((a, b) => a - b)); + await dispose(); +}); + +test('a turn the team runs is not kept twice', async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { + messages: { + 'bot:bot_b1': [ + { id: 'msg_d1', chatId: 'bot:bot_b1', ts: 1, role: 'relay', text: 'Hi' }, + { id: 'msg_d2', chatId: 'bot:bot_b1', ts: 2, role: 'bot', botId: 'bot_b1', text: 'Hello' }, + ], + }, + }, + }); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi' }); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + const ids = history.messages.map((/** @type {any} */ message) => message.messageId); + // The desktop's record and the bridge's own copy of the same exchange are one + // message, not two. + assert.equal(new Set(ids).size, ids.length); + assert.equal(ids.length, 2); + await dispose(); +}); + +test('the bridge keeps the note that says where the answer goes', async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { + messages: { + 'bot:bot_b1': [ + { id: 'msg_d1', chatId: 'bot:bot_b1', ts: 1, role: 'relay', text: 'Hi' }, + { id: 'msg_d2', chatId: 'bot:bot_b1', ts: 2, role: 'bot', botId: 'bot_b1', text: 'Hello' }, + ], + }, + }, + }); + + // The team stopped for a reason the phone has to know about. The desktop's transcript + // never held that note, so the bridge keeps it: a phone that opens the chat later must + // still be told where the answer goes. + bridge.handleEvent('evt.message_committed', { + message: { + messageId: 'msg_t1_note', + chatId: 'bot:bot_b1', + role: 'system', + text: 'The Bot is waiting for an answer. Open MiniMax Bot on the desktop to answer it.', + ts: 3, + }, + }); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + const note = history.messages.find((/** @type {any} */ message) => message.role === 'system'); + + assert.ok(note, 'the note is in the thread'); + assert.match(note.text, /Open MiniMax Bot on the desktop/); + await dispose(); +}); + +test('the reactions of a chat come back with the thread', async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { + messages: { + 'bot:bot_b1': [ + { id: 'msg_d1', chatId: 'bot:bot_b1', ts: 1, role: 'relay', text: 'Hi' }, + { id: 'msg_d2', chatId: 'bot:bot_b1', ts: 2, role: 'bot', botId: 'bot_b1', text: 'Hello' }, + ], + }, + }, + }); + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + const message = history.messages.find((/** @type {any} */ entry) => entry.role === 'assistant'); + + await bridge.dispatch('op.react', { messageId: message.messageId, emoji: '🔥' }); + await bridge.dispatch('op.react', { messageId: message.messageId, emoji: '🔥' }); + + const reloaded = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + // The counts belong to the message the phone can see, so a phone that opens the chat + // later reads them here. + assert.deepEqual(reloaded.reactions, [{ messageId: 'msg_d2', emoji: '🔥', count: 2 }]); + await dispose(); +}); + +test('reactions are counted per message', async () => { + const { bridge, sent, dispose } = await makeBridge(); + await bridge.dispatch('op.react', { messageId: 'm1', emoji: '🔥' }); + await bridge.dispatch('op.react', { messageId: 'm1', emoji: '🔥' }); + + assert.ok(sent.some((frame) => frame.type === 'evt.reaction_added')); + assert.equal(bridge.reactions.get('m1').get('🔥'), 2); + await dispose(); +}); + +test('usage is reported from the ledger', async () => { + const { bridge, dispose } = await makeBridge(); + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Hi' }); + + const usage = /** @type {UsageResult} */ (await bridge.dispatch('op.get_usage', { days: 7 })); + assert.ok(usage.buckets.length > 0); + assert.ok(usage.totals.turns >= 1); + await dispose(); +}); + +test('a permission request is held and can be answered', async () => { + const { bridge, sent, agentReplies, dispose } = await makeBridge(); + + // The Host asks the bridge about a run the bridge owns. A Bot's own approval lives + // in the Bot's session, which the team owns, so it is answered in MiniMax Bot. + holdRequest(bridge, 'permission', { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + + const asked = sent.find((frame) => frame.type === 'evt.permission_requested'); + assert.ok(asked); + assert.equal(asked.payload.request.requestId, 'req-1'); + assert.equal(bridge.pending.size, 1); + + await bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }); + assert.equal(agentReplies.length, 1); + assert.equal(agentReplies[0].decision, 'allowOnce'); + assert.equal(bridge.pending.size, 0); + assert.ok(sent.some((frame) => frame.type === 'evt.permission_resolved')); + await dispose(); +}); + +test('a permission decision is recorded against the phone that made it', async () => { + const { bridge, dispose } = await makeBridge(); + + holdRequest(bridge, 'permission', { requestId: 'req-1', toolName: 'shell', reason: 'writes a file' }); + + // The relay names the phone that sent the frame: it is the only thing that + // knows which socket the frame came from. + await bridge.dispatch('op.respond_permission', { requestId: 'req-1', decision: 'allowOnce' }, 'phone_1'); + + const decisions = bridge.ledger.recent().filter((entry) => entry.kind === 'permission'); + assert.equal(decisions.length, 1); + assert.equal(decisions[0].requestId, 'req-1'); + assert.equal(decisions[0].toolName, 'shell'); + assert.equal(decisions[0].deviceId, 'phone_1'); + assert.equal(decisions[0].decision, 'allowOnce'); + assert.ok(decisions[0].ts > 0); + await dispose(); +}); + +test('a permission decision is not counted as usage', async () => { + const { bridge, dispose } = await makeBridge(); + + holdRequest(bridge, 'permission', { requestId: 'req-2', toolName: 'shell', reason: 'writes a file' }); + + await bridge.dispatch('op.respond_permission', { requestId: 'req-2', decision: 'deny' }, 'phone_1'); + + // A decision is a record, not a tool call: counting it as one would put it + // into the usage totals and into a bot that is not there. + const usage = /** @type {UsageResult} */ (await bridge.dispatch('op.get_usage', { days: 7 })); + assert.equal(usage.totals.tools, 0); + assert.ok(usage.byBot.every((bot) => bot.botId !== 'unknown')); + await dispose(); +}); + +test('a revoked phone is recorded', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.relay.revokeDevice = async () => true; + + await bridge.revokeDevice('phone_1'); + + const changes = bridge.ledger.recent().filter((entry) => entry.kind === 'access'); + assert.equal(changes.length, 1); + assert.equal(changes[0].action, 'device-revoked'); + assert.equal(changes[0].subject, 'phone_1'); + await dispose(); +}); + +test('a rotated bridge token is recorded', async () => { + const { bridge, dispose } = await makeBridge(); + bridge.relay.rotateToken = async () => 'the-new-token'; + + await bridge.rotateToken(); + + const changes = bridge.ledger.recent().filter((entry) => entry.kind === 'access'); + assert.equal(changes.length, 1); + assert.equal(changes[0].action, 'bridge-token-rotated'); + assert.equal(changes[0].subject, 'bridge-1'); + await dispose(); +}); + +test('a ledger that cannot be written does not make the decision look failed', async () => { + const { bridge, agentReplies, dispose } = await makeBridge(); + + holdRequest(bridge, 'permission', { requestId: 'req-3', toolName: 'shell', reason: 'writes a file' }); + + // The disk is full. + bridge.ledger.persist = async () => { + throw new Error('the disk is full'); + }; + + const result = await bridge.dispatch( + 'op.respond_permission', + { requestId: 'req-3', decision: 'allowOnce' }, + 'phone_1', + ); + + // The Host was told the decision, so the phone must not be told it failed. + assert.equal(agentReplies.length, 1); + assert.equal(result.decision, 'allowOnce'); + // And the record is still there for the session. + const decisions = bridge.ledger.recent().filter((entry) => entry.kind === 'permission'); + assert.equal(decisions.length, 1); + assert.equal(decisions[0].deviceId, 'phone_1'); + await dispose(); +}); + +test('a pending approval is replayed when a phone resumes', async () => { + const { bridge, sent, dispose } = await makeBridge(); + + holdRequest(bridge, 'permission', { requestId: 'req-2', toolName: 'shell', reason: 'writes a file' }); + sent.length = 0; + + bridge.replayPending(); + const replayed = sent.find((frame) => frame.type === 'evt.permission_requested'); + assert.ok(replayed); + assert.equal(replayed.payload.request.requestId, 'req-2'); + await dispose(); +}); + +test('the bridge reports an unavailable team instead of failing to start', async () => { + const { bridge, dispose } = await makeBridge({ tools: [] }); + assert.equal(bridge.tools.available, false); + assert.equal(bridge.diagnostics().teamToolsAvailable, false); + assert.ok(bridge.diagnostics().reason); + await dispose(); +}); + +test("a Bot's look and picture travel with the roster", async () => { + const { bridge, dispose } = await makeBridge(); + bridge.tools.call = async () => ({ + ok: true, + content: [ + { + type: 'text', + // The fields are spelled differently by different writers of a Bot's record: + // `look` here, `skin` there, `colour` and `color`. + text: JSON.stringify({ + bots: [ + { bot_id: 'bot_b1', name: 'Writer', look: 'fuzzy', colour: 'ocean' }, + { bot_id: 'bot_b2', name: 'Reviewer', skin: 'moon', avatar: 'https://cdn.example.com/reviewer.png' }, + { bot_id: 'bot_b3', name: 'Engineer', avatar: '/Users/someone/avatar.png' }, + ], + groups: [], + }), + }, + ], + }); + + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + + // The phone draws the same octopus the desktop draws. + assert.equal(result.bots[0].skin, 'fuzzy'); + assert.equal(result.bots[0].color, 'ocean'); + assert.equal(result.bots[1].skin, 'moon'); + assert.equal(result.bots[1].avatar, 'https://cdn.example.com/reviewer.png'); + // A path on the desktop is not something the phone can load, so it is left out and + // the app falls back to the octopus rather than showing a broken frame. + assert.equal(result.bots[2].avatar, undefined); + await dispose(); +}); + +test("the desktop's own record of the team reaches the phone", async () => { + // The team tools answer with a summary. What a Bot *looks* like is not in that + // summary, so it is read from the desktop's own record of the team. + const { bridge, dispose } = await makeBridge({ + desktop: { + bots: [ + { + id: 'bot_b1', + name: 'Writer', + role: 'Long-form drafting', + skin: 'fuzzy', + color: 'ocean', + face: 'happy', + instructions: 'Writes the documents the team ships.', + isMain: true, + }, + ], + groups: [{ id: 'grp_g1', name: 'Team', adminId: 'bot_b1', memberIds: ['bot_b1'], replyMode: 'admin' }], + }, + }); + + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + + // The look, the face and what the Bot is for come from the desktop. + assert.equal(result.bots[0].skin, 'fuzzy'); + assert.equal(result.bots[0].color, 'ocean'); + assert.equal(result.bots[0].face, 'happy'); + assert.equal(result.bots[0].instructions, 'Writes the documents the team ships.'); + assert.equal(result.bots[0].isMain, true); + // The summary still says what the team tools say. + assert.equal(result.bots[0].model, 'minimax/MiniMax-M3.1-Flash'); + assert.equal(result.bots[0].effort, 'default'); + // The group keeps the notice the tool reported and gains the member ids the phone + // needs to open a group chat. + assert.equal(result.groups[0].notice, 'The whole team.'); + assert.deepEqual(result.groups[0].memberIds, ['bot_b1']); + await dispose(); +}); + +test('the roster is still the summary when the desktop keeps no Bot app', async () => { + const { bridge, dispose } = await makeBridge(); + + const result = /** @type {TeamResult} */ (await bridge.dispatch('op.list_team', {})); + + // No desktop record, no crash: the phone shows what the tool reported. + assert.equal(result.bots.length, 1); + assert.equal(result.bots[0].name, 'Writer'); + assert.equal(result.bots[0].skin, undefined); + await dispose(); +}); + +test("a Bot's picture travels from the desktop", async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { + bots: [{ id: 'bot_b1', name: 'Writer', skin: 'fuzzy' }], + skins: { fuzzy: TINY_PNG }, + }, + }); + + const result = /** @type {{botId: string, mime: string, data: string, source: string}} */ ( + await bridge.dispatch('op.bot_avatar', { botId: 'bot_b1' }) + ); + + // The bytes are the desktop's own: the phone draws the artwork the desktop draws. + assert.equal(result.botId, 'bot_b1'); + assert.equal(result.mime, 'image/png'); + assert.equal(result.source, 'skin'); + assert.deepEqual(Buffer.from(result.data, 'base64'), TINY_PNG); + await dispose(); +}); + +test('a Bot the desktop has no picture for has none', async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { bots: [{ id: 'bot_b1', name: 'Writer' }] }, + }); + + await assert.rejects( + () => bridge.dispatch('op.bot_avatar', { botId: 'bot_b1' }), + /picture/i, + ); + await dispose(); +}); + +test('a picture is asked for by Bot, not by file name', async () => { + const { bridge, dispose } = await makeBridge({ + desktop: { + bots: [{ id: 'bot_b1', name: 'Writer', skin: '../../secrets' }], + skins: { fuzzy: TINY_PNG }, + }, + }); + + // A look that tries to walk out of the artwork folder finds nothing. + await assert.rejects( + () => bridge.dispatch('op.bot_avatar', { botId: 'bot_b1' }), + /picture/i, + ); + await dispose(); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/caller.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/caller.test.mjs new file mode 100644 index 0000000..61e5fa1 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/caller.test.mjs @@ -0,0 +1,142 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { checkCaller } from '../miniapp/node/caller.mjs'; + +/** The address the Host assigned to the bridge in these tests. */ +const SURFACE = { host: '127.0.0.1', port: 53378 }; + +/** + * A request as the browser would send it. + * + * @param {Record} [headers] + * @returns {any} + */ +function fakeRequest(headers = {}) { + return { method: 'GET', url: '/api/session', headers }; +} + +/** + * @param {Record} headers + * @returns {any} + */ +function requestWith(headers) { + return fakeRequest(headers); +} + +test('the surface itself is welcome', () => { + const verdict = checkCaller(requestWith({ host: '127.0.0.1:53378' }), SURFACE); + assert.equal(verdict.ok, true); +}); + +test('the loopback name is welcome too', () => { + // A user may reach the surface by another name for the same machine. + const verdict = checkCaller(requestWith({ host: 'localhost:53378' }), SURFACE); + assert.equal(verdict.ok, true); +}); + +test('a request that names another host is refused', () => { + // DNS rebinding: the page was loaded from its own name, which now resolves to + // the loopback address. The browser sends that name, and it is not this bridge. + for (const host of [ + 'evil.example:53378', + 'evil.example', + '127.0.0.1.evil.example:53378', + 'localhost.evil.example:53378', + '10.0.0.5:53378', + '169.254.169.254:53378', + ]) { + const verdict = checkCaller(requestWith({ host }), SURFACE); + assert.equal(verdict.ok, false, `expected ${host} to be refused`); + } +}); + +test('a request that names another loopback address is refused', () => { + // The bridge is reachable on the address the Host assigned, not on every + // loopback address of the machine. + for (const host of ['127.0.0.2:53378', '127.1.2.3:53378', '[::1]:53378']) { + const verdict = checkCaller(requestWith({ host }), SURFACE); + assert.equal(verdict.ok, false, `expected ${host} to be refused`); + } +}); + +test('a request that names another port is refused', () => { + const verdict = checkCaller(requestWith({ host: '127.0.0.1:1234' }), SURFACE); + assert.equal(verdict.ok, false); +}); + +test('a request without a host is refused', () => { + const verdict = checkCaller(requestWith({}), SURFACE); + assert.equal(verdict.ok, false); +}); + +test('a request from another origin is refused', () => { + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', origin: 'https://evil.example' }), + SURFACE, + ); + assert.equal(verdict.ok, false); +}); + +test('a request from the surface origin is welcome', () => { + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', origin: 'http://127.0.0.1:53378' }), + SURFACE, + ); + assert.equal(verdict.ok, true); +}); + +test('a request from another loopback origin is refused', () => { + // Another origin on this machine is another origin, even when it is loopback. + for (const origin of [ + 'http://127.0.0.2:53378', + 'http://localhost:53378', + 'http://127.0.0.1:1234', + 'http://127.0.0.1', + 'ws://127.0.0.1:53378', + 'file://127.0.0.1:53378', + ]) { + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', origin }), + SURFACE, + ); + assert.equal(verdict.ok, false, `expected ${origin} to be refused`); + } +}); + +test('a request with an opaque origin is refused', () => { + // `null` is what a sandboxed page sends. It is not an origin this surface can + // have, so it is not one it accepts. + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', origin: 'null' }), + SURFACE, + ); + assert.equal(verdict.ok, false); +}); + +test('a cross-site request is refused', () => { + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', 'sec-fetch-site': 'cross-site' }), + SURFACE, + ); + assert.equal(verdict.ok, false); +}); + +test('a repeated sec-fetch-site header is refused', () => { + // A repeated header is joined with a comma, so the value is read as a list. + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', 'sec-fetch-site': 'cross-site, same-origin' }), + SURFACE, + ); + assert.equal(verdict.ok, false); +}); + +test('the same-site and same-origin markers are welcome', () => { + for (const site of ['same-origin', 'same-site', 'none']) { + const verdict = checkCaller( + requestWith({ host: '127.0.0.1:53378', 'sec-fetch-site': site }), + SURFACE, + ); + assert.equal(verdict.ok, true, `expected ${site} to be welcome`); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/capabilities.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/capabilities.test.mjs new file mode 100644 index 0000000..bf5e2bc --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/capabilities.test.mjs @@ -0,0 +1,205 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + frontmatter, + hostHome, + readCapabilities, +} from '../miniapp/node/capabilities.mjs'; + +/** + * What the desktop can do. + * + * The phone shows the desktop's skills, plugins and agents, so the user can ask for one + * by name from the phone. The list is read from the Host's own installation, and + * nothing is run from the phone: the bridge reads, it never writes. + */ + +/** + * A Home directory that looks like the Host's. + * + * @param {(home: string) => Promise} [build] + */ +async function homeWith(build) { + const home = await mkdtemp(join(tmpdir(), 'minimax-home-')); + await mkdir(join(home, 'v2', 'plugin-data', 'liveboards', 'minimax-bot-bridge'), { + recursive: true, + }); + if (build) await build(home); + return home; +} + +/** + * Writes a file, making its folder first. + * + * @param {string} file + * @param {string} text + */ +async function put(file, text) { + await mkdir(join(file, '..'), { recursive: true }); + await writeFile(file, text, 'utf8'); +} + +test("the Host's home is found from the bridge's own data directory", async () => { + const home = await homeWith(); + try { + const context = { + dataDir: join(home, 'v2', 'plugin-data', 'liveboards', 'minimax-bot-bridge'), + }; + assert.equal(hostHome(context), home); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('a bridge that does not know where it is does not guess', () => { + assert.equal(hostHome({}), ''); + assert.equal(hostHome(undefined), ''); +}); + +test('the front matter of a skill is read, quotes and all', () => { + const meta = frontmatter( + ['---', 'name: expo', 'description: "A long description.', 'It runs over lines."', 'license: MIT', '---', '', '# expo'].join( + '\n', + ), + ); + + assert.equal(meta.name, 'expo'); + assert.equal(meta.description, 'A long description. It runs over lines.'); + assert.equal(meta.license, 'MIT'); +}); + +test('a file without front matter has none', () => { + assert.deepEqual(frontmatter('# just a heading'), {}); + assert.deepEqual(frontmatter(''), {}); +}); + +test('the skills, plugins and agents of the desktop are listed', async () => { + const home = await homeWith(async (dir) => { + await put( + join(dir, 'skills', 'expo', 'SKILL.md'), + ['---', 'name: expo', 'description: "Everything Expo."', '---', '', '# expo'].join('\n'), + ); + await put( + join(dir, 'skills', 'writing', 'SKILL.md'), + ['---', 'name: writing', 'description: "Writes things."', '---'].join('\n'), + ); + // A folder that is not a skill is not one. + await put(join(dir, 'skills', 'notes', 'readme.md'), 'not a skill'); + await put( + join(dir, 'plugins', 'minimax-bot-bridge', '.minimax-plugin', 'plugin.json'), + JSON.stringify({ name: 'minimax-bot-bridge', displayName: 'MiniMax Bot Bridge', version: '0.1.0', description: 'The bridge.' }), + ); + await put( + join(dir, 'v2', 'plugin-cache', 'official', 'sha256-tree-v1-abc', '.minimax-plugin', 'plugin.json'), + JSON.stringify({ name: 'pdf', displayName: 'PDF', version: '1.0.4', description: 'PDFs.' }), + ); + // The cache and the marketplaces are folders, not plugins. + await put(join(dir, 'plugins', 'cache', 'note.txt'), 'not a plugin'); + await put(join(dir, 'plugins', 'marketplaces', 'note.txt'), 'not a plugin'); + await put(join(dir, 'agents', 'worker', 'config.yaml'), '{}\n'); + }); + + try { + const context = { + dataDir: join(home, 'v2', 'plugin-data', 'liveboards', 'minimax-bot-bridge'), + }; + const capabilities = await readCapabilities(context); + + assert.deepEqual(capabilities.skills, [ + { id: 'expo', name: 'expo', description: 'Everything Expo.', source: 'user' }, + { id: 'writing', name: 'writing', description: 'Writes things.', source: 'user' }, + ]); + assert.deepEqual(capabilities.plugins, [ + { + id: 'minimax-bot-bridge', + name: 'MiniMax Bot Bridge', + description: 'The bridge.', + source: 'installed', + version: '0.1.0', + }, + { + id: 'pdf', + name: 'PDF', + description: 'PDFs.', + source: 'official', + version: '1.0.4', + }, + ]); + assert.deepEqual(capabilities.agents, [ + { id: 'worker', name: 'worker', source: 'installed' }, + ]); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('the skills the Host carries itself are listed next to the user\'s own', async () => { + const home = await homeWith(async (dir) => { + await put( + join(dir, 'skills', 'expo', 'SKILL.md'), + ['---', 'name: expo', 'description: "Everything Expo."', '---'].join('\n'), + ); + await put( + join(dir, '.builtin-skills', 'pdf', 'SKILL.md'), + ['---', 'name: pdf', 'description: "PDFs."', '---'].join('\n'), + ); + }); + + try { + const capabilities = await readCapabilities({ + dataDir: join(home, 'v2', 'plugin-data', 'liveboards', 'minimax-bot-bridge'), + }); + + assert.deepEqual(capabilities.skills, [ + { id: 'expo', name: 'expo', description: 'Everything Expo.', source: 'user' }, + { id: 'pdf', name: 'pdf', description: 'PDFs.', source: 'built-in' }, + ]); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('the MCP servers the desktop has are listed', async () => { + const home = await homeWith(async (dir) => { + await put( + join(dir, 'mcp.json'), + JSON.stringify({ + mcpServers: { + expo: { type: 'stdio', enabled: true, description: 'The Expo MCP.' }, + 'shared-memory': { type: 'stdio', enabled: true, builtin: true }, + }, + }), + ); + }); + + try { + const capabilities = await readCapabilities({ + dataDir: join(home, 'v2', 'plugin-data', 'liveboards', 'minimax-bot-bridge'), + }); + + assert.deepEqual(capabilities.mcp, [ + { id: 'expo', name: 'expo', description: 'The Expo MCP.', source: 'user' }, + { id: 'shared-memory', name: 'shared-memory', source: 'built-in' }, + ]); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); + +test('a desktop with nothing to list is not an error', async () => { + const home = await homeWith(); + try { + const context = { + dataDir: join(home, 'v2', 'plugin-data', 'liveboards', 'minimax-bot-bridge'), + }; + const capabilities = await readCapabilities(context); + + assert.deepEqual(capabilities, { skills: [], plugins: [], agents: [], mcp: [] }); + } finally { + await rm(home, { recursive: true, force: true }); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/config-race.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/config-race.test.mjs new file mode 100644 index 0000000..a9482e7 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/config-race.test.mjs @@ -0,0 +1,258 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs/promises'; +import http from 'node:http'; +import { syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { loadConfig, saveConfig } from '../miniapp/node/config.mjs'; +import { start } from '../miniapp/node/server.mjs'; + +// The scratch root lives in the operating system's temporary directory, so the suite +// runs the same way in the workspace, in a published package and in an installed one. +const ROOT = join(tmpdir(), 'minimax-bot-bridge-tests', 'bridge-config'); +const A = { relayUrl: 'https://relay-a.example', accountId: 'room-a', bridgeId: 'bridge-a', bridgeToken: 'TOKEN_A', bridgeName: 'Desk' }; +const B = { relayUrl: 'https://relay-b.example', accountId: 'room-b', bridgeId: 'bridge-b', bridgeToken: 'TOKEN_B', bridgeName: 'Desk B' }; +const settle = () => new Promise(setImmediate); +function deferred() { + /** @type {(value?: any) => void} */ + let resolve = () => {}; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} + +// Exercise the actual start() wiring and route, replacing only Host/socket and I/O boundaries. +async function session(t) { + await fs.mkdir(ROOT, { recursive: true }); + const dataDir = await fs.mkdtemp(join(ROOT, 'config-')); + await saveConfig(dataDir, A); + const logs = []; + const logger = { info: (...args) => logs.push(args), warn: (...args) => logs.push(args), error: (...args) => logs.push(args), debug() {} }; + /** @type {Bridge} */ + let bridge; + /** @type {(request: any, response: any) => void} */ + let listener; + t.mock.method(Bridge.prototype, 'start', async function () { bridge = this; }); + t.mock.method(http, 'createServer', (callback) => { + listener = callback; + return { once() {}, off() {}, listen(_port, _host, done) { done(); }, close() {} }; + }); + syncBuiltinESMExports(); + t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status: 404 })); + const originalSocket = globalThis.WebSocket; + globalThis.WebSocket = /** @type {any} */ (class { + readyState = 0; + addEventListener() {} + close() {} + }); + const app = await start({ dataDir, logger, listen: { host: '127.0.0.1', port: 54321 }, signal: new AbortController().signal }); + bridge.ledger.persist = async () => {}; + const tokens = []; + bridge.onToken = (token) => { tokens.push(token); }; + t.after(() => { + app.dispose(); + globalThis.WebSocket = originalSocket; + t.mock.restoreAll(); + syncBuiltinESMExports(); + }); + const call = async (method, path, body = null, secret = null) => { + const done = deferred(); + const request = { + method, url: path, + headers: { host: '127.0.0.1:54321', 'content-type': 'application/json', 'x-bridge-secret': secret }, + async *[Symbol.asyncIterator]() { if (body !== null) yield Buffer.from(JSON.stringify(body)); }, + }; + const response = { status: 0, writeHead(status) { this.status = status; }, end(raw) { done.resolve({ status: this.status, body: JSON.parse(raw) }); } }; + listener(request, response); + return done.promise; + }; + const { body: sessionBody } = await call('GET', '/api/session'); + return { bridge, dataDir, logs, tokens, post: (body) => call('POST', '/api/config', body, sessionBody.secret) }; +} + +function blockNextSave(t, dataDir) { + const entered = deferred(); + const release = deferred(); + const write = fs.writeFile; + let blocked = false; + t.mock.method(fs, 'writeFile', async (path, contents, options) => { + if (!blocked && String(path) === join(dataDir, 'bridge-config.json')) { + blocked = true; + entered.resolve(); + await release.promise; + } + return write(path, contents, options); + }); + syncBuiltinESMExports(); + t.after(() => release.resolve()); + return { entered: entered.promise, release: () => release.resolve() }; +} +function pendingRotation(t) { + const pending = deferred(); + t.mock.method(globalThis, 'fetch', async (url) => String(url).endsWith('/bridge/token') + ? pending.promise : new Response('{}', { status: 404 })); + return () => pending.resolve(new Response(JSON.stringify({ token: 'ROTATED_A' }))); +} +function outcome(promise) { + return promise.then((token) => ({ token, error: null }), (error) => ({ token: null, error })); +} +async function assertB(state) { + assert.equal(state.bridge.config.relayUrl, B.relayUrl); + assert.equal(state.bridge.config.bridgeToken, 'TOKEN_B'); + assert.equal(state.bridge.relay.token, 'TOKEN_B'); + const stored = await loadConfig(state.dataDir); + assert.equal(stored.relayUrl, B.relayUrl); + assert.equal(stored.accountId, 'room-b'); + assert.equal(stored.bridgeToken, 'TOKEN_B'); +} + +test('rotation A completed after accepted configuration B cannot change memory, disk, or notify success', async (t) => { + const state = await session(t); + const complete = pendingRotation(t); + const rotating = outcome(state.bridge.rotateToken()); + assert.equal((await state.post(B)).status, 200); + complete(); + const result = await rotating; + await assertB(state); + assert.match(result.error?.message ?? '', /configuration changed|stale/i); + assert.deepEqual(state.tokens, []); + assert.ok(!JSON.stringify(state.logs).includes('the bridge token was rotated')); + assert.ok(!JSON.stringify(state.logs).includes('ROTATED_A')); + assert.equal(state.bridge.ledger.entries.filter((entry) => entry.kind === 'access').length, 0); +}); + +test('rotation A finishing while server B persistence is blocked cannot commit under B', async (t) => { + const state = await session(t); + const complete = pendingRotation(t); + const rotating = outcome(state.bridge.rotateToken()); + const gate = blockNextSave(t, state.dataDir); + const configuring = state.post(B); + await gate.entered; + complete(); + await settle(); + gate.release(); + assert.equal((await configuring).status, 200); + const result = await rotating; + await assertB(state); + assert.match(result.error?.message ?? '', /configuration changed|stale/i); + assert.deepEqual(state.tokens, []); + const requests = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + requests.push({ url: String(url), authorization: init.headers.authorization }); + return new Response('{}', { status: 404 }); + }); + await state.bridge.relay.fetchTicket(); + assert.deepEqual(requests, [{ url: 'https://relay-b.example/r/room-b/ws-ticket', authorization: 'Bearer TOKEN_B' }]); +}); + +test('a queued surface rename derives from the latest committed identity rather than reverting B', async (t) => { + const state = await session(t); + const gate = blockNextSave(t, state.dataDir); + const first = state.post(B); + await gate.entered; + const second = state.post({ bridgeName: 'Latest name' }); + await settle(); + gate.release(); + assert.equal((await first).status, 200); + assert.equal((await second).status, 200); + await assertB(state); + assert.equal((await loadConfig(state.dataDir)).bridgeName, 'Latest name'); +}); + +test('a bot-mode write queued during surface persistence preserves B and the preference', async (t) => { + const state = await session(t); + const gate = blockNextSave(t, state.dataDir); + const configuring = state.post(B); + await gate.entered; + const mode = state.bridge.setBotMode({ botId: 'bot-1', mode: 'hosted' }); + await settle(); + gate.release(); + await Promise.all([configuring, mode]); + await assertB(state); + assert.equal(state.bridge.config.botModes['bot-1'], 'hosted'); + assert.equal((await loadConfig(state.dataDir)).botModes['bot-1'], 'hosted'); +}); + +test('surface B and bot-mode writes queued behind rotation persistence do not restore A', async (t) => { + const state = await session(t); + const complete = pendingRotation(t); + const gate = blockNextSave(t, state.dataDir); + const rotating = outcome(state.bridge.rotateToken()); + complete(); + await gate.entered; + const configuring = state.post(B); + const mode = state.bridge.setBotMode({ botId: 'bot-2', mode: 'team' }); + await settle(); + gate.release(); + await Promise.all([rotating, configuring, mode]); + await assertB(state); + assert.equal((await loadConfig(state.dataDir)).botModes['bot-2'], 'team'); +}); + +test('a queued target change without its own token is revalidated against the latest identity', async (t) => { + const state = await session(t); + const gate = blockNextSave(t, state.dataDir); + const configuring = state.post(B); + await gate.entered; + const stale = state.post({ relayUrl: A.relayUrl, accountId: A.accountId }); + await settle(); + gate.release(); + assert.equal((await configuring).status, 200); + assert.equal((await stale).status, 400); + await assertB(state); +}); + +test('stop during rotation persistence restores the prior token and suppresses success callbacks', async (t) => { + const state = await session(t); + const complete = pendingRotation(t); + const gate = blockNextSave(t, state.dataDir); + const rotating = outcome(state.bridge.rotateToken()); + complete(); + await gate.entered; + state.bridge.stop(); + state.bridge.relay.start(); + gate.release(); + const result = await rotating; + assert.match(result.error?.message ?? '', /configuration changed|stale/i); + assert.equal(state.bridge.config.bridgeToken, 'TOKEN_A'); + assert.equal(state.bridge.relay.token, 'TOKEN_A'); + assert.equal((await loadConfig(state.dataDir)).bridgeToken, 'TOKEN_A'); + assert.deepEqual(state.tokens, []); +}); + +test('a failed config write does not update memory or poison the following config commit', async (t) => { + const state = await session(t); + const write = fs.writeFile; + let fail = true; + t.mock.method(fs, 'writeFile', async (path, contents, options) => { + if (fail && String(path) === join(state.dataDir, 'bridge-config.json')) { + fail = false; + throw new Error('simulated disk failure'); + } + return write(path, contents, options); + }); + syncBuiltinESMExports(); + assert.equal((await state.post(B)).status, 500); + assert.equal(state.bridge.config.bridgeToken, 'TOKEN_A'); + assert.equal(state.bridge.relay.token, 'TOKEN_A'); + assert.equal((await state.post(B)).status, 200); + await assertB(state); +}); + +test('same-identity proactive renewal still persists the token and keeps concurrent preferences', async (t) => { + const state = await session(t); + const complete = pendingRotation(t); + t.mock.method(state.bridge.relay, 'tokenExpiresAt', () => Date.now() + 1000); + const rotating = state.bridge.rotateTokenIfExpiring(); + await state.bridge.setBotMode({ botId: 'bot-3', mode: 'hosted' }); + complete(); + assert.equal(await rotating, true); + assert.equal(state.bridge.config.bridgeToken, 'ROTATED_A'); + assert.equal(state.bridge.relay.token, 'ROTATED_A'); + const stored = await loadConfig(state.dataDir); + assert.equal(stored.bridgeToken, 'ROTATED_A'); + assert.equal(stored.botModes['bot-3'], 'hosted'); + assert.deepEqual(state.tokens, ['ROTATED_A']); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/config.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/config.test.mjs new file mode 100644 index 0000000..39c8a7e --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/config.test.mjs @@ -0,0 +1,210 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { isAllowedRelayUrl, loadConfig, relayOrigin, saveConfig } from '../miniapp/node/config.mjs'; + +test('a relay address that is not one the bridge may use is refused', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-config-')); + try { + await loadConfig(dataDir, { + relayUrl: 'http://relay.example.workers.dev', + bridgeToken: 'a token', + }); + + const config = await loadConfig(dataDir, { bridgeToken: 'a token' }); + + // The bridge holds the long-lived token, so it never presents it to an + // address it cannot vouch for. The desktop is left unconfigured, which is + // the state a fresh install is in anyway. + assert.equal(config.relayUrl, ''); + assert.equal(config.bridgeToken, 'a token'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); + +test('a secure relay address is kept', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-config-')); + try { + await loadConfig(dataDir, { relayUrl: 'https://relay.example.workers.dev' }); + + const config = await loadConfig(dataDir); + + assert.equal(config.relayUrl, 'https://relay.example.workers.dev'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); + +test('a secure relay is allowed wherever it is', () => { + assert.equal(isAllowedRelayUrl('https://relay.example.workers.dev'), true); + assert.equal(isAllowedRelayUrl('wss://relay.example.workers.dev'), true); + assert.equal(isAllowedRelayUrl('https://relay.example.workers.dev/r/default'), true); +}); + +test('a cleartext relay is allowed only on the loopback interface', () => { + assert.equal(isAllowedRelayUrl('http://127.0.0.1:8787'), true); + assert.equal(isAllowedRelayUrl('ws://127.0.0.1:8787'), true); + assert.equal(isAllowedRelayUrl('http://localhost:8787'), true); + assert.equal(isAllowedRelayUrl('http://[::1]:8787'), true); +}); + +test('a cleartext relay anywhere else is refused', () => { + // The token would cross the network in the clear. + assert.equal(isAllowedRelayUrl('http://relay.example.workers.dev'), false); + // A LAN host is reachable but not trusted. + assert.equal(isAllowedRelayUrl('http://192.168.1.10:8787'), false); + assert.equal(isAllowedRelayUrl('http://10.0.0.5:8787'), false); + // Link-local and the cloud metadata address. + assert.equal(isAllowedRelayUrl('http://169.254.169.254'), false); + assert.equal(isAllowedRelayUrl('http://[fe80::1]:8787'), false); + // The metadata address over TLS is still not a relay. + assert.equal(isAllowedRelayUrl('https://169.254.169.254'), true); +}); + +test('an address that is not a relay address is refused', () => { + assert.equal(isAllowedRelayUrl(''), false); + assert.equal(isAllowedRelayUrl(' '), false); + assert.equal(isAllowedRelayUrl('relay.example.workers.dev'), false); + assert.equal(isAllowedRelayUrl('file:///etc/passwd'), false); + assert.equal(isAllowedRelayUrl('javascript:alert(1)'), false); + assert.equal(isAllowedRelayUrl('https://'), false); + assert.equal(isAllowedRelayUrl(undefined), false); +}); + +test('the origin of a relay address is the same for every scheme spelling', () => { + // The pairing request is HTTP and the frame channel is a WebSocket, so one relay + // has several spellings. They have to compare as the same relay. + assert.equal(relayOrigin('https://relay.example.workers.dev'), relayOrigin('wss://relay.example.workers.dev')); + assert.equal(relayOrigin('http://127.0.0.1:8787'), relayOrigin('ws://127.0.0.1:8787')); + assert.equal(relayOrigin('https://relay.example.workers.dev/'), relayOrigin('https://relay.example.workers.dev')); +}); + +test('two different relays have different origins', () => { + assert.notEqual( + relayOrigin('https://relay.example.workers.dev'), + relayOrigin('https://other.example.workers.dev'), + ); + assert.notEqual(relayOrigin('https://relay.example.workers.dev'), relayOrigin('http://relay.example.workers.dev')); +}); + +test('an address that is not a relay address has no origin', () => { + assert.equal(relayOrigin(''), ''); + assert.equal(relayOrigin('nonsense'), ''); +}); + +test('where the bridge token is kept', async () => { + const CONFIG = { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'the-bridge-token', + bridgeName: 'Desktop', + }; + + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-config-')); + try { + await saveConfig(dataDir, CONFIG); + + // The token is not in the configuration file: that file is the one that + // gets copied around. + const raw = await readFile(join(dataDir, 'bridge-config.json'), 'utf8'); + assert.equal(raw.includes('the-bridge-token'), false); + // The rest of the configuration is still there. + assert.equal(raw.includes('relay.example.workers.dev'), true); + + // It is in a file of its own. + const token = await readFile(join(dataDir, 'bridge-token'), 'utf8'); + assert.equal(token.trim(), 'the-bridge-token'); + + const config = await loadConfig(dataDir); + assert.equal(config.bridgeToken, 'the-bridge-token'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); + +test('the token file is written for its owner only', async (t) => { + if (process.platform === 'win32') { + t.skip('Windows has no POSIX file modes'); + return; + } + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-config-')); + try { + await saveConfig(dataDir, { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'the-bridge-token', + bridgeName: 'Desktop', + }); + + for (const file of ['bridge-config.json', 'bridge-token']) { + const info = await stat(join(dataDir, file)); + assert.equal(info.mode & 0o777, 0o600, `expected ${file} to be 0600`); + } + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); + +test('legacy token fields are removed even when the dedicated token file already exists', async (t) => { + for (const legacyToken of ['current-token', 'stale-token', '']) { + await t.test(JSON.stringify(legacyToken), async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-config-')); + try { + await writeFile(join(dataDir, 'bridge-config.json'), JSON.stringify({ + bridgeId: 'bridge-1', + bridgeName: 'Desktop', + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeToken: legacyToken, + })); + await writeFile(join(dataDir, 'bridge-token'), 'current-token\n'); + + const config = await loadConfig(dataDir); + assert.equal(config.bridgeToken, 'current-token'); + assert.equal(Object.hasOwn(JSON.parse(await readFile(join(dataDir, 'bridge-config.json'), 'utf8')), 'bridgeToken'), false); + assert.equal(await readFile(join(dataDir, 'bridge-token'), 'utf8'), 'current-token\n'); + assert.equal((await loadConfig(dataDir)).bridgeToken, 'current-token'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } + }); + } +}); + +test('a token in a configuration that still carried it is moved', async () => { + // A desktop that was configured before the token had a file of its own. + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-config-')); + try { + await writeFile( + join(dataDir, 'bridge-config.json'), + `${JSON.stringify( + { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'the-bridge-token', + bridgeName: 'Desktop', + }, + null, + 2, + )}\n`, + 'utf8', + ); + + const config = await loadConfig(dataDir); + + assert.equal(config.bridgeToken, 'the-bridge-token'); + const raw = await readFile(join(dataDir, 'bridge-config.json'), 'utf8'); + assert.equal(raw.includes('the-bridge-token'), false); + const token = await readFile(join(dataDir, 'bridge-token'), 'utf8'); + assert.equal(token.trim(), 'the-bridge-token'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/desktop-turn.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/desktop-turn.test.mjs new file mode 100644 index 0000000..f918116 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/desktop-turn.test.mjs @@ -0,0 +1,165 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { desktopMessage, desktopTurn } from '../miniapp/node/desktop-turn.mjs'; + +/** + * The desktop's own record of a turn. + * + * The shape is the one the desktop writes: one record for the whole turn, holding the + * parts it drew — what the Bot said while it worked, which tools it used, and the + * answer it ended with. The record's own text is only that answer. + */ +function record(overrides = {}) { + return { + id: 'msg_9b651f7988f2', + chatId: 'bot:bot_b1', + ts: 1793708397828, + status: 'done', + role: 'bot', + botId: 'bot_b1', + taskId: 'task_a5ffe76a214e', + text: 'The answer.', + parts: [ + { type: 'text', text: 'Working on it.' }, + { type: 'tool', name: 'bash', state: 'ok' }, + { type: 'tool', name: 'read', state: 'failed' }, + { type: 'text', text: 'The answer.' }, + ], + ...overrides, + }; +} + +test('every part the desktop draws as a bubble becomes a message', () => { + const { messages } = desktopTurn(record()); + + assert.equal(messages.length, 2); + assert.equal(messages[0].messageId, 'msg_9b651f7988f2#p0'); + assert.equal(messages[0].text, 'Working on it.'); + assert.equal(messages[0].role, 'assistant'); + assert.equal(messages[0].authorId, 'bot_b1'); + assert.equal(messages[0].ts, 1793708397828); +}); + +test('the answer keeps the record’s own id, so a reaction still belongs to it', () => { + const { messages } = desktopTurn(record()); + + assert.equal(messages[1].messageId, 'msg_9b651f7988f2'); + assert.equal(messages[1].text, 'The answer.'); + // Every message of the turn carries the turn's own name. + for (const message of messages) assert.equal(message.turnId, 'task_a5ffe76a214e'); +}); + +test('a turn of eight narrations and fifty-seven tools keeps all of them', () => { + const parts = []; + for (let index = 0; index < 8; index += 1) parts.push({ type: 'text', text: `Narration ${index}.` }); + for (let index = 0; index < 57; index += 1) parts.push({ type: 'tool', name: 'bash', state: 'ok' }); + parts.push({ type: 'text', text: 'The answer.' }); + + const { messages, activity } = desktopTurn(record({ parts })); + + assert.equal(messages.length, 9); + assert.equal(activity.length, 57); + assert.equal(messages[0].text, 'Narration 0.'); + assert.equal(messages[8].messageId, 'msg_9b651f7988f2'); + assert.equal(messages[8].text, 'The answer.'); + // The ids are unique, so no two parts can land on the same message. + assert.equal(new Set(messages.map((message) => message.messageId)).size, messages.length); + assert.equal(new Set(activity.map((entry) => entry.callId)).size, activity.length); +}); + +test('the tool calls of a turn carry their name and how they ended', () => { + const { activity } = desktopTurn(record()); + + assert.equal(activity.length, 2); + assert.deepEqual(activity[0], { + turnId: 'task_a5ffe76a214e', + callId: 'msg_9b651f7988f2#t1', + toolName: 'bash', + status: 'ok', + }); + assert.equal(activity[1].toolName, 'read'); + assert.equal(activity[1].status, 'failed'); +}); + +test('a call the desktop never saw report back is not a failure', () => { + const { activity } = desktopTurn( + record({ parts: [{ type: 'tool', name: 'bash', state: 'unknown' }] }), + ); + + assert.equal(activity[0].status, 'unknown'); +}); + +test('a call with no state is unknown rather than a guess', () => { + const { activity } = desktopTurn(record({ parts: [{ type: 'tool', name: 'bash' }] })); + + assert.equal(activity[0].status, 'unknown'); +}); + +test('the answer is the record’s own text even when no part holds it', () => { + const { messages } = desktopTurn( + record({ parts: [{ type: 'text', text: 'Working on it.' }], text: 'A different answer.' }), + ); + + assert.equal(messages.length, 2); + assert.equal(messages[1].messageId, 'msg_9b651f7988f2'); + assert.equal(messages[1].text, 'A different answer.'); +}); + +test('a record without parts still gives the answer', () => { + const { messages, activity } = desktopTurn(record({ parts: undefined })); + + assert.equal(messages.length, 1); + assert.equal(messages[0].messageId, 'msg_9b651f7988f2'); + assert.equal(messages[0].text, 'The answer.'); + assert.equal(activity.length, 0); +}); + +test('the desktop’s own bookkeeping is left out', () => { + const { messages, activity } = desktopTurn( + record({ + parts: [ + { type: 'event', kind: 'bot_created', botId: 'bot_b1' }, + { type: 'text', text: 'The answer.' }, + ], + }), + ); + + assert.equal(messages.length, 1); + assert.equal(activity.length, 0); +}); + +test('a part that holds nothing is left out, as the desktop leaves it out', () => { + const { messages } = desktopTurn( + record({ parts: [{ type: 'text', text: ' ' }, { type: 'text', text: 'The answer.' }] }), + ); + + assert.equal(messages.length, 1); + assert.equal(messages[0].messageId, 'msg_9b651f7988f2'); +}); + +test('a turn without a task is named after its own record', () => { + const { messages } = desktopTurn(record({ taskId: undefined, parts: [{ type: 'text', text: 'Hi.' }] })); + + assert.equal(messages[0].turnId, 'msg_9b651f7988f2'); +}); + +test('a message typed on the desktop is marked as coming from there', () => { + const message = desktopMessage( + { id: 'msg_typed', chatId: 'bot:bot_b1', ts: 10, role: 'user', text: 'Hello' }, + 'bot:bot_b1', + ); + + assert.equal(message.role, 'user'); + assert.equal(message.origin, 'desktop'); +}); + +test('a message that arrived from the phone is the user’s own', () => { + const message = desktopMessage( + { id: 'msg_task_1', chatId: 'bot:bot_b1', ts: 10, role: 'relay', fromName: 'the phone', text: 'Hello' }, + 'bot:bot_b1', + ); + + assert.equal(message.role, 'user'); + assert.equal(message.origin, undefined); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/error-text.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/error-text.test.mjs new file mode 100644 index 0000000..53553a9 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/error-text.test.mjs @@ -0,0 +1,204 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; + +/** + * What the room is told when something goes wrong. + * + * The diagnostics travel with the team snapshot to every phone in the account. The + * Host's own error text can name internal paths, tool identifiers and host-side detail + * that the phone's users did not need to see, so the room gets a fixed sentence and the + * desktop keeps the detail. + */ + +const SECRET = '/Users/local-user/.minimax/plugins/minimax-bot-bridge/tools.json'; + +/** + * @param {{toolList?: () => Promise, toolCall?: () => Promise}} options + */ +async function makeBridge({ toolList, toolCall } = {}) { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-errors-')); + const sent = []; + const logged = []; + + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { + info() {}, + warn(message, detail) { + logged.push({ message, detail }); + }, + error() {}, + debug() {}, + }, + tool: { + async list() { + if (toolList) return toolList(); + return { tools: [], partial: false }; + }, + async call() { + if (toolCall) return toolCall(); + return { kind: 'mcp', isError: false, content: [] }; + }, + }, + agent: { + async run() { + return { sessionId: 'sess-1', turnId: 'turn-1', status: 'finished' }; + }, + async reply() {}, + async answer() { + return { sessionId: 'sess-1', status: 'finished' }; + }, + }, + }; + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = (frame) => { + sent.push(frame); + return true; + }; + + await bridge.start(); + sent.length = 0; + + return { + bridge, + sent, + logged, + async dispose() { + bridge.stop(); + await rm(dataDir, { recursive: true, force: true }); + }, + }; +} + +/** Every piece of text in a frame, as one string. */ +function frameText(frame) { + return JSON.stringify(frame); +} + +test('the room is told a sentence, not the Host error text', async () => { + const { bridge, sent, logged, dispose } = await makeBridge({ + toolList: async () => { + throw new Error(`cannot read ${SECRET}: mcp__minimax-bot__message_bot is not registered`); + }, + }); + try { + await bridge.tools.refresh(); + + // The room's diagnostics carry a sentence. + const reason = bridge.diagnostics().reason; + assert.ok(reason); + assert.equal(reason.includes('mcp__minimax-bot'), false); + assert.equal(reason.includes(SECRET), false); + + // The desktop keeps the detail. + assert.equal(bridge.desktopDiagnostics().detail.includes(SECRET), true); + // And it was written down on the desktop. + assert.ok(logged.some((entry) => JSON.stringify(entry).includes(SECRET))); + + // And nothing that goes to the room carries it. + for (const frame of sent) { + assert.equal(frameText(frame).includes(SECRET), false); + assert.equal(frameText(frame).includes('mcp__minimax-bot'), false); + } + } finally { + await dispose(); + } +}); + +test('a roster that cannot be read is a sentence to the room as well', async () => { + const { bridge, sent, logged, dispose } = await makeBridge(); + try { + // The roster cannot be read. + bridge.readTeam = async () => { + throw new Error(`team tool failed: ${SECRET} is not readable`); + }; + + await bridge.publishTeamSnapshot(); + + const reason = bridge.diagnostics().reason; + assert.ok(reason); + assert.equal(reason.includes(SECRET), false); + + assert.equal(bridge.desktopDiagnostics().detail.includes(SECRET), true); + assert.ok(logged.some((entry) => JSON.stringify(entry).includes(SECRET))); + + for (const frame of sent) { + assert.equal(frameText(frame).includes(SECRET), false); + } + } finally { + await dispose(); + } +}); + +test('direct roster request exceptions stay on the desktop for Error and non-Error failures', async (t) => { + for (const failure of [new Error(`cannot read ${SECRET}`), `cannot read ${SECRET}`]) { + await t.test(typeof failure, async () => { + const { bridge, sent, logged, dispose } = await makeBridge({ + toolList: async () => ({ tools: [{ name: 'mcp__minimax-bot__list_team', server: 'minimax-bot', tool: 'list_team' }] }), + toolCall: async () => { throw failure; }, + }); + try { + const request = { type: 'op.list_team', id: 'roster-frame', opId: 'roster-op', from: 'phone-1', payload: {} }; + await Promise.all([bridge.handleFrame(request), bridge.handleFrame(request)]); + assert.equal(sent.filter(frame => frame.type === 'err').length, 2); + for (const frame of sent) { + assert.equal(frameText(frame).includes(SECRET), false); + } + assert.ok(logged.some(entry => JSON.stringify(entry).includes(SECRET))); + assert.equal(bridge.desktopDiagnostics().detail.includes(SECRET), true); + } finally { + await dispose(); + } + }); + } +}); + +test('an unavailable roster rejects with a fixed public error, never an empty successful roster', async () => { + const { bridge, sent, logged, dispose } = await makeBridge({ + toolList: async () => { + throw new Error(`cannot read ${SECRET}`); + }, + }); + const message = 'The team roster could not be read. Open MiniMax Bot on this desktop to try again.'; + try { + await assert.rejects(() => bridge.dispatch('op.list_team', {}), error => { + const publicError = /** @type {any} */ (error); + assert.equal(publicError.code, 'TEAM_ROSTER_UNREADABLE'); + assert.equal(publicError.message, message); + // Error.message is not enumerable: serialize the actual public fields, + // rather than accepting a vacuous JSON.stringify(Error) === '{}'. + assert.equal(JSON.stringify({ code: publicError.code, message: publicError.message, details: publicError.details }).includes(SECRET), false); + return true; + }); + assert.equal(bridge.diagnostics().reason, message); + // The catalogue failure remains in the desktop's private tool diagnostic and + // log even though the public roster failure has its own fixed sentence. + assert.equal(bridge.tools.detail.includes(SECRET), true); + assert.ok(logged.some(entry => JSON.stringify(entry).includes(SECRET))); + + await bridge.handleFrame({ type: 'op.list_team', id: 'unavailable-frame', opId: 'unavailable-op', from: 'phone-1', payload: {} }); + const failures = sent.filter(frame => frame.type === 'err'); + assert.equal(failures.length, 1); + assert.deepEqual(failures[0].payload, { + opId: 'unavailable-op', code: 'TEAM_ROSTER_UNREADABLE', message, retryable: false, + }); + assert.equal(sent.some(frame => frame.type === 'ack' || frame.type === 'evt.team_snapshot'), false); + assert.equal(JSON.stringify(sent).includes(SECRET), false); + assert.equal(JSON.stringify(bridge.diagnostics()).includes(SECRET), false); + } finally { + await dispose(); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/execution-hardening.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/execution-hardening.test.mjs new file mode 100644 index 0000000..0335b5c --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/execution-hardening.test.mjs @@ -0,0 +1,384 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { AgentRunner, MAX_CONCURRENT_RUNS } from '../miniapp/node/agent.mjs'; +import { BotRouter } from '../miniapp/node/bot-router.mjs'; +import { PendingSet } from '../miniapp/node/pending.mjs'; +import { buildToolArguments } from '../miniapp/node/tools.mjs'; +import { createFakeTeam, TEAM_TOOL_DESCRIPTORS } from './fake-team.mjs'; + +function deferred() { + /** @type {(value?: any) => void} */ + let resolve = () => {}; + const promise = new Promise(r => { resolve = r; }); + return { promise, resolve }; +} +/** @param {any} [agent] @param {Record} [botModes] */ +async function fixture(agent = {}, botModes = { b1: 'hosted' }) { + // The scratch root lives in the operating system's temporary directory, so the suite + // runs the same way in the workspace, in a published package and in an installed one. + const root = join(tmpdir(), 'minimax-bot-bridge-tests', 'bridge-execution'); + await mkdir(root, { recursive: true }); + const frames = [], warnings = []; + const context = { dataDir: await mkdtemp(join(root, 'bridge-')), agent, + logger: { info() {}, debug() {}, error() {}, warn: (...args) => warnings.push(args) } }; + const bridge = new Bridge(context, { config: /** @type {any} */ ({ botModes }) }); + bridge.relay.send = f => { frames.push(f); return true; }; + bridge.ledger.persist = async () => {}; + bridge.router.roster = async () => ({ bots: [{ botId: 'b1', name: 'Writer' }] }); + return { bridge, context, frames, warnings }; +} +function question(bridge, id = 'q1') { + bridge.runner.rememberSession('bot:b1', 's1'); + bridge.pending.add('question', { requestId: id, chatId: 'bot:b1', turnId: 'original', steps: [] }, 's1'); +} + +test('questionnaire continuations share pending, permission, session, tool and terminal lifecycle', async () => { + const { bridge, frames } = await fixture({ + answer: async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 'continued' }); + onEvent({ type: 'permission.requested', requestId: 'p2', sessionId: 's2', turnId: 'continued', toolName: 'shell' }); + onEvent({ type: 'tool.started', sessionId: 's1', name: 'read', turnId: 'continued' }); + onEvent({ type: 'tool.finished', sessionId: 's1', name: 'read', turnId: 'continued', ok: true }); + return { sessionId: 's2', turnId: 'continued', status: 'finished', pendingQuestion: { + requestId: 'q2', mode: 'questionnaire', request: { schemaVersion: 2, id: 'question-q2', steps: [] }, + } }; + }, + }); + question(bridge); + await bridge.runner.answer('q1', []); + assert.equal(bridge.pending.get('q1'), undefined); + assert.ok(bridge.pending.get('q2')); + assert.ok(bridge.pending.get('p2')); + assert.equal(bridge.runner.sessionFor('bot:b1'), 's2'); + assert.equal(bridge.router.phoneSessions.get('bot:b1'), 's2'); + assert.equal(bridge.runner.aborts.size, 0); + assert.equal(bridge.runner.toolCalls.size, 0); + assert.equal(bridge.ledger.report().totals.tools, 1); + assert.ok(frames.some(f => f.type === 'evt.turn_finished')); + assert.ok(frames.some(f => f.type === 'evt.question_requested' && f.payload.request.requestId === 'q2')); + bridge.context.agent.answer = async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 'last' }); + onEvent({ type: 'message.committed', sessionId: 's1', turnId: 'last', text: 'done' }); + return { sessionId: 's3', turnId: 'last', status: 'finished', text: 'done' }; + }; + await bridge.runner.answer('q2', []); + assert.ok((await bridge.historyFor('bot:b1')).messages.some(m => m.text === 'done')); + bridge.stop(); +}); + +test('continuation failure cleans watchers, records completed tools and failure, and does not reopen an accepted answer', async () => { + const { bridge, frames } = await fixture({ answer: async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 'continued' }); + onEvent({ type: 'tool.started', sessionId: 's1', turnId: 'continued', name: 'read' }); + onEvent({ type: 'tool.finished', sessionId: 's1', turnId: 'continued', name: 'read', ok: false }); + throw new Error('poll failed'); + } }); + question(bridge); + await assert.rejects(bridge.runner.answer('q1', []), /poll failed/); + assert.equal(bridge.runner.aborts.size, 0); + assert.equal(bridge.runner.toolCalls.size, 0); + assert.equal(bridge.runner.activeRuns, 0); + assert.equal(bridge.pending.size, 0); + assert.ok(frames.some(f => f.type === 'evt.turn_finished' && f.payload.status === 'error')); + assert.equal(bridge.ledger.report().totals.tools, 1); + assert.equal(bridge.ledger.report().totals.errors, 2); + bridge.stop(); +}); + +test('answers and permission completions are admitted at the run creation ceiling', async () => { + let replies = 0, answers = 0; + const pending = new PendingSet(); + const runner = new AgentRunner({ agent: { reply: async () => { replies += 1; }, answer: async () => { answers += 1; return { status: 'finished' }; } } }, { pending }); + pending.add('permission', { requestId: 'p' }, 's'); + pending.add('question', { requestId: 'q', chatId: 'bot:b' }, 's'); + runner.activeRuns = MAX_CONCURRENT_RUNS; + await runner.reply('p', 'deny'); + await runner.answer('q', []); + assert.equal(replies, 1); + assert.equal(answers, 1); + assert.equal(runner.activeRuns, MAX_CONCURRENT_RUNS); + await assert.rejects(runner.runHosted({ chatId: 'bot:b', text: 'new' }), { code: 'BUSY' }); +}); + +test('remote cancel refusal or transport failure leaves the watcher and retry intact', async () => { + const polled = deferred(), result = deferred(); + /** @type {AbortSignal} */ + let signal; + let cancels = 0; + const router = new BotRouter({}, { gateway: /** @type {any} */ ({ available: true, call: async (name, _input, options = {}) => { + if (name === 'message_bot') return { ok: true, structuredContent: { value: { batch_id: 'batch', tasks: [{ task_id: 'task' }] } } }; + if (name === 'get_tasks') { + signal = options.signal; polled.resolve(); + return result.promise; + } + if (name === 'cancel_tasks') { + cancels += 1; + if (cancels === 1) return { ok: true, isError: true, message: 'cancel refused' }; + if (cancels === 2) throw new Error('transport failure'); + return { ok: true, structuredContent: { value: { tasks: [{ task_id: 'task', status: 'cancelled' }] } } }; + } + } }) }); + const runner = new AgentRunner({}, { router }); + const run = runner.run({ chatId: 'bot:b', text: 'work', mode: 'team' }); + await polled.promise; + await assert.rejects(runner.abort('task'), /cancel refused/); + assert.equal(signal.aborted, false); + await assert.rejects(runner.abort('task'), /transport failure/); + assert.equal(signal.aborted, false); + assert.equal(await runner.abort('task'), true); + assert.equal(signal.aborted, true); + result.resolve({ ok: true, structuredContent: { tasks: [{ task_id: 'task', status: 'cancelled' }] } }); + await run; + assert.equal(router.turns.size, 0); +}); + +test('assistant and tool history follow their run owner across both mode switches', async () => { + const ready = deferred(), end = deferred(); + const { bridge } = await fixture({ run: async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 'hosted' }); ready.resolve(); await end.promise; + onEvent({ type: 'tool.started', sessionId: 's1', turnId: 'hosted', name: 'read' }); + onEvent({ type: 'tool.finished', sessionId: 's1', turnId: 'hosted', name: 'read', ok: true }); + onEvent({ type: 'message.committed', sessionId: 's1', turnId: 'hosted', text: 'hosted answer' }); + return { sessionId: 's', turnId: 'hosted', status: 'finished' }; + } }); + const run = bridge.sendMessage({ chatId: 'bot:b1', text: 'work' }); + await ready.promise; + await bridge.setBotMode({ botId: 'b1', mode: 'team' }); end.resolve(); await run; + assert.ok((await bridge.historyFor('bot:b1')).messages.some(m => m.text === 'hosted answer')); + assert.equal(bridge.activity.get('bot:b1').length, 1); + bridge.runner.publishExchange('bot:b1', 'official', { messages: [{ role: 'assistant', messageId: 'official', chatId: 'bot:b1', ts: 1, text: 'team answer' }], activity: [] }); + await bridge.setBotMode({ botId: 'b1', mode: 'hosted' }); + bridge.runner.publishExchange('bot:b1', 'official2', { messages: [{ role: 'assistant', messageId: 'official2', chatId: 'bot:b1', ts: 2, text: 'another team answer' }], activity: [] }); + assert.equal(bridge.history.get('bot:b1').some(m => m.text.includes('team answer')), false); + bridge.stop(); +}); + +test('roster tool errors and malformed successful results cannot publish an empty success', async () => { + const { bridge, frames } = await fixture(); + for (const response of [ + { ok: false, code: 'TOOL_NOT_AVAILABLE' }, + { ok: true, isError: true, message: 'Please open the team.' }, + { ok: true, structuredContent: { ok: false, error: 'Please open the team.' } }, + { ok: true, structuredContent: { unrelated: [] } }, + ]) { + bridge.tools.call = async () => response; + assert.equal(await bridge.publishTeamSnapshot(), false); + } + assert.equal(frames.some(f => f.type === 'evt.team_snapshot'), false); + bridge.tools.call = async () => ({ ok: true, structuredContent: { bots: [], groups: [] } }); + assert.deepEqual(await bridge.readTeam(), { bots: [], groups: [] }); + bridge.stop(); +}); + +test('hosted errors retain supplied result usage and completed tool errors without invented tokens', async () => { + const { bridge } = await fixture({ run: async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 't' }); + onEvent({ type: 'tool.started', sessionId: 's1', turnId: 't', name: 'read' }); + onEvent({ type: 'tool.finished', sessionId: 's1', turnId: 't', name: 'read', ok: false }); + return { status: 'error', turnId: 't', usage: { totalTokens: 17 }, error: { message: 'failed' } }; + } }); + await bridge.runner.run({ chatId: 'bot:b1', text: 'work' }); + const entries = bridge.ledger.recent(); + assert.equal(entries.find(e => e.kind === 'turn').tokens, 17); + assert.equal(entries.find(e => e.kind === 'tool').tokens, 0); + assert.equal(bridge.ledger.report().totals.errors, 2); + bridge.stop(); +}); + +test('a successful remote mutation remains acknowledged when audit persistence fails', async () => { + const { bridge, frames, warnings } = await fixture(); + let effects = 0; + bridge.tools.call = async () => { effects += 1; return { ok: true, structuredContent: { id: 'created' } }; }; + bridge.ledger.persist = async () => { throw new Error('SECRET_PATH disk full'); }; + const frame = { type: 'op.create_schedule', id: 'f', opId: 'o', from: 'phone', payload: {} }; + await bridge.handleFrame(frame); await bridge.handleFrame(frame); + assert.equal(effects, 1); + assert.equal(frames.filter(f => f.type === 'ack').length, 2); + assert.ok(frames.some(f => f.type === 'evt.schedule_updated')); + assert.ok(warnings.length > 0); + assert.equal(JSON.stringify(warnings).includes('SECRET_PATH'), false); + bridge.stop(); +}); + +test('fallback dispatch request ids are collision resistant and explicit keys survive', () => { + const original = Date.now; Date.now = () => 123; + try { + const keys = Array.from({ length: 100 }, () => buildToolArguments({ botIds: ['b'], prompt: 'work' }, undefined, 'dispatch_tasks').request_id); + assert.equal(new Set(keys).size, keys.length); + assert.equal(buildToolArguments({ botIds: ['b'], prompt: 'work', requestId: 'stable' }, undefined, 'dispatch_tasks').request_id, 'stable'); + } finally { Date.now = original; } +}); + +test('stop aborts owned watchers and blocks stale callbacks, retries and new work without remote team cancellation', async () => { + const ready = deferred(), end = deferred(); + /** @type {AbortSignal} */ + let signal; + const { bridge, frames } = await fixture({ run: async (_input, options) => { + signal = options.signal; options.onEvent({ type: 'turn.started', sessionId: 's1', turnId: 't' }); ready.resolve(); await end.promise; + options.onEvent({ type: 'message.committed', sessionId: 's1', turnId: 't', text: 'stale' }); + return { sessionId: 'stale-session', turnId: 't' }; + } }); + const running = bridge.runner.run({ chatId: 'bot:b1', text: 'work' }); + await ready.promise; + bridge.scheduleSnapshotRetry(); + bridge.stop(); + const before = frames.length; + end.resolve(); await running; + assert.equal(signal.aborted, true); + assert.equal(frames.length, before); + assert.equal(bridge.snapshotTimer, undefined); + assert.equal(bridge.runner.sessionFor('bot:b1'), undefined); + assert.equal(bridge.router.phoneSessions.has('bot:b1'), false); + await assert.rejects(bridge.runner.run({ chatId: 'bot:b1', text: 'new' }), { code: 'BRIDGE_STOPPED' }); +}); + +test('invalid modes including null and omission are rejected before configuration writes', async () => { + const { bridge } = await fixture(); + const original = bridge.config; + for (const mode of [undefined, null, 'tean', 42]) { + await assert.rejects(bridge.setBotMode(/** @type {any} */ ({ botId: 'b1', mode })), { code: 'INVALID_ARGUMENTS' }); + assert.equal(bridge.config, original); + } + bridge.stop(); +}); + +test('a late permission completion from a stopped generation cannot emit into its replacement', async () => { + const ready = deferred(), finish = deferred(); + const { bridge, frames } = await fixture({ reply: async () => { ready.resolve(); await finish.promise; } }); + bridge.pending.add('permission', { requestId: 'p', chatId: 'bot:b1' }, 's1'); + const replying = bridge.runner.reply('p', 'deny'); + await ready.promise; + bridge.stop(); bridge.stopped = false; bridge.runner.stopped = false; + const before = frames.length; + finish.resolve(); await replying; + assert.equal(frames.length, before); + bridge.stop(); +}); + +test('a mutation completing in an old stopped generation cannot publish or acknowledge into the replacement', async () => { + const ready = deferred(), finish = deferred(); + const { bridge, frames } = await fixture(); + bridge.tools.call = async () => { ready.resolve(); await finish.promise; return { ok: true, structuredContent: { id: 'created' } }; }; + const running = bridge.handleFrame({ type: 'op.create_schedule', id: 'f', opId: 'old', from: 'phone', payload: {} }); + await ready.promise; + bridge.stop(); bridge.stopped = false; bridge.runner.stopped = false; + const before = frames.length; + finish.resolve(); await running; + assert.equal(frames.length, before); + bridge.stop(); +}); + +test('overlapping polls retain their router reference until the last poll settles', async () => { + const entered = deferred(), finish = deferred(); let polls = 0; + const router = new BotRouter({}, { gateway: /** @type {any} */ ({ available: true, call: async (name) => { + if (name === 'message_bot') return { ok: true, structuredContent: { batch_id: 'b', tasks: [{ task_id: 't' }] } }; + polls += 1; + if (polls === 2) { entered.resolve(); await finish.promise; } + return { ok: true, structuredContent: { tasks: [{ task_id: 't', status: 'done', result: 'done' }] } }; + } }) }); + const turn = await router.send({ chatId: 'bot:b', text: 'work' }); + const first = router.wait(turn), second = router.wait(turn); + await entered.promise; await first; + assert.equal(router.turns.size, 1); + finish.resolve(); await second; + assert.equal(router.turns.size, 0); +}); + +test('cancelling an already completed remote task does not abort its result watcher', async () => { + const ready = deferred(), finish = deferred(); + /** @type {AbortSignal} */ + let signal; + const router = new BotRouter({}, { gateway: /** @type {any} */ ({ available: true, call: async (name, _payload, options = {}) => { + if (name === 'message_bot') return { ok: true, structuredContent: { batch_id: 'b', tasks: [{ task_id: 't' }] } }; + if (name === 'cancel_tasks') return { ok: true, structuredContent: { tasks: [{ task_id: 't', status: 'done' }] } }; + signal = options.signal; ready.resolve(); await finish.promise; + return { ok: true, structuredContent: { tasks: [{ task_id: 't', status: 'done', result: 'finished before cancel' }] } }; + } }) }); + const runner = new AgentRunner({}, { router }); + const running = runner.run({ chatId: 'bot:b', text: 'work', mode: 'team' }); + await ready.promise; + assert.equal(await runner.abort('t'), false); + assert.equal(signal.aborted, false); + finish.resolve(); + assert.equal((await running).text, 'finished before cancel'); +}); + +test('an accepted answer without a continuation does not invent a turn', async () => { + const { bridge, frames } = await fixture({ answer: async () => ({ sessionId: 's1', status: 'finished' }) }); + question(bridge); + await bridge.runner.answer('q1', []); + assert.equal(bridge.ledger.report().totals.turns, 0); + assert.equal(frames.filter(f => f.type === 'evt.turn_finished').length, 0); + assert.equal(bridge.pending.size, 0); + bridge.stop(); +}); + +test('a pending request retains its owning chat after the session index evicts it', async () => { + const { bridge } = await fixture({ answer: async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 'continued' }); + onEvent({ type: 'message.committed', sessionId: 's1', turnId: 'continued', text: 'right chat' }); + return { sessionId: 's1', turnId: 'continued', status: 'finished' }; + } }); + question(bridge); + for (let i = 0; i < 60; i += 1) bridge.runner.rememberSession(`bot:other${i}`, `s${i + 2}`); + assert.equal(bridge.runner.sessionFor('bot:b1'), undefined); + await bridge.runner.answer('q1', []); + assert.ok(bridge.history.get('bot:b1').some(m => m.text === 'right chat')); + assert.equal(bridge.history.has('unknown'), false); + bridge.stop(); +}); + +test('stop while roster lookup is pending cannot start a new Host session', async () => { + let calls = 0; + const ready = deferred(), roster = deferred(); + const { bridge } = await fixture({ run: async () => { calls += 1; return { sessionId: 's', status: 'finished' }; } }); + bridge.router.roster = async () => { ready.resolve(); return roster.promise; }; + const running = bridge.runner.run({ chatId: 'bot:b1', text: 'work' }); + await ready.promise; bridge.stop(); + roster.resolve({ bots: [{ botId: 'b1', name: 'Bot' }] }); + await running.catch(() => {}); + assert.equal(calls, 0); +}); + +test('stop during group mention lookup does not dispatch new official team work', async () => { + const ready = deferred(), finish = deferred(); let effects = 0; + const router = new BotRouter({}, { gateway: /** @type {any} */ ({ available: true, call: async () => { + effects += 1; + return { ok: true, structuredContent: { batch_id: 'b', tasks: [{ task_id: 't', status: 'done' }] } }; + } }), roster: async () => { ready.resolve(); await finish.promise; return { bots: [{ botId: 'b', name: 'Bot' }] }; } }); + const runner = new AgentRunner({}, { router }); + const running = runner.run({ chatId: 'group:g', text: 'work', mentions: ['b'] }); + await ready.promise; runner.stop(); finish.resolve(); + await running.catch(() => {}); + assert.equal(effects, 0); +}); + +test('a terminal continuation clears stale asks from its chat without clearing another active run', async () => { + const { bridge } = await fixture({ answer: async (_input, { onEvent }) => { + onEvent({ type: 'turn.started', sessionId: 's1', turnId: 'last' }); + return { sessionId: 's1', turnId: 'last', status: 'finished' }; + } }); + question(bridge); + bridge.pending.add('permission', { requestId: 'old-permission', chatId: 'bot:b1', turnId: 'older' }, 's1'); + bridge.pending.add('permission', { requestId: 'other', chatId: 'bot:other', turnId: 'elsewhere' }, 'other-session'); + await bridge.runner.answer('q1', []); + assert.equal(bridge.pending.get('old-permission'), undefined); + assert.ok(bridge.pending.get('other')); + bridge.stop(); +}); + +test('team message batches survive reconnect queries and settled router references are retired', async () => { + const { bridge, context } = await fixture({}, {}); + const team = createFakeTeam(); + context.tool = { list: async () => ({ tools: TEAM_TOOL_DESCRIPTORS }), call: team.call }; + await bridge.tools.refresh(); + for (let i = 0; i < 5; i += 1) await bridge.sendMessage({ chatId: 'bot:bot_b1', text: 'work' }); + assert.equal(bridge.router.turns.size, 0); + assert.equal(bridge.ledger.batches().length, 5); + assert.equal((await bridge.listKnownTasks()).tasks.length, 5); + bridge.stop(); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/fake-agent.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/fake-agent.mjs new file mode 100644 index 0000000..38e5b70 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/fake-agent.mjs @@ -0,0 +1,55 @@ +/** + * A fake desktop agent. + * + * The Host's agent API is what a hosted turn runs on: the bridge owns the run, so the + * questions the run raises — a permission check, a questionnaire — are raised here and + * can be answered here. The fake speaks the same events the Host does, so a test that + * passes here is a test of the same contract. + */ + +/** + * @param {object} [options] + * @param {Array} [options.script] the events the run reports, in order + * @param {string} [options.sessionId] the session a run continues + * @param {string} [options.status] how the run ends + * @param {string} [options.text] the run's answer + * @param {any} [options.catalog] the model catalog, when the Host lets a run pick its model + * @param {any} [options.pendingQuestion] the result marker for a turn paused on a questionnaire + */ +export function createFakeAgent({ script = [], sessionId, status = 'finished', text = '', catalog, pendingQuestion } = {}) { + /** The runs the bridge started. */ + const runs = []; + /** The answers the bridge gave. */ + const replies = []; + /** The questionnaires the bridge answered. */ + const answers = []; + + const agent = { + runs, + replies, + answers, + async run(input, options) { + runs.push({ input, options }); + const turnId = `turn_${runs.length}`; + const session = input.sessionId ?? sessionId ?? `sess_${runs.length}`; + for (const event of script) { + options?.onEvent?.({ turnId, sessionId: session, ...event }); + } + return { sessionId: session, turnId, status, text, ...(pendingQuestion ? { pendingQuestion } : {}) }; + }, + async reply(input) { + replies.push(input); + }, + async answer(input) { + answers.push(input); + return { sessionId: input.sessionId, status: 'finished' }; + }, + }; + + // The Host lets a run pick its model on some hosts and not on others, and a run that + // names a model the host does not take is refused before it starts. + if (catalog) { + agent.models = async () => catalog; + } + return agent; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/fake-team.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/fake-team.mjs new file mode 100644 index 0000000..c29320e --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/fake-team.mjs @@ -0,0 +1,401 @@ +/** + * The team tools the bridge needs, with the schemas the real server advertises. + * + * The schemas are the ones the official `minimax-bot` MCP server publishes, because the + * bridge maps a message onto the tool's own argument names: without the schema it + * cannot know that a Bot is called `to` and a group is called `group`. + */ +export const TEAM_TOOL_DESCRIPTORS = [ + { name: 'mcp__minimax-bot__list_team', server: 'minimax-bot', tool: 'list_team' }, + { + name: 'mcp__minimax-bot__message_bot', + server: 'minimax-bot', + tool: 'message_bot', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + request_id: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,128}$' }, + to: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['to', 'message'], + additionalProperties: false, + }, + }, + { + name: 'mcp__minimax-bot__post_group', + server: 'minimax-bot', + tool: 'post_group', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + request_id: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,128}$' }, + group: { type: 'string' }, + message: { type: 'string' }, + }, + required: ['group', 'message'], + additionalProperties: false, + }, + }, + { + name: 'mcp__minimax-bot__get_tasks', + server: 'minimax-bot', + tool: 'get_tasks', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + task_id: { type: 'string' }, + batch_id: { type: 'string' }, + wait_ms: { type: 'integer', minimum: 0, maximum: 20000 }, + result_offset: { type: 'integer', minimum: 0 }, + }, + required: [], + additionalProperties: false, + oneOf: [{ required: ['task_id'] }, { required: ['batch_id'] }], + }, + }, + { + name: 'mcp__minimax-bot__cancel_tasks', + server: 'minimax-bot', + tool: 'cancel_tasks', + inputSchema: { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + task_id: { type: 'string' }, + batch_id: { type: 'string' }, + }, + required: [], + additionalProperties: false, + oneOf: [{ required: ['task_id'] }, { required: ['batch_id'] }], + }, + }, +]; + +/** + * A fake `@minimax-bot` team. + * + * The answers are the shapes the real team answers in — the durable admission receipt, + * the task view, the settled result — copied from the official plugin's own task + * dispatcher, so a test that passes here is a test of the same contract the desktop + * answers. + * + * The team owns what a Bot is: its instructions, its model, its effort and its session. + * This fake does not pretend to be a model; it reports the reply it was configured + * with, under the Bot that was addressed, in that Bot's session. + */ + +/** + * The roster the fake team answers `list_team` with. + * + * The shape is the one the real tool answers in: `bot_id`, `main`, members and admin + * by name, `reply_mode`. + */ +export const TEAM_BOTS = [ + { + bot_id: 'bot_b1', + name: 'Writer', + role: 'Long-form drafting', + main: false, + model: 'minimax/MiniMax-M3.1-Flash', + effort: 'default', + }, + { + bot_id: 'bot_b2', + name: 'Reviewer', + role: 'Second pair of eyes', + main: false, + model: 'minimax/MiniMax-M3.1-Flash', + effort: 'high', + }, +]; + +export const TEAM_GROUPS = [ + { + group_id: 'grp_g1', + name: 'Team', + notice: 'The whole team.', + admin: 'Writer', + members: ['Writer', 'Reviewer'], + reply_mode: 'admin', + }, +]; + +/** + * Wraps a value the way the team's MCP server wraps it: as JSON text in one content + * block, under a `value` key. + * + * @param {unknown} value + */ +function mcp(value) { + return { + kind: 'mcp', + isError: false, + content: [{ type: 'text', text: JSON.stringify(value, null, 2) }], + }; +} + +/** + * Builds a fake team. + * + * @param {object} [options] + * @param {Array} [options.bots] the roster + * @param {Array} [options.groups] the groups + * @param {Record} [options.replies] the reply each Bot makes + * @param {Record} [options.sessions] the session each Bot answers in + * @param {(task: any) => void} [options.onTask] called when a task settles + * @param {boolean} [options.resolveUnknown] answer for a Bot that is not in the roster + * @param {boolean} [options.hold] keep the task running instead of settling it + */ +export function createFakeTeam({ + bots = TEAM_BOTS, + groups = TEAM_GROUPS, + replies = {}, + sessions = {}, + onTask, + resolveUnknown = false, + hold = false, +} = {}) { + /** @type {Array<{name: string, arguments: any}>} */ + const calls = []; + /** @type {Map} */ + const tasks = new Map(); + let counter = 0; + + const findBot = (ref) => + bots.find( + (bot) => + bot.bot_id === ref || + String(bot.name).toLowerCase() === String(ref ?? '').toLowerCase(), + ) ?? + // A test that only cares about how many conversations the bridge remembers does + // not need a roster entry for every one of them. + (resolveUnknown && ref + ? { bot_id: String(ref), name: String(ref), role: '', main: false, model: '', effort: 'default' } + : undefined); + + const findGroup = (ref) => + groups.find( + (group) => + group.group_id === ref || + String(group.name).toLowerCase() === String(ref ?? '').toLowerCase(), + ); + + /** + * The task view, in the shape the team reports it. + * + * @param {any} task + */ + const view = (task) => ({ + task_id: task.task_id, + kind: task.kind, + ...(task.bot_id ? { bot_id: task.bot_id } : {}), + title: task.title, + status: task.status, + chat_id: task.chat_id, + session_id: task.session_id, + result_message_id: task.result_message_id, + created_at: task.created_at, + started_at: task.started_at, + ended_at: task.ended_at, + ...(task.error ? { error: task.error } : {}), + // The result is the Bot's finished answer, so a task that has not finished has + // none. + ...(task.status === 'done' && task.result ? { result: task.result } : {}), + }); + + /** + * @param {string} name the qualified tool name + * @param {any} args + */ + async function call(name, args = {}) { + calls.push({ name, arguments: args }); + const tool = String(name).split('__').pop(); + + if (tool === 'list_team') return mcp({ bots, groups }); + + if (tool === 'message_bot') { + const bot = findBot(args.to); + if (!bot) { + return { kind: 'mcp', isError: true, content: [{ type: 'text', text: 'unknown bot' }] }; + } + counter += 1; + const taskId = `task_${counter}`; + const batchId = `batch_${counter}`; + const text = String(args.message ?? ''); + tasks.set(taskId, { + task_id: taskId, + batch_id: batchId, + request_id: args.request_id, + kind: 'bot', + bot_id: bot.bot_id, + title: text.replace(/\s+/g, ' ').slice(0, 80), + status: hold ? 'running' : 'done', + chat_id: `bot:${bot.bot_id}`, + session_id: sessions[bot.bot_id] ?? `sess_${bot.bot_id}`, + // The desktop names the message that carried the turn in `msg_task_`, and + // the Bot's own answer gets a record of its own, which is what the receipt + // points at. + result_message_id: `msg_reply_${taskId}`, + result: replies[bot.bot_id] ?? `${bot.name} answers.`, + created_at: 1, + started_at: 2, + ended_at: 3, + }); + onTask?.(tasks.get(taskId)); + // The receipt is captured before the work starts, so it still says "queued". + return mcp({ + value: { + batch_id: batchId, + request_id: args.request_id, + notification: 'pending', + complete: false, + tasks: [ + { + task_id: taskId, + kind: 'bot', + bot_id: bot.bot_id, + title: text.replace(/\s+/g, ' ').slice(0, 80), + status: 'queued', + chat_id: `bot:${bot.bot_id}`, + created_at: 1, + }, + ], + }, + event: { kind: 'asked', botId: bot.bot_id, text }, + }); + } + + if (tool === 'post_group') { + const group = findGroup(args.group); + if (!group) { + return { kind: 'mcp', isError: true, content: [{ type: 'text', text: 'unknown group' }] }; + } + counter += 1; + const taskId = `task_${counter}`; + const batchId = `batch_${counter}`; + const text = String(args.message ?? ''); + tasks.set(taskId, { + task_id: taskId, + batch_id: batchId, + request_id: args.request_id, + kind: 'discussion', + title: text.replace(/\s+/g, ' ').slice(0, 80), + status: 'done', + chat_id: `group:${group.group_id}`, + session_id: sessions[group.group_id] ?? `sess_${group.group_id}`, + result_message_id: `msg_reply_${taskId}`, + result: replies[group.group_id] ?? `${group.name} answers.`, + created_at: 1, + started_at: 2, + ended_at: 3, + }); + onTask?.(tasks.get(taskId)); + return mcp({ + value: { + batch_id: batchId, + request_id: args.request_id, + notification: 'pending', + complete: false, + posted_to: group.name, + group_id: group.group_id, + first_speaker: [group.admin], + tasks: [ + { + task_id: taskId, + kind: 'discussion', + title: text.replace(/\s+/g, ' ').slice(0, 80), + status: 'queued', + chat_id: `group:${group.group_id}`, + created_at: 1, + }, + ], + }, + event: { kind: 'group_posted', groupId: group.group_id }, + }); + } + + if (tool === 'get_tasks') { + const task = args.task_id + ? tasks.get(args.task_id) + : [...tasks.values()].find((entry) => entry.batch_id === args.batch_id); + if (!task) { + // The team is asked about batches it knows; a batch it has no task for answers + // with an empty list rather than with an error. + return mcp({ + value: { + batch_id: args.batch_id ?? '', + request_id: 'req', + notification: 'idle', + complete: true, + tasks: [], + }, + }); + } + // The real team holds the query until the task settles or the wait runs out, so + // a task that is still running does not spin the caller. + if (task.status !== 'done' && task.status !== 'failed' && task.status !== 'cancelled') { + await new Promise((resolve) => setTimeout(resolve, 15)); + } + return mcp({ + value: { + batch_id: task.batch_id, + request_id: task.request_id, + notification: 'done', + complete: true, + tasks: [view(task)], + }, + }); + } + + if (tool === 'cancel_tasks') { + const task = args.task_id + ? tasks.get(args.task_id) + : [...tasks.values()].find((entry) => entry.batch_id === args.batch_id); + if (!task) { + return { kind: 'mcp', isError: true, content: [{ type: 'text', text: 'no such task' }] }; + } + task.status = 'cancelled'; + task.ended_at = 4; + return mcp({ + value: { + batch_id: task.batch_id, + request_id: task.request_id, + notification: 'done', + complete: true, + tasks: [view(task)], + }, + }); + } + + return mcp({ ok: true }); + } + + /** + * Puts a task into the team, the way a turn that already ran would have left it. + * + * @param {any} task + */ + function seedTask(task) { + const seeded = { + batch_id: 'batch_1', + request_id: 'req_1', + kind: 'bot', + bot_id: 'bot_b1', + title: 'a task', + status: 'running', + chat_id: 'bot:bot_b1', + session_id: 'sess-1', + created_at: 1, + started_at: 1, + ...task, + }; + tasks.set(seeded.task_id, seeded); + return seeded; + } + + return { call, calls, tasks, seedTask }; +} diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/ledger-hardening.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/ledger-hardening.test.mjs new file mode 100644 index 0000000..3150f91 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/ledger-hardening.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { mkdir, mkdtemp, readFile, readdir, stat, writeFile } from 'node:fs/promises'; +import { syncBuiltinESMExports } from 'node:module'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { spawnSync } from 'node:child_process'; +import test from 'node:test'; +import { Ledger } from '../miniapp/node/ledger.mjs'; + +// The scratch root lives in the operating system's temporary directory, so the suite +// runs the same way in the workspace, in a published package and in an installed one. +const root = join(tmpdir(), 'minimax-bot-bridge-tests', 'bridge-execution'); +async function fixture() { + await mkdir(root, { recursive: true }); + const dir = await mkdtemp(join(root, 'ledger-')); + const ledger = await new Ledger(dir).init(); + ledger.recordPermission({ requestId: 'durable', decision: 'deny', toolName: 'shell', deviceId: 'phone' }); + await ledger.persist(); + return { dir, ledger, path: join(dir, 'ledger.json') }; +} + +test('ledger serializes concurrent admitted records on the real filesystem', async () => { + const { dir, ledger, path } = await fixture(); + const writes = []; + for (let i = 0; i < 30; i += 1) { + ledger.recordPermission({ requestId: `p${i}`, decision: 'deny', toolName: 'x'.repeat(i % 2 ? 8 : 20000), deviceId: 'phone' }); + writes.push(ledger.persist()); + } + await Promise.all(writes); + const disk = JSON.parse(await readFile(path, 'utf8')); + assert.equal(disk.entries.length, 31); + assert.deepEqual(disk.entries.map(e => e.requestId), ledger.entries.map(e => e.requestId)); + assert.deepEqual(await readdir(dir), ['ledger.json']); + if (process.platform !== 'win32') assert.equal((await stat(path)).mode & 0o777, 0o600); +}); + +test('a blocked first write cannot overlap or overwrite a newer admitted snapshot', async () => { + const { ledger, path } = await fixture(); + const original = fs.promises.writeFile; + /** @type {(value?: any) => void} */ + let release = () => {}; + /** @type {(value?: any) => void} */ + let entered = () => {}; + const blocked = new Promise(resolve => { entered = resolve; }); + const gate = new Promise(resolve => { release = resolve; }); + let calls = 0; + const writes = []; + fs.promises.writeFile = async (...args) => { + calls += 1; + if (calls === 1) { entered(); await gate; } + return original(...args); + }; + syncBuiltinESMExports(); + try { + ledger.recordPermission({ requestId: 'first', toolName: 'shell', deviceId: 'phone', decision: 'deny' }); + writes.push(ledger.persist()); + await blocked; + ledger.recordPermission({ requestId: 'second', toolName: 'shell', deviceId: 'phone', decision: 'deny' }); + writes.push(ledger.persist()); + await new Promise(setImmediate); + assert.equal(calls, 1, 'the second filesystem write must wait for the first replacement'); + } finally { + release(); + await Promise.all(writes); + fs.promises.writeFile = original; + syncBuiltinESMExports(); + } + assert.equal(JSON.parse(await readFile(path, 'utf8')).entries.length, 3); +}); + +test('partial ENOSPC and rename failures preserve the previous valid ledger and do not poison the queue', async () => { + for (const operation of ['writeFile', 'rename']) { + const { ledger, path, dir } = await fixture(); + const previous = await readFile(path, 'utf8'); + ledger.recordPermission({ requestId: 'new', decision: 'allowOnce', toolName: 'shell', deviceId: 'phone' }); + const original = fs.promises[operation]; + /** @type {any} */ (fs.promises)[operation] = async (...args) => { + if (operation === 'writeFile') { + await original(args[0], String(args[1]).slice(0, 20), args[2]); + throw Object.assign(new Error('disk full'), { code: 'ENOSPC' }); + } + throw Object.assign(new Error('rename refused'), { code: 'EACCES' }); + }; + syncBuiltinESMExports(); + try { + await assert.rejects(ledger.persist(), { code: operation === 'writeFile' ? 'ENOSPC' : 'EACCES' }); + } finally { + fs.promises[operation] = original; + syncBuiltinESMExports(); + } + assert.equal(await readFile(path, 'utf8'), previous); + assert.deepEqual(await readdir(dir), ['ledger.json']); + await ledger.persist(); + assert.equal((await new Ledger(dir).init()).entries.length, 2); + } +}); + +test('process death before atomic replacement leaves the previous ledger readable', async () => { + const { dir, path } = await fixture(); + const previous = await readFile(path, 'utf8'); + const moduleUrl = new URL('../miniapp/node/ledger.mjs', import.meta.url).href; + const script = `import fs from 'node:fs'; import {syncBuiltinESMExports} from 'node:module'; + import {Ledger} from ${JSON.stringify(moduleUrl)}; + const l = await new Ledger(${JSON.stringify(dir)}).init(); l.recordPermission({requestId:'new'}); + const original = fs.promises.writeFile; + fs.promises.writeFile = async (path, text, options) => { + await original(path, text.slice(0, 20), options); process.exit(73); + }; syncBuiltinESMExports(); await l.persist();`; + const child = spawnSync(process.execPath, ['--input-type=module', '-e', script], { encoding: 'utf8' }); + assert.equal(child.status, 73, child.stderr); + assert.equal(await readFile(path, 'utf8'), previous); + assert.equal((await new Ledger(dir).init()).entries.length, 1); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/ledger.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/ledger.test.mjs new file mode 100644 index 0000000..cffac98 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/ledger.test.mjs @@ -0,0 +1,123 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Ledger } from '../miniapp/node/ledger.mjs'; +import { PendingSet } from '../miniapp/node/pending.mjs'; + +async function withLedger(fn) { + const dir = await mkdtemp(join(tmpdir(), 'bridge-ledger-')); + try { + const ledger = await new Ledger(dir).init(); + await fn(ledger); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +test('the ledger starts empty', async () => { + await withLedger(async (ledger) => { + const report = ledger.report({ days: 7 }); + assert.equal(report.totals.turns, 0); + assert.equal(report.byBot.length, 0); + }); +}); + +test('the ledger counts turns and tools per bot', async () => { + await withLedger(async (ledger) => { + ledger.recordTurn({ botId: 'b1', botName: 'Writer', chars: 120, durationMs: 900 }); + ledger.recordTool({ botId: 'b1', toolName: 'recall', ok: true, durationMs: 40 }); + + const report = ledger.report({ days: 7 }); + assert.equal(report.totals.turns, 1); + assert.equal(report.totals.tools, 1); + assert.equal(report.totals.chars, 120); + assert.equal(report.byBot[0].botId, 'b1'); + assert.equal(report.byBot[0].name, 'Writer'); + assert.equal(report.byBot[0].tools, 1); + }); +}); + +test('the ledger counts errors', async () => { + await withLedger(async (ledger) => { + ledger.recordTurn({ botId: 'b1', error: true }); + const report = ledger.report({ days: 7 }); + assert.equal(report.totals.errors, 1); + }); +}); + +test('the ledger buckets by day over a week', async () => { + await withLedger(async (ledger) => { + ledger.recordTurn({ botId: 'b1' }); + const report = ledger.report({ days: 7 }); + assert.equal(report.buckets.length, 7); + assert.equal(report.buckets[report.buckets.length - 1].turns, 1); + }); +}); + +test('the ledger buckets by hour over a day', async () => { + await withLedger(async (ledger) => { + ledger.recordTurn({ botId: 'b1' }); + const report = ledger.report({ days: 1 }); + assert.equal(report.buckets.length, 24); + }); +}); + +test('the ledger survives a reload', async () => { + const dir = await mkdtemp(join(tmpdir(), 'bridge-ledger-')); + try { + const ledger = await new Ledger(dir).init(); + ledger.recordTurn({ botId: 'b1', botName: 'Writer' }); + await ledger.persist(); + + const reloaded = await new Ledger(dir).init(); + assert.equal(reloaded.report({ days: 7 }).totals.turns, 1); + } finally { + await rm(dir, { recursive: true, force: true }); + } +}); + +test('the ledger keeps only the most recent entries', async () => { + await withLedger(async (ledger) => { + ledger.maxEntries = 3; + for (let i = 0; i < 10; i += 1) ledger.recordTurn({ botId: 'b1' }); + assert.equal(ledger.entries.length, 3); + }); +}); + +test('pending approvals expire', () => { + let now = 1000; + const pending = new PendingSet({ ttlMs: 100, now: () => now }); + pending.add('permission', { requestId: 'r1' }, 'session'); + + assert.equal(pending.size, 1); + now = 2000; + assert.equal(pending.size, 0); + assert.equal(pending.get('r1'), undefined); +}); + +test('pending requests are resolved once', () => { + const pending = new PendingSet(); + pending.add('question', { requestId: 'q1' }, 'session'); + assert.equal(pending.resolve('q1'), true); + assert.equal(pending.resolve('q1'), false); +}); + +test('pending requests are filtered by kind', () => { + const pending = new PendingSet(); + pending.add('permission', { requestId: 'p1' }, 's'); + pending.add('question', { requestId: 'q1' }, 's'); + + assert.equal(pending.list('permission').length, 1); + assert.equal(pending.list('question').length, 1); + assert.equal(pending.list().length, 2); +}); + +test('pending requests carry an expiry', () => { + let now = 5000; + const pending = new PendingSet({ ttlMs: 60000, now: () => now }); + const stored = pending.add('permission', { requestId: 'p1' }, 's'); + assert.equal(stored.expiresAt, 65000); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/op-cache.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/op-cache.test.mjs new file mode 100644 index 0000000..f7e48a4 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/op-cache.test.mjs @@ -0,0 +1,147 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { createFakeTeam, TEAM_TOOL_DESCRIPTORS } from './fake-team.mjs'; + +/** + * The operation cache. + * + * The bridge answers an operation once, even if the phone sends it twice: the result + * is cached for ten minutes under a key built from the operation type and the + * operation id. The key has to say which operation it is, or one frame poisons the + * key for every other frame of its type. + */ + +async function makeBridge() { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-opcache-')); + const sent = []; + const team = createFakeTeam(); + /** The turns the team was asked to run. */ + const teamTurns = []; + + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { + async list() { + return { tools: TEAM_TOOL_DESCRIPTORS, partial: false }; + }, + async call(name, args) { + const result = await team.call(name, args); + if (String(name).endsWith('message_bot')) teamTurns.push(args); + return result; + }, + }, + agent: { + async run(input) { + // A turn the bridge runs on the desktop. + teamTurns.push(input); + return { sessionId: 'sess-1', turnId: 'turn-1', status: 'finished', text: 'Hello' }; + }, + async reply() {}, + async answer() { + return { sessionId: 'sess-1', status: 'finished' }; + }, + }, + }; + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = (frame) => { + sent.push(frame); + return true; + }; + + await bridge.start(); + + return { + bridge, + sent, + teamTurns, + async dispose() { + bridge.stop(); + await rm(dataDir, { recursive: true, force: true }); + }, + }; +} + +/** An operation frame as a phone sends it. */ +function opFrame({ type = 'op.send_message', opId, from, payload = { chatId: 'bot:bot_b1', text: 'hello' } }) { + const frame = { v: 1, id: opId, ts: Date.now(), type, source: 'app', payload }; + if (opId !== undefined) frame.opId = opId; + if (from !== undefined) frame.from = from; + return frame; +} + +test('an operation without an id is refused instead of poisoning the cache', async () => { + const { bridge, teamTurns, sent, dispose } = await makeBridge(); + try { + // Neither an opId nor an id. + await bridge.handleFrame({ v: 1, ts: Date.now(), type: 'op.send_message', source: 'app', payload: {} }); + + // The operation did not run. + assert.equal(teamTurns.length, 0); + + // And the cache holds no entry under a key that every later frame would share. + assert.equal(bridge.opCache.size, 0); + + // The phone was told, rather than being left waiting. + const errors = sent.filter((frame) => frame.type === 'err'); + assert.equal(errors.length, 1); + assert.equal(errors[0].payload.code, 'INVALID_ARGUMENTS'); + } finally { + await dispose(); + } +}); + +test('a later operation of the same type still runs', async () => { + const { bridge, teamTurns, dispose } = await makeBridge(); + try { + await bridge.handleFrame({ v: 1, ts: Date.now(), type: 'op.send_message', source: 'app', payload: {} }); + assert.equal(teamTurns.length, 0); + + await bridge.handleFrame(opFrame({ opId: 'op-1', from: 'phone_1' })); + assert.equal(teamTurns.length, 1); + } finally { + await dispose(); + } +}); + +test('one device cannot swallow another device operation', async () => { + const { bridge, teamTurns, dispose } = await makeBridge(); + try { + // The same operation id, from two different phones. + await bridge.handleFrame(opFrame({ opId: 'op-same', from: 'phone_1' })); + await bridge.handleFrame(opFrame({ opId: 'op-same', from: 'phone_2' })); + + // Both ran. The second one is not answered with the first one's result. + assert.equal(teamTurns.length, 2); + } finally { + await dispose(); + } +}); + +test('the same device repeating an operation still gets one answer', async () => { + const { bridge, teamTurns, sent, dispose } = await makeBridge(); + try { + await bridge.handleFrame(opFrame({ opId: 'op-1', from: 'phone_1' })); + await bridge.handleFrame(opFrame({ opId: 'op-1', from: 'phone_1' })); + + // The operation ran once, and the phone was answered twice. + assert.equal(teamTurns.length, 1); + const acks = sent.filter((frame) => frame.type === 'ack'); + assert.equal(acks.length, 2); + } finally { + await dispose(); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/pending-hardening.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/pending-hardening.test.mjs new file mode 100644 index 0000000..d188968 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/pending-hardening.test.mjs @@ -0,0 +1,74 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { PendingSet } from '../miniapp/node/pending.mjs'; +import { AgentRunner } from '../miniapp/node/agent.mjs'; + +test('a duplicate request preserves its first payload, expiry and in-flight decision', () => { + let now = 0; + const pending = new PendingSet({ ttlMs: 100, now: () => now }); + pending.add('permission', { requestId: 'p', toolName: 'first' }, 's1'); + const item = pending.claim('p'); + now = 50; + pending.add('permission', { requestId: 'p', toolName: 'different' }, 's2'); + assert.equal(pending.claim('p'), undefined); + assert.equal(pending.get('p'), item); + assert.equal(item.request.toolName, 'first'); + assert.equal(item.expiresAt, 100); + pending.resolve('p'); + pending.add('permission', { requestId: 'p' }, 's2'); + assert.equal(pending.claim('p'), undefined); + pending.add('question', { requestId: 'next' }, 's1'); + assert.ok(pending.claim('next')); +}); + +test('expiry at claim refuses Host work and emits a resolution exactly once', async () => { + let now = 0; + let calls = 0; + const events = []; + const pending = new PendingSet({ ttlMs: 100, now: () => now }); + const runner = new AgentRunner({ agent: { reply: async () => { calls += 1; } } }, { + pending, onEvent: (type, payload) => events.push({ type, ...payload }), + }); + runner.handleAgentEvent('bot:b', { type: 'permission.requested', requestId: 'p', turnId: 't', sessionId: 's', toolName: 'shell' }, new AbortController()); + now = 100; + await assert.rejects(runner.reply('p', 'deny'), { code: 'REQUEST_NOT_FOUND' }); + pending.expire(); + assert.equal(calls, 0); + assert.equal(events.filter(e => e.type === 'evt.permission_resolved' && e.requestId === 'p').length, 1); +}); + +test('expiry publishes resolution even without another client lookup', async () => { + const pending = new PendingSet({ ttlMs: 15 }); + const events = []; + new AgentRunner({}, { pending, onEvent: (type, payload) => events.push({ type, ...payload }) }); + pending.add('permission', { requestId: 'timer' }, 's'); + await new Promise(resolve => setTimeout(resolve, 35)); + assert.equal(events.filter(event => event.type === 'evt.permission_resolved' && event.requestId === 'timer').length, 1); +}); + +test('a Host questionnaire expiry cannot be extended by the bridge retention window', () => { + let now = 0; + const pending = new PendingSet({ ttlMs: 100, now: () => now }); + pending.add('question', { requestId: 'q', expiresAt: 20 }, 's'); + now = 20; + assert.equal(pending.claim('q'), undefined); +}); + +test('a terminal run clears its requests but a waiting questionnaire survives its turn finish', async () => { + for (const status of ['finished', 'aborted', 'error', 'waiting_for_user']) { + const pending = new PendingSet(); + const question = { requestId: 'q', turnId: 't', mode: 'questionnaire', request: { schemaVersion: 2, id: 'question-q', steps: [] } }; + const context = { agent: {} }; + const runner = new AgentRunner(context, { pending, router: /** @type {any} */ ({ + route: () => ({ kind: 'bot' }), + hosted: async ({ onEvent }) => { + onEvent({ type: 'question.requested', sessionId: 's', ...question }); + const hostStatus = status === 'waiting_for_user' ? 'finished' : status; + onEvent({ type: 'turn.finished', turnId: 't', status: hostStatus }); + return { sessionId: 's', turnId: 't', status: hostStatus, ...(status === 'waiting_for_user' ? { pendingQuestion: question } : {}) }; + }, + }) }); + await runner.run({ chatId: 'bot:b', text: 'work' }); + assert.equal(pending.size, status === 'waiting_for_user' ? 1 : 0, status); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/relay-client.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/relay-client.test.mjs new file mode 100644 index 0000000..de8958d --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/relay-client.test.mjs @@ -0,0 +1,163 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { RelayClient } from '../miniapp/node/relay-client.mjs'; + +const LOGGER = { info() {}, warn() {}, error() {}, debug() {} }; + +test('a relay address given as http is used as ws for the socket', () => { + const client = new RelayClient({ + url: 'http://127.0.0.1:8787', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + assert.equal(client.wsUrl(), 'ws://127.0.0.1:8787'); + assert.equal(client.httpUrl(), 'http://127.0.0.1:8787'); +}); + +test('a relay address given as wss is used as https for the pairing request', () => { + const client = new RelayClient({ + url: 'wss://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + assert.equal(client.wsUrl(), 'wss://relay.example.workers.dev'); + assert.equal(client.httpUrl(), 'https://relay.example.workers.dev'); +}); + +test('a trailing slash never doubles up in the endpoint', () => { + const client = new RelayClient({ + url: 'http://127.0.0.1:8787/', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + assert.match(client.endpoint(), /^ws:\/\/127\.0\.0\.1:8787\/r\/default\/ws\?/); + assert.equal(client.endpoint().includes('8787//'), false); +}); + +test('the endpoint carries the role, the subject and the token', () => { + const client = new RelayClient({ + url: 'http://127.0.0.1:8787', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'a token', + logger: LOGGER, + }); + const url = new URL(client.endpoint()); + assert.equal(url.pathname, '/r/default/ws'); + assert.equal(url.searchParams.get('role'), 'bridge'); + assert.equal(url.searchParams.get('subject'), 'bridge-1'); + assert.equal(url.searchParams.get('token'), 'a token'); +}); + +test('the endpoint carries a ticket instead of the token when there is one', () => { + const client = new RelayClient({ + url: 'http://127.0.0.1:8787', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'a token', + logger: LOGGER, + }); + const url = new URL(client.endpoint('the-ticket')); + assert.equal(url.searchParams.get('ticket'), 'the-ticket'); + // The token itself does not travel in the URL. + assert.equal(url.searchParams.get('token'), null); +}); + +test('the ticket is asked for over a request that can carry a header', async () => { + const seen = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, init) => { + seen.push({ url: String(url), init }); + return new Response(JSON.stringify({ ticket: 'the-ticket' }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + try { + const client = new RelayClient({ + url: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + + const ticket = await client.fetchTicket(); + + assert.equal(ticket, 'the-ticket'); + assert.equal(seen[0].url, 'https://relay.example.workers.dev/r/default/ws-ticket'); + assert.equal(seen[0].init.headers.authorization, 'Bearer tok'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('a relay that does not offer tickets leaves the token where it was', async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = async () => new Response('nope', { status: 404 }); + + try { + const client = new RelayClient({ + url: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + + assert.equal(await client.fetchTicket(), null); + assert.equal(new URL(client.endpoint(null)).searchParams.get('token'), 'tok'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('the pairing request is sent over http even when the relay is addressed as ws', async () => { + const seen = []; + const originalFetch = globalThis.fetch; + globalThis.fetch = async (url, init) => { + seen.push({ url: String(url), init }); + return new Response(JSON.stringify({ code: '123456', expiresAt: Date.now() + 60000 }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }); + }; + + try { + const client = new RelayClient({ + url: 'ws://127.0.0.1:8787', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + const result = await client.createPairingCode(); + assert.equal(result.code, '123456'); + assert.equal(seen.length, 1); + assert.equal(seen[0].url, 'http://127.0.0.1:8787/r/default/pair/code'); + assert.equal(seen[0].init.method, 'POST'); + assert.equal(seen[0].init.headers.authorization, 'Bearer tok'); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test('a bridge with no token refuses to ask for a pairing code', async () => { + const client = new RelayClient({ + url: 'http://127.0.0.1:8787', + accountId: 'default', + bridgeId: 'bridge-1', + token: '', + logger: LOGGER, + }); + assert.equal(client.configured, false); + await assert.rejects(() => client.createPairingCode(), /not configured/i); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/relay-generation.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/relay-generation.test.mjs new file mode 100644 index 0000000..24f9df4 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/relay-generation.test.mjs @@ -0,0 +1,197 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { RelayClient } from '../miniapp/node/relay-client.mjs'; + +const A = { url: 'https://relay-a.example', accountId: 'room-a', bridgeId: 'bridge-a', token: 'TOKEN_A', name: 'Desk' }; +const B = { url: 'https://relay-b.example', accountId: 'room-b', bridgeId: 'bridge-b', token: 'TOKEN_B', name: 'Desk' }; +const LOGGER = { info() {}, warn() {}, error() {}, debug() {} }; +const settle = () => new Promise(setImmediate); +function deferred() { + /** @type {(value?: any) => void} */ + let resolve = () => {}; + const promise = new Promise((done) => { resolve = done; }); + return { promise, resolve }; +} +class Socket { + static all = []; + constructor(url) { + this.url = String(url); + this.readyState = 0; + this.listeners = new Map(); + this.sent = []; + Socket.all.push(this); + } + addEventListener(type, fn) { this.listeners.set(type, fn); } + fire(type, event = {}) { this.listeners.get(type)?.(event); } + open() { this.readyState = 1; this.fire('open'); } + send(raw) { this.sent.push(raw); } + close() { this.readyState = 3; } +} +function network(t) { + Socket.all = []; + const original = globalThis.WebSocket; + globalThis.WebSocket = /** @type {any} */ (Socket); + t.after(() => { globalThis.WebSocket = original; }); + t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status: 404 })); +} + +for (const lifecycle of ['A to B', 'A to B to A', 'stop then start']) { + test(`a pending ticket cannot open a socket after ${lifecycle}`, async (t) => { + network(t); + const pending = deferred(); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => { + calls += 1; + return calls === 1 ? pending.promise : new Response(JSON.stringify({ ticket: `CURRENT_${calls}` })); + }); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + client.start(); + if (lifecycle === 'stop then start') { client.stop(); client.start(); } + else { client.reconfigure(B); if (lifecycle === 'A to B to A') client.reconfigure(A); } + await settle(); + const live = client.socket; + const count = Socket.all.length; + pending.resolve(new Response(JSON.stringify({ ticket: 'STALE_TICKET_A' }))); + await settle(); + assert.equal(Socket.all.length, count, 'stale completion must not construct a socket'); + assert.equal(client.socket, live); + assert.ok(Socket.all.every((socket) => !socket.url.includes('STALE_TICKET_A'))); + }); +} + +test('a ticket response body that finishes after reconfigure is also stale', async (t) => { + network(t); + const body = deferred(); + let calls = 0; + t.mock.method(globalThis, 'fetch', async () => ++calls === 1 + ? { ok: true, json: () => body.promise } + : new Response('{}', { status: 404 })); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + client.start(); + await settle(); + client.reconfigure(B); + await settle(); + body.resolve({ ticket: 'STALE_BODY_A' }); + await settle(); + assert.equal(Socket.all.length, 1); + assert.match(Socket.all[0].url, /relay-b\.example/); +}); + +test('old socket open/message/close callbacks are inert, including ticket fallback', async (t) => { + network(t); + t.mock.method(globalThis, 'fetch', async () => new Response(JSON.stringify({ ticket: 'ticket' }))); + const statuses = []; + const frames = []; + const client = new RelayClient({ ...A, logger: LOGGER, onStatus: (value) => statuses.push(value), onFrame: (frame) => frames.push(frame) }); + t.after(() => client.stop()); + client.start(); + await settle(); + const old = Socket.all[0]; + client.reconfigure(B); + await settle(); + const live = Socket.all.at(-1); + client.send({ private: 'B_ONLY' }); + const before = statuses.length; + old.fire('open'); + old.fire('message', { data: JSON.stringify({ private: 'A_ONLY' }) }); + old.fire('close'); + assert.equal(statuses.length, before, 'stale open must not report online'); + assert.deepEqual(frames, [], 'stale messages must not reach the bridge'); + assert.deepEqual(live.sent, [], 'stale open must not flush the current queue'); + assert.equal(client.socket, live); + assert.equal(Socket.all.length, 2, 'stale close must not trigger token fallback'); + live.open(); + assert.deepEqual(live.sent, [JSON.stringify({ private: 'B_ONLY' })]); +}); + +for (const changed of [{ url: B.url }, { accountId: B.accountId }, { bridgeId: B.bridgeId }]) { + test(`offline frames are discarded on ${Object.keys(changed)[0]} identity change`, async (t) => { + network(t); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + client.send({ private: 'A_ONLY' }); + client.reconfigure({ ...A, ...changed }); + await settle(); + Socket.all[0].open(); + assert.deepEqual(Socket.all[0].sent, []); + }); +} + +test('same-identity token refresh and stop/start preserve offline frames', async (t) => { + network(t); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + client.send({ private: 'KEEP' }); + client.reconfigure({ ...A, url: 'wss://relay-a.example/', token: 'REFRESHED_A' }); + client.stop(); + client.start(); + await settle(); + const live = Socket.all.at(-1); + live.open(); + assert.deepEqual(live.sent, [JSON.stringify({ private: 'KEEP' })]); +}); + +test('reconfigure clears the retry timer and an already-queued old callback is inert', async (t) => { + network(t); + const timers = []; + t.mock.method(globalThis, 'setTimeout', (fn) => { + const timer = { fn, unref() {} }; + timers.push(timer); + return timer; + }); + const cleared = []; + t.mock.method(globalThis, 'clearTimeout', (timer) => cleared.push(timer)); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + client.scheduleReconnect(); + const old = timers[0]; + client.reconfigure(B); + await settle(); + const live = client.socket; + old.fn(); + await settle(); + assert.ok(cleared.includes(old)); + assert.equal(Socket.all.length, 1); + assert.equal(client.socket, live); +}); + +test('a malformed rotation response cannot expose credentials in its error', async (t) => { + network(t); + t.mock.method(globalThis, 'fetch', async () => new Response('TOKEN_A is not JSON')); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + await assert.rejects(client.rotateToken(), (error) => { + assert.ok(error instanceof Error); + assert.ok(!error.message.includes('TOKEN_A')); + return true; + }); +}); + +for (const lifecycle of ['A to B', 'A to B to A', 'stop then start']) { + test(`a pending rotation cannot replace credentials after ${lifecycle}`, async (t) => { + network(t); + const rotation = deferred(); + const seen = []; + t.mock.method(globalThis, 'fetch', async (url, init) => { + seen.push({ url: String(url), authorization: init.headers.authorization }); + return String(url).endsWith('/bridge/token') ? rotation.promise : new Response('{}', { status: 404 }); + }); + const client = new RelayClient({ ...A, logger: LOGGER }); + t.after(() => client.stop()); + const result = assert.rejects(client.rotateToken(), /configuration changed|stale/i); + if (lifecycle === 'stop then start') { client.stop(); client.start(); } + else { client.reconfigure(B); if (lifecycle === 'A to B to A') client.reconfigure(A); } + rotation.resolve(new Response(JSON.stringify({ token: 'ROTATED_A' }))); + await result; + await settle(); + const expected = lifecycle === 'A to B' ? 'TOKEN_B' : 'TOKEN_A'; + assert.equal(client.token, expected); + client.connect(); + await settle(); + assert.ok(seen.every((request) => !request.url.includes('relay-b.example') || request.authorization === 'Bearer TOKEN_B')); + assert.ok(Socket.all.every((socket) => !socket.url.includes('ROTATED_A'))); + }); +} diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/relay-reconfigure.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/relay-reconfigure.test.mjs new file mode 100644 index 0000000..df92654 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/relay-reconfigure.test.mjs @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { RelayClient } from '../miniapp/node/relay-client.mjs'; + +/** + * Changing the relay configuration. + * + * The desktop can be pointed at another relay. The socket that was open against the + * old address has to be closed on purpose, and its close event must not be allowed to + * clear the socket that replaced it. + */ + +const LOGGER = { info() {}, warn() {}, error() {}, debug() {} }; + +/** A socket the test drives by hand. */ +class FakeSocket { + constructor(url) { + this.url = url; + this.readyState = 1; + this.closed = false; + this.listeners = new Map(); + } + + addEventListener(name, handler) { + this.listeners.set(name, handler); + } + + send() {} + + close() { + this.closed = true; + this.readyState = 3; + } + + /** Fires the event, the way the socket would. */ + fire(name, event = {}) { + this.listeners.get(name)?.(event); + } +} + +/** + * Installs a fake WebSocket and a fetch that offers no ticket. + * + * @returns {{sockets: FakeSocket[], restore: () => void}} + */ +function withFakeSockets() { + const originalWebSocket = globalThis.WebSocket; + const originalFetch = globalThis.fetch; + const sockets = []; + + globalThis.WebSocket = /** @type {any} */ ( + class extends FakeSocket { + constructor(url) { + super(url); + sockets.push(this); + } + } + ); + globalThis.fetch = async () => new Response('nope', { status: 404 }); + + return { + sockets, + restore() { + globalThis.WebSocket = originalWebSocket; + globalThis.fetch = originalFetch; + }, + }; +} + +/** Lets the client's promises settle. */ +async function settle() { + for (let i = 0; i < 5; i += 1) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } +} + +test('the old socket close event cannot clear the socket that replaced it', async () => { + const { sockets, restore } = withFakeSockets(); + try { + const statuses = []; + const client = new RelayClient({ + url: 'https://old.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + onStatus: (connected) => statuses.push(connected), + }); + client.start(); + await settle(); + + const old = sockets[0]; + old.fire('open'); + + // The desktop points the client at another relay, the way it always did. + client.url = 'https://new.example.workers.dev'; + client.accountId = 'default'; + client.bridgeId = 'bridge-1'; + client.token = 'tok'; + client.name = 'Desk'; + client.start(); + await settle(); + + const live = sockets[1]; + live.fire('open'); + assert.equal(client.socket, live); + + // The old socket's close event arrives late, after the new one is live. + old.fire('close'); + + // The live socket is still the live socket. + assert.equal(client.socket, live); + assert.equal(client.connected, true); + // The client did not report itself as offline. + assert.equal(statuses[statuses.length - 1], true); + // And it did not open a third connection. + assert.equal(sockets.length, 2); + + client.stop(); + } finally { + restore(); + } +}); + +test('the socket against the old address is closed when the configuration changes', async () => { + const { sockets, restore } = withFakeSockets(); + try { + const client = new RelayClient({ + url: 'https://old.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + client.start(); + await settle(); + + assert.equal(sockets.length, 1); + const old = sockets[0]; + + client.reconfigure({ + url: 'https://new.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + name: 'Desk', + }); + await settle(); + + // The old socket was closed on purpose. + assert.equal(old.closed, true); + // And the new connection is against the new address. + assert.equal(sockets.length, 2); + assert.match(sockets[1].url, /new\.example\.workers\.dev/); + + client.stop(); + } finally { + restore(); + } +}); + +test('a configuration change does not leave the old socket open', async () => { + const { sockets, restore } = withFakeSockets(); + try { + const client = new RelayClient({ + url: 'https://old.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + logger: LOGGER, + }); + client.start(); + await settle(); + sockets[0].fire('open'); + + client.reconfigure({ + url: 'https://new.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + token: 'tok', + name: 'Desk', + }); + await settle(); + + assert.equal(sockets[0].closed, true); + client.stop(); + } finally { + restore(); + } +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/relay-revoke-device.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/relay-revoke-device.test.mjs new file mode 100644 index 0000000..e11106b --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/relay-revoke-device.test.mjs @@ -0,0 +1,36 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { RelayClient } from '../miniapp/node/relay-client.mjs'; + +const OPTIONS = { url: 'https://relay.example', accountId: 'room /%', bridgeId: 'desktop', token: 'owner-proof' }; + +for (const deviceId of ['.', '..', '%2e', '%2e%2e', '/', '\\', '%', 'phone/slash', 'phone space', 'me', '手机-雪']) { + test(`owner revocation transports exact device ID ${JSON.stringify(deviceId)} outside the URL`, async (t) => { + let calls = 0; + t.mock.method(globalThis, 'fetch', async (url, init) => { + calls += 1; + assert.equal(new URL(url).pathname, '/r/room%20%2F%25/devices/revoke'); + assert.equal(init.method, 'POST'); + assert.equal(init.headers.authorization, 'Bearer owner-proof'); + assert.equal(init.headers['content-type'], 'application/json'); + assert.deepEqual(JSON.parse(init.body), { deviceId }); + return new Response('{"revoked":true}', { status: 200 }); + }); + const client = new RelayClient(OPTIONS); + assert.equal(await client.revokeDevice(deviceId), true); + assert.equal(calls, 1); + }); +} + +test('unconfigured revocation does not issue a request', async (t) => { + t.mock.method(globalThis, 'fetch', async () => { assert.fail('must not fetch'); }); + assert.equal(await new RelayClient().revokeDevice('.'), false); +}); + +for (const status of [401, 404, 500]) { + test(`owner revocation reports relay failure ${status}`, async (t) => { + t.mock.method(globalThis, 'fetch', async () => new Response('{}', { status })); + assert.equal(await new RelayClient(OPTIONS).revokeDevice('.'), false); + }); +} diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/rotation.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/rotation.test.mjs new file mode 100644 index 0000000..b9892f3 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/rotation.test.mjs @@ -0,0 +1,130 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { saveConfig } from '../miniapp/node/config.mjs'; + +const LOGGER = { info() {}, warn() {}, error() {}, debug() {} }; + +/** + * A context with just enough of the Host for the bridge to start. + * + * @param {string} dataDir + * @param {Record} [extra] + */ +function fakeContext(dataDir, extra = {}) { + return { + dataDir, + logger: LOGGER, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + ...extra, + }; +} + +test('a rotated bridge token is written down', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-rotate-')); + try { + const stored = { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'the-old-token', + bridgeName: 'Desktop', + }; + await saveConfig(dataDir, stored); + + const bridge = new Bridge(fakeContext(dataDir), { config: stored, logger: LOGGER }); + + // The relay is not reachable, so the rotation fails — and nothing may be + // written, so the token on disk is still the one that works. + await assert.rejects(() => bridge.rotateToken()); + + const onDisk = await readFile(join(dataDir, 'bridge-token'), 'utf8'); + assert.equal(onDisk.trim(), 'the-old-token'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); + +test('a rotation that succeeds replaces the token on disk', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'bridge-rotate-')); + try { + const bridge = new Bridge(fakeContext(dataDir), { + config: { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'the-old-token', + bridgeName: 'Desktop', + }, + logger: LOGGER, + }); + + // A relay that answers with a token of its own. + bridge.relay.rotateToken = async () => { + bridge.relay.token = 'the-new-token'; + return 'the-new-token'; + }; + + const token = await bridge.rotateToken(); + + assert.equal(token, 'the-new-token'); + assert.equal(bridge.config.bridgeToken, 'the-new-token'); + + // The token has to be on disk as well: the bridge can rotate while it starts, + // before anything else has had a chance to listen, and a rotation that was + // not written down would leave the desktop holding a revoked token. + const stored = await readFile(join(dataDir, 'bridge-token'), 'utf8'); + assert.equal(stored.trim(), 'the-new-token'); + } finally { + await rm(dataDir, { recursive: true, force: true }); + } +}); + +test('the bridge reads its own expiry from the token it holds', () => { + const payload = { + v: 1, + sub: 'bridge-1', + role: 'bridge', + exp: 1234567890000, + jti: 'x', + acc: 'default', + }; + const token = `${Buffer.from(JSON.stringify(payload)).toString('base64url')}.sig`; + + const bridge = new Bridge(fakeContext('/tmp'), { + config: { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: token, + bridgeName: 'Desktop', + }, + logger: LOGGER, + }); + + // The token is signed, not encrypted: the payload can be read, so the bridge + // can tell that the token is about to run out. + const expiresAt = bridge.relay.tokenExpiresAt(); + assert.equal(expiresAt, 1234567890000); + assert.equal(expiresAt > Date.now(), false); +}); + +test('a token it cannot read has no expiry', () => { + const bridge = new Bridge(fakeContext('/tmp'), { + config: { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'not-a-token', + bridgeName: 'Desktop', + }, + logger: LOGGER, + }); + + assert.equal(bridge.relay.tokenExpiresAt(), null); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/server.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/server.test.mjs new file mode 100644 index 0000000..586094a --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/server.test.mjs @@ -0,0 +1,270 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { handle } from '../miniapp/node/server.mjs'; + +const SECRET = 'the-per-process-secret'; + +/** The address the Host assigned to the bridge in these tests. */ +const HOST = '127.0.0.1:53378'; +const SURFACE = { host: '127.0.0.1', port: 53378 }; + +/** + * A request the surface can read. + * + * `readJson` walks the request as an async iterable of chunks, so the fake has to + * be one. It is typed as `any` because it stands in for a socket request. + * + * The `Host` header is the address the request was sent to, and the bridge only + * answers a request that names it — a browser always sends it. + * + * @returns {any} + */ +function fakeRequest(method, path, { headers = {}, body = null } = {}) { + const chunks = body === null ? [] : [Buffer.from(JSON.stringify(body), 'utf8')]; + return { + method, + url: `http://127.0.0.1${path}`, + headers: { host: HOST, ...headers }, + async *[Symbol.asyncIterator]() { + for (const chunk of chunks) yield chunk; + }, + }; +} + +/** + * A response the surface can write to. + * + * @returns {any} + */ +function fakeResponse() { + return { + status: 0, + headers: {}, + body: '', + writeHead(status, headers) { + this.status = status; + this.headers = headers; + }, + end(chunk) { + this.body = chunk; + }, + }; +} + +/** The dependencies the surface needs, with the configuration the desktop starts from. */ +function makeDeps(overrides = {}) { + const state = { + config: { + relayUrl: 'https://relay.example.workers.dev', + accountId: 'default', + bridgeId: 'bridge-1', + bridgeToken: 'token-1', + bridgeName: 'Desktop', + }, + }; + return { + state, + bridge: { + relay: { configured: true, connected: true, listDevices: async () => [] }, + diagnostics: () => ({}), + desktopDiagnostics: () => ({}), + pending: { list: () => [] }, + readTeam: async () => ({ members: [] }), + tools: { catalog: [] }, + }, + context: {}, + secret: SECRET, + surface: SURFACE, + getConfig: () => state.config, + setConfig: async (next) => { + state.config = next; + }, + ...overrides, + }; +} + +const TRUSTED = { 'content-type': 'application/json', 'x-bridge-secret': SECRET }; + +async function postConfig(body, deps = makeDeps()) { + const response = fakeResponse(); + await handle(fakeRequest('POST', '/api/config', { headers: TRUSTED, body }), response, deps); + return { response, deps }; +} + +test('a relay address that is not one the bridge may use is refused', async () => { + for (const relayUrl of [ + 'http://169.254.169.254', + 'http://10.0.0.5:8787', + 'http://192.168.1.10:8787', + 'http://relay.example.workers.dev', + 'file:///etc/passwd', + ]) { + const { response, deps } = await postConfig({ relayUrl }); + assert.equal(response.status, 400, `expected ${relayUrl} to be refused`); + // The configuration is untouched. + assert.equal(deps.state.config.relayUrl, 'https://relay.example.workers.dev'); + } +}); + +test('pointing the bridge at a different relay without a token for it is refused', async () => { + // This is the one that hands the long-lived bridge token to a third party: the + // token was minted for the current relay, and it must not travel to another one. + const { response, deps } = await postConfig({ relayUrl: 'https://other.example.workers.dev' }); + + assert.equal(response.status, 400); + assert.equal(deps.state.config.relayUrl, 'https://relay.example.workers.dev'); + assert.equal(deps.state.config.bridgeToken, 'token-1'); +}); + +test('pointing the bridge at a different account without a token for it is refused', async () => { + const { response, deps } = await postConfig({ accountId: 'other-account' }); + + assert.equal(response.status, 400); + assert.equal(deps.state.config.accountId, 'default'); +}); + +test('a different relay is accepted when a token minted for it comes with it', async () => { + const { response, deps } = await postConfig({ + relayUrl: 'https://other.example.workers.dev', + bridgeToken: 'token-2', + }); + + assert.equal(response.status, 200); + assert.equal(deps.state.config.relayUrl, 'https://other.example.workers.dev'); + assert.equal(deps.state.config.bridgeToken, 'token-2'); +}); + +test('the same relay with the same account needs no new token', async () => { + const { response, deps } = await postConfig({ bridgeName: 'Work' }); + + assert.equal(response.status, 200); + assert.equal(deps.state.config.bridgeName, 'Work'); + assert.equal(deps.state.config.bridgeToken, 'token-1'); +}); + +test('a spelling change of the same relay is not a change of relay', async () => { + // `wss://` and `https://` name the same relay, so the token stays. + const { response, deps } = await postConfig({ relayUrl: 'wss://relay.example.workers.dev' }); + + assert.equal(response.status, 200); + assert.equal(deps.state.config.bridgeToken, 'token-1'); +}); + +test('the configuration never echoes the bridge token back', async () => { + const { response } = await postConfig({ relayUrl: 'https://other.example.workers.dev', bridgeToken: 'token-2' }); + + assert.equal(response.status, 200); + assert.equal(response.body.includes('token-1'), false); + assert.equal(response.body.includes('token-2'), false); +}); + +test('a request without the surface secret cannot change the configuration', async () => { + const response = fakeResponse(); + await handle( + fakeRequest('POST', '/api/config', { + headers: { 'content-type': 'application/json' }, + body: { relayUrl: 'https://other.example.workers.dev' }, + }), + response, + makeDeps(), + ); + + assert.equal(response.status, 403); +}); + +test('a request that names another host is refused before anything else', async () => { + // DNS rebinding: the page was loaded from its own name, which now resolves to + // the loopback address. The browser sends that name, and the bridge is not it. + const response = fakeResponse(); + await handle( + fakeRequest('GET', '/api/session', { headers: { host: 'evil.example:53378' } }), + response, + makeDeps(), + ); + + assert.equal(response.status, 403); + assert.equal(response.body.includes(SECRET), false); +}); + +test('a request from another origin is refused before anything else', async () => { + const response = fakeResponse(); + await handle( + fakeRequest('GET', '/api/session', { headers: { origin: 'https://evil.example' } }), + response, + makeDeps(), + ); + + assert.equal(response.status, 403); + assert.equal(response.body.includes(SECRET), false); +}); + +test('the account details are not handed to a caller without the secret', async () => { + // The account id and the relay address are what an attacker aims with. + for (const path of ['/api/status', '/api/devices', '/api/snapshot']) { + const response = fakeResponse(); + await handle(fakeRequest('GET', path), response, makeDeps()); + assert.equal(response.status, 403, `expected ${path} to need the secret`); + assert.equal(response.body.includes('default'), false); + assert.equal(response.body.includes('relay.example.workers.dev'), false); + } +}); + +test('the account details are handed to the surface', async () => { + const response = fakeResponse(); + await handle( + fakeRequest('GET', '/api/status', { headers: { 'x-bridge-secret': SECRET } }), + response, + makeDeps(), + ); + + assert.equal(response.status, 200); + assert.equal(response.body.includes('default'), true); +}); + +test('the device list is handed to the surface', async () => { + const response = fakeResponse(); + await handle( + fakeRequest('GET', '/api/devices', { headers: { 'x-bridge-secret': SECRET } }), + response, + makeDeps(), + ); + + assert.equal(response.status, 200); +}); + +test('the secret is only handed to a caller that names the bridge', async () => { + const response = fakeResponse(); + await handle(fakeRequest('GET', '/api/session'), response, makeDeps()); + + assert.equal(response.status, 200); + assert.equal(response.body.includes(SECRET), true); + + // ... and to nobody else. + const refused = fakeResponse(); + await handle( + fakeRequest('GET', '/api/session', { headers: { host: 'evil.example:53378' } }), + refused, + makeDeps(), + ); + + assert.equal(refused.status, 403); + assert.equal(refused.body.includes(SECRET), false); +}); + +test('a mutating request has to be a json body', async () => { + // The content type is what makes a cross-site request trigger a preflight, + // and the bridge answers no preflight. Without it, a cross-site form post + // would reach the bridge with the secret attached. + const response = fakeResponse(); + await handle( + fakeRequest('POST', '/api/config', { + headers: { 'content-type': 'text/plain', 'x-bridge-secret': SECRET }, + body: { relayUrl: 'https://other.example.workers.dev' }, + }), + response, + makeDeps(), + ); + + assert.equal(response.status, 403); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/surface.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/surface.test.mjs new file mode 100644 index 0000000..6c6b31a --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/surface.test.mjs @@ -0,0 +1,35 @@ +import assert from 'node:assert/strict'; +import { readFile } from 'node:fs/promises'; +import test from 'node:test'; + +const SURFACE = new URL('../miniapp/client/index.html', import.meta.url); + +test('the desktop surface never builds markup from a value', async () => { + // The surface runs in the bridge's own origin, where the per-process secret and + // the relay configuration live. Anything it renders as markup is script in that + // origin, so it may not render markup at all. + const source = await readFile(SURFACE, 'utf8'); + + /** @type {Array<[RegExp, string]>} */ + const forbidden = [ + [/\.innerHTML\s*=/, 'innerHTML'], + [/\.outerHTML\s*=/, 'outerHTML'], + [/\.insertAdjacentHTML\s*\(/, 'insertAdjacentHTML'], + [/document\.write\s*\(/, 'document.write'], + [/new\s+Function\s*\(/, 'new Function'], + ]; + + for (const [pattern, name] of forbidden) { + assert.equal(pattern.test(source), false, `the surface uses ${name}`); + } +}); + +test('the desktop surface puts remote values into the DOM as text', async () => { + const source = await readFile(SURFACE, 'utf8'); + + // The device name is whatever the phone typed when it paired. + assert.match(source, /nameLine\.textContent = device\.deviceName/); + assert.match(source, /versionLine\.textContent = device\.clientVersion/); + // The error message is whatever the relay or the browser said. + assert.match(source, /failed\.textContent = error\.message/); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/team-parity.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/team-parity.test.mjs new file mode 100644 index 0000000..0ec5e49 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/team-parity.test.mjs @@ -0,0 +1,285 @@ +import assert from 'node:assert/strict'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { Bridge } from '../miniapp/node/bridge.mjs'; +import { TEAM_TOOL_DESCRIPTORS, createFakeTeam } from './fake-team.mjs'; + +/** + * The desktop's own record of a turn. + * + * The shape is the one the desktop writes: one record for the whole turn, holding what + * the Bot said while it worked, which tools it used, and the answer it ended with. + */ +function desktopTurnRecord() { + return { + id: 'msg_reply_1', + chatId: 'bot:bot_b1', + ts: 1_793_708_397_828, + status: 'done', + role: 'bot', + botId: 'bot_b1', + taskId: 'task_1', + text: 'The answer.', + parts: [ + { type: 'text', text: 'Working on it.' }, + { type: 'tool', name: 'bash', state: 'ok' }, + { type: 'tool', name: 'read', state: 'failed' }, + { type: 'text', text: 'The answer.' }, + ], + }; +} + +/** + * A bridge on a desktop that holds one conversation. + * + * The runtime keeps one directory per Mini App, so the bridge's data and the Bot app's + * data are siblings under one root, and the conversation is a file of its own. + * + * @param {{ records?: Array, replies?: Record }} options + */ +async function makeBridge({ records = [desktopTurnRecord()], replies = { bot_b1: 'The answer.' } } = {}) { + const root = await mkdtemp(join(tmpdir(), 'bridge-parity-')); + const dataDir = join(root, 'plugin-data', 'liveboards', 'minimax-bot-bridge'); + const botDir = join(root, 'plugin-data', 'liveboards', 'minimax-bot'); + await mkdir(dataDir, { recursive: true }); + await mkdir(join(botDir, 'messages'), { recursive: true }); + await writeFile( + join(botDir, 'state.json'), + JSON.stringify({ + bots: [{ id: 'bot_b1', name: 'Writer', role: 'Drafting', isMain: true }], + groups: [], + sessions: {}, + }), + 'utf8', + ); + await writeFile(join(botDir, 'messages', 'bot_bot_b1.json'), JSON.stringify(records), 'utf8'); + + const team = createFakeTeam({ replies }); + const sent = []; + const context = { + pluginId: 'minimax-bot-bridge', + pluginRoot: '.', + dataDir, + listen: { host: '127.0.0.1', port: 0 }, + signal: new AbortController().signal, + logger: { info() {}, warn() {}, error() {}, debug() {} }, + tool: { + async list() { + return { tools: TEAM_TOOL_DESCRIPTORS, partial: false }; + }, + async call(name, args) { + return team.call(name, args); + }, + }, + }; + + const bridge = new Bridge(context, { + config: { bridgeId: 'bridge-1', accountId: 'default', bridgeToken: '', relayUrl: '', bridgeName: 'Desk' }, + logger: context.logger, + }); + bridge.relay.send = (frame) => { + sent.push(frame); + return true; + }; + await bridge.start(); + + return { + bridge, + sent, + events: (type) => sent.filter((frame) => frame.type === type).map((frame) => frame.payload), + async dispose() { + bridge.stop(); + await rm(root, { recursive: true, force: true }); + }, + }; +} + +/** + * The messages a set of frames committed, in the order they were committed. + * + * @param {any[]} frames the payloads of `evt.message_committed` + */ +function committed(frames) { + return frames.map((payload) => payload.message); +} + +test('a team turn publishes the whole exchange the desktop recorded', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + + const messages = committed(events('evt.message_committed')).filter((message) => message.role === 'assistant'); + assert.equal(messages.length, 2); + assert.equal(messages[0].messageId, 'msg_reply_1#p0'); + assert.equal(messages[0].text, 'Working on it.'); + assert.equal(messages[1].messageId, 'msg_reply_1'); + assert.equal(messages[1].text, 'The answer.'); + await dispose(); +}); + +test('the tool calls of a team turn are published as calls', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + + const started = events('evt.tool_started'); + const finished = events('evt.tool_finished'); + assert.equal(started.length, 2); + assert.equal(finished.length, 2); + assert.equal(started[0].toolName, 'bash'); + assert.equal(started[0].callId, 'msg_reply_1#t1'); + assert.equal(finished[0].ok, true); + assert.equal(finished[1].ok, false); + await dispose(); +}); + +test('everything a team turn publishes belongs to the same turn', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + + const turnId = events('evt.turn_started')[0].turnId; + for (const message of committed(events('evt.message_committed'))) { + if (message.role === 'assistant') assert.equal(message.turnId, turnId); + } + for (const event of events('evt.tool_started')) assert.equal(event.turnId, turnId); + for (const event of events('evt.tool_finished')) assert.equal(event.turnId, turnId); + await dispose(); +}); + +test('a turn says when it started, so the phone can show how long it runs', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + + const started = events('evt.turn_started')[0]; + assert.equal(typeof started.startedAt, 'number'); + assert.ok(started.startedAt > 0); + await dispose(); +}); + +test('the thread a phone reads back is the thread it was shown', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + const shown = committed(events('evt.message_committed')).filter((message) => message.role === 'assistant'); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + const read = history.messages.filter((message) => message.role === 'assistant'); + + assert.deepEqual( + read.map((message) => message.messageId), + shown.map((message) => message.messageId), + ); + assert.deepEqual( + read.map((message) => message.text), + shown.map((message) => message.text), + ); + await dispose(); +}); + +test('the calls a phone reads back are the calls it was shown', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + const shown = events('evt.tool_started').map((event) => event.callId); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + + assert.deepEqual( + history.activity.map((entry) => entry.callId), + shown, + ); + assert.equal(history.activity[0].toolName, 'bash'); + await dispose(); +}); + +test('a team turn is not streamed, because there is nothing to stream', async () => { + const { bridge, events, dispose } = await makeBridge(); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + + assert.equal(events('evt.message_chunk').length, 0); + await dispose(); +}); + +test('an answer is still given when the desktop’s record cannot be read', async () => { + const { bridge, events, dispose } = await makeBridge({ records: [] }); + + await bridge.dispatch('op.send_message', { chatId: 'bot:bot_b1', text: 'Do the thing' }); + + const messages = committed(events('evt.message_committed')).filter((message) => message.role === 'assistant'); + assert.equal(messages.length, 1); + assert.equal(messages[0].text, 'The answer.'); + // The turn still ends, so the phone is not left waiting for an answer that never + // comes. + assert.equal(events('evt.turn_finished').length, 1); + await dispose(); +}); + +test('a message typed on the desktop is marked as coming from there', async () => { + const { bridge, dispose } = await makeBridge({ + records: [ + { + id: 'msg_typed', + chatId: 'bot:bot_b1', + ts: 10, + status: 'done', + role: 'user', + text: 'Typed on the desktop', + }, + ], + }); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + + assert.equal(history.messages[0].origin, 'desktop'); + assert.equal(history.messages[0].role, 'user'); + await dispose(); +}); + +test('a turn that used many tools keeps every one of them', async () => { + // A real turn of the desktop's own engine used fifty-seven tools. A limit that + // counts calls rather than turns would drop the turn itself, and the phone would + // show the answer with none of the work behind it. + const tools = Array.from({ length: 57 }, () => ({ type: 'tool', name: 'bash', state: 'ok' })); + const { bridge, dispose } = await makeBridge({ + records: [ + { + ...desktopTurnRecord(), + parts: [{ type: 'text', text: 'Working on it.' }, ...tools, { type: 'text', text: 'The answer.' }], + }, + ], + }); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + + assert.equal(history.activity.length, 57); + assert.equal(history.activity[0].turnId, 'task_1'); + assert.equal(history.activity[0].toolName, 'bash'); + await dispose(); +}); + +test('the calls of a turn the thread does not show are not sent', async () => { + // A call whose turn has no messages is a call of a turn the reader cannot see. + const { bridge, dispose } = await makeBridge({ records: [] }); + + bridge.history.set('bot:bot_b1', [ + { messageId: 'm1', chatId: 'bot:bot_b1', role: 'assistant', text: 'Hello.', ts: 1, turnId: 'task_1' }, + ]); + bridge.activity.set('bot:bot_b1', [ + { turnId: 'task_gone', callId: 'call_gone', toolName: 'bash', status: 'ok' }, + { turnId: 'task_1', callId: 'call_1', toolName: 'read', status: 'ok' }, + ]); + + const history = /** @type {any} */ (await bridge.dispatch('op.list_history', { chatId: 'bot:bot_b1' })); + + assert.deepEqual( + history.activity.map((entry) => entry.callId), + ['call_1'], + ); + await dispose(); +}); diff --git a/plugins/nattiini45/minimax-bot-bridge/tests/tools.test.mjs b/plugins/nattiini45/minimax-bot-bridge/tests/tools.test.mjs new file mode 100644 index 0000000..18387d5 --- /dev/null +++ b/plugins/nattiini45/minimax-bot-bridge/tests/tools.test.mjs @@ -0,0 +1,532 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { ToolArgumentError, ToolGateway, buildToolArguments } from '../miniapp/node/tools.mjs'; + +test('passes the payload through when the schema is unknown', () => { + const args = buildToolArguments({ botId: 'b1', text: 'hi' }, undefined); + assert.deepEqual(args, { botId: 'b1', text: 'hi' }); +}); + +test('maps operation fields onto schema property names', () => { + const schema = { + type: 'object', + properties: { to: { type: 'string' }, message: { type: 'string' } }, + required: ['to', 'message'], + }; + const args = buildToolArguments({ botId: 'b1', text: 'hello' }, schema); + assert.deepEqual(args, { to: 'b1', message: 'hello' }); +}); + +test('keeps a property whose name matches exactly', () => { + const schema = { type: 'object', properties: { title: { type: 'string' } } }; + const args = buildToolArguments({ title: 'Digest' }, schema); + assert.deepEqual(args, { title: 'Digest' }); +}); + +/* + * The schemas below are the ones the real `@minimax-bot` team tools advertise. They + * are copied from the tool catalogue rather than invented, because the whole point of + * mapping the payload onto the live schema is that the two agree. + */ + +test('a memory tool that names its target bot receives it', () => { + const schema = { + type: 'object', + properties: { + query: { type: 'string' }, + limit: { type: 'integer' }, + scope: { type: 'string', enum: ['self', 'team', 'bot'] }, + target_bot: { type: 'string' }, + }, + }; + const args = buildToolArguments({ botId: 'bot_b1', query: 'changelog', limit: 5 }, schema); + assert.deepEqual(args, { query: 'changelog', limit: 5, target_bot: 'bot_b1' }); +}); + +test('a group tool that names its group receives it', () => { + const schema = { + type: 'object', + properties: { + group: { type: 'string' }, + name: { type: 'string' }, + members: { type: 'array', items: { type: 'string' } }, + reply_mode: { type: 'string', enum: ['admin', 'all'] }, + }, + required: ['group'], + }; + const args = buildToolArguments( + { groupId: 'grp_g1', name: 'Studio', memberIds: ['bot_b1'], replyMode: 'admin' }, + schema, + ); + assert.deepEqual(args, { + group: 'grp_g1', + name: 'Studio', + members: ['bot_b1'], + reply_mode: 'admin', + }); +}); + +test('a bot tool that names its bot receives it', () => { + const schema = { + type: 'object', + properties: { bot: { type: 'string' }, name: { type: 'string' }, main: { type: 'boolean' } }, + required: ['bot'], + }; + const args = buildToolArguments({ botId: 'bot_b1', name: 'Writer', main: true }, schema); + assert.deepEqual(args, { bot: 'bot_b1', name: 'Writer', main: true }); +}); + +test('the caller the team fills in itself is never a missing argument', () => { + const schema = { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + task: { type: 'string' }, + title: { type: 'string' }, + }, + required: ['caller_bot_id', 'task'], + }; + const args = buildToolArguments({ task: 'do the thing', title: 'Thing' }, schema); + assert.deepEqual(args, { task: 'do the thing', title: 'Thing' }); +}); + +test('a schedule tool receives the time its kind asks for', () => { + const schema = { + type: 'object', + properties: { + prompt: { type: 'string' }, + name: { type: 'string' }, + kind: { type: 'string', enum: ['daily', 'interval', 'once'] }, + time: { type: 'string' }, + every_minutes: { type: 'integer' }, + in_minutes: { type: 'integer' }, + target_bot: { type: 'string' }, + }, + required: ['prompt', 'kind'], + }; + const args = buildToolArguments( + { prompt: 'write the notes', name: 'Notes', kind: 'daily', spec: '09:00', targetBotId: 'bot_b1' }, + schema, + ); + assert.deepEqual(args, { + prompt: 'write the notes', + name: 'Notes', + kind: 'daily', + time: '09:00', + target_bot: 'bot_b1', + }); +}); + +test('refuses when a required property cannot be satisfied', () => { + const schema = { + type: 'object', + properties: { to: { type: 'string' }, message: { type: 'string' } }, + required: ['to', 'message'], + }; + assert.throws( + () => buildToolArguments({ text: 'hello' }, schema), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.equal(error.code, 'INVALID_ARGUMENTS'); + // `text` satisfies `message`; only the recipient is missing. + assert.deepEqual(error.details.missing, ['to']); + return true; + }, + ); +}); + +test('drops payload fields the schema does not know', () => { + const schema = { type: 'object', properties: { title: { type: 'string' } } }; + const args = buildToolArguments({ title: 'Digest', extra: 'ignored' }, schema); + assert.deepEqual(args, { title: 'Digest' }); +}); + +test('the gateway only exposes the team tools', async () => { + const context = { + tool: { + async list() { + return { + tools: [ + { name: 'mcp__minimax-bot__message_bot', server: 'minimax-bot', tool: 'message_bot' }, + { name: 'mcp__other-server__thing', server: 'other-server', tool: 'thing' }, + ], + partial: false, + }; + }, + async call() { + return { kind: 'mcp', isError: false, content: [] }; + }, + }, + }; + const gateway = new ToolGateway(context); + await gateway.refresh(); + + assert.equal(gateway.available, true); + assert.equal(gateway.catalog.length, 1); + assert.equal(gateway.find('message_bot')?.name, 'mcp__minimax-bot__message_bot'); + assert.equal(gateway.find('thing'), undefined); +}); + +test('the gateway reports an unavailable team instead of throwing', async () => { + const gateway = new ToolGateway({ + tool: { + async list() { + throw new Error('MCP_SERVER_LIST_FAILED'); + }, + }, + }); + await gateway.refresh(); + + assert.equal(gateway.available, false); + assert.equal(gateway.catalog.length, 0); + // The room is told a sentence, not the Host's own text: this travels to every phone + // in the account. + assert.doesNotMatch(gateway.reason, /MCP_SERVER_LIST_FAILED/); + assert.match(gateway.reason, /could not be read/i); + // The detail is kept for the desktop. + assert.equal(gateway.detail, 'MCP_SERVER_LIST_FAILED'); +}); + +test('the gateway reports a missing tool grant', async () => { + const gateway = new ToolGateway({}); + await gateway.refresh(); + + assert.equal(gateway.available, false); + assert.match(gateway.reason, /tool access/i); +}); + +test('calling an unavailable tool is refused, not dispatched', async () => { + const gateway = new ToolGateway({}); + await gateway.refresh(); + + const result = await gateway.call('message_bot', { botId: 'b1' }); + assert.equal(result.ok, false); + assert.equal(result.code, 'TOOL_NOT_AVAILABLE'); + assert.equal(result.disposition, 'not_dispatched'); +}); + +test('team tool routing requires consistent canonical metadata, independent of catalogue order', async (t) => { + const canonical = { name: 'mcp__minimax-bot__message_bot', server: 'minimax-bot', tool: 'message_bot' }; + const helper = { name: 'mcp__minimax-bot-helper__message_bot', server: 'minimax-bot-helper', tool: 'message_bot' }; + for (const tools of [[helper, canonical], [canonical, helper]]) { + await t.test(tools[0].server, async () => { + const called = []; + const gateway = new ToolGateway({ tool: { + list: async () => ({ tools }), + call: async (name) => { called.push(name); return { kind: 'mcp', isError: false }; }, + } }); + await gateway.refresh(); + assert.equal((await gateway.call('message_bot', { botId: 'b1', text: 'hello' })).ok, true); + assert.deepEqual(called, [canonical.name]); + assert.equal(gateway.catalog.length, 1); + }); + } +}); + +test('helper-only, missing or contradictory tool identities are never dispatched', async () => { + const tools = [ + { name: 'mcp__minimax-bot-helper__message_bot', server: 'minimax-bot-helper', tool: 'message_bot' }, + { name: 'mcp__minimax-bot__message_bot', server: 'other', tool: 'message_bot' }, + { name: 'mcp__other__message_bot', server: 'minimax-bot', tool: 'message_bot' }, + { name: 'mcp__minimax-bot__message_bot', tool: 'message_bot' }, + { name: 'mcp__minimax-bot__other_tool', server: 'minimax-bot', tool: 'message_bot' }, + ]; + let calls = 0; + const gateway = new ToolGateway({ tool: { + list: async () => ({ tools }), + call: async () => { calls += 1; return { kind: 'mcp', isError: false }; }, + } }); + await gateway.refresh(); + assert.equal(gateway.available, false); + assert.equal((await gateway.call('message_bot', {})).disposition, 'not_dispatched'); + assert.equal(calls, 0); +}); + +test('duplicate eligible short tool names fail closed instead of selecting the first schema', async () => { + const descriptor = { name: 'mcp__minimax-bot__message_bot', server: 'minimax-bot', tool: 'message_bot' }; + let calls = 0; + const gateway = new ToolGateway({ tool: { + list: async () => ({ tools: [descriptor, { ...descriptor, description: 'another descriptor' }] }), + call: async () => { calls += 1; return { kind: 'mcp', isError: false }; }, + } }); + await gateway.refresh(); + assert.equal((await gateway.call('message_bot', {})).disposition, 'not_dispatched'); + assert.equal(calls, 0); +}); + +test('calling a tool forwards the mapped arguments', async () => { + const seen = []; + const gateway = new ToolGateway({ + tool: { + async list() { + return { + tools: [ + { + name: 'mcp__minimax-bot__message_bot', + server: 'minimax-bot', + tool: 'message_bot', + inputSchema: { + type: 'object', + properties: { to: { type: 'string' }, message: { type: 'string' } }, + required: ['to', 'message'], + }, + }, + ], + partial: false, + }; + }, + async call(name, args) { + seen.push({ name, arguments: args }); + return { kind: 'mcp', isError: false, content: [{ type: 'text', text: '{"ok":true}' }] }; + }, + }, + }); + await gateway.refresh(); + + const result = await gateway.call('message_bot', { botId: 'b1', text: 'hello' }); + assert.equal(result.ok, true); + assert.deepEqual(seen[0].arguments, { to: 'b1', message: 'hello' }); + assert.equal(seen[0].name, 'mcp__minimax-bot__message_bot'); +}); + +test('a tool that reports an error is surfaced as a failure', async () => { + const gateway = new ToolGateway({ + tool: { + async list() { + return { + tools: [{ name: 'mcp__minimax-bot__list_team', server: 'minimax-bot', tool: 'list_team' }], + partial: false, + }; + }, + async call() { + return { kind: 'mcp', isError: true, content: [] }; + }, + }, + }); + await gateway.refresh(); + + const result = await gateway.call('list_team', {}); + assert.equal(result.ok, true); + assert.equal(result.isError, true); +}); + +/* + * The three tools below take arguments that are not a flat mapping of the operation + * payload. Their schemas are the ones the real `@minimax-bot` team tools advertise. + */ + +const DISPATCH_SCHEMA = { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + request_id: { type: 'string', pattern: '^[A-Za-z0-9_.-]{1,128}$' }, + tasks: { + type: 'array', + minItems: 1, + maxItems: 32, + items: { + type: 'object', + additionalProperties: false, + required: ['to', 'message'], + properties: { + to: { type: 'string' }, + message: { type: 'string', minLength: 1, maxLength: 20000 }, + title: { type: 'string', maxLength: 80 }, + }, + }, + }, + }, + required: ['request_id', 'tasks'], + additionalProperties: false, +}; + +const TASK_QUERY_SCHEMA = { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + task_id: { type: 'string' }, + batch_id: { type: 'string' }, + wait_ms: { type: 'integer', minimum: 0, maximum: 20000 }, + result_offset: { type: 'integer', minimum: 0 }, + }, + required: [], + additionalProperties: false, + oneOf: [{ required: ['task_id'] }, { required: ['batch_id'] }], +}; + +const REMEMBER_SCHEMA = { + type: 'object', + properties: { + caller_bot_id: { type: 'string', minLength: 1 }, + text: { type: 'string', minLength: 1, maxLength: 1200 }, + key: { type: 'string', pattern: '^[a-z0-9][a-z0-9._-]{0,63}$' }, + scope: { type: 'string', enum: ['self', 'team', 'bot'] }, + target_bot: { type: 'string' }, + replace_id: { type: 'string', pattern: '^[a-f0-9]{8}$' }, + expected_revision: { type: 'string', pattern: '^[a-f0-9]{64}$' }, + pinned: { type: 'boolean' }, + source: { type: 'string' }, + }, + required: ['text'], + additionalProperties: false, + anyOf: [{ required: ['key'] }, { required: ['replace_id'] }], +}; + +test('a dispatch becomes the list of tasks the team asks for', () => { + const args = buildToolArguments( + { botIds: ['bot_b1', 'bot_b2'], prompt: 'write the changelog', title: 'Changelog' }, + DISPATCH_SCHEMA, + 'dispatch_tasks', + ); + + assert.match(String(args.request_id), /^[A-Za-z0-9_.-]{1,128}$/); + assert.deepEqual(args.tasks, [ + { to: 'bot_b1', message: 'write the changelog', title: 'Changelog' }, + { to: 'bot_b2', message: 'write the changelog', title: 'Changelog' }, + ]); +}); + +test('a dispatch without a Bot is refused before it is sent', () => { + assert.throws( + () => buildToolArguments({ prompt: 'write the changelog' }, DISPATCH_SCHEMA, 'dispatch_tasks'), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.equal(error.code, 'INVALID_ARGUMENTS'); + assert.equal(error.details.field, 'botIds'); + return true; + }, + ); +}); + +test('a dispatch without a message is refused before it is sent', () => { + assert.throws( + () => buildToolArguments({ botIds: ['bot_b1'] }, DISPATCH_SCHEMA, 'dispatch_tasks'), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.equal(error.details.field, 'prompt'); + return true; + }, + ); +}); + +test('a dispatch keeps the idempotency key the client chose', () => { + const args = buildToolArguments( + { botIds: ['bot_b1'], prompt: 'go', requestId: 'phone-1' }, + DISPATCH_SCHEMA, + 'dispatch_tasks', + ); + assert.equal(args.request_id, 'phone-1'); +}); + +test('a task query names the batch it asks about', () => { + const args = buildToolArguments({ batchId: 'batch_1', waitMs: 500 }, TASK_QUERY_SCHEMA, 'get_tasks'); + assert.deepEqual(args, { batch_id: 'batch_1', wait_ms: 500 }); +}); + +test('a task query without a selector is refused', () => { + assert.throws( + () => buildToolArguments({}, TASK_QUERY_SCHEMA, 'get_tasks'), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.equal(error.code, 'INVALID_ARGUMENTS'); + assert.match(error.message, /Exactly one of task_id, batch_id/); + return true; + }, + ); +}); + +test('a task query that names both a task and a batch is refused', () => { + assert.throws( + () => buildToolArguments({ taskId: 't1', batchId: 'b1' }, TASK_QUERY_SCHEMA, 'get_tasks'), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.match(error.message, /Exactly one of task_id, batch_id/); + return true; + }, + ); +}); + +test('a memory is filed under the topic it names', () => { + const args = buildToolArguments( + { botId: 'bot_b1', text: 'the launch is on friday', key: 'launch.date' }, + REMEMBER_SCHEMA, + 'remember', + ); + assert.deepEqual(args, { + text: 'the launch is on friday', + target_bot: 'bot_b1', + key: 'launch.date', + }); +}); + +test('a memory without a topic or an entry to replace is refused', () => { + assert.throws( + () => buildToolArguments({ botId: 'bot_b1', text: 'note' }, REMEMBER_SCHEMA, 'remember'), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.equal(error.code, 'INVALID_ARGUMENTS'); + assert.match(error.message, /One of key, replace_id/); + return true; + }, + ); +}); + +test('a memory that replaces an entry carries the entry id', () => { + const args = buildToolArguments( + { botId: 'bot_b1', text: 'note', replaceId: 'a1b2c3d4' }, + REMEMBER_SCHEMA, + 'remember', + ); + assert.deepEqual(args, { + text: 'note', + target_bot: 'bot_b1', + replace_id: 'a1b2c3d4', + }); +}); + +test('a memory without a text is refused', () => { + assert.throws( + () => buildToolArguments({ botId: 'bot_b1', key: 'topic' }, REMEMBER_SCHEMA, 'remember'), + (error) => { + assert.ok(error instanceof ToolArgumentError); + assert.deepEqual(error.details.missing, ['text']); + return true; + }, + ); +}); + +test('the gateway dispatches a dispatch with the task list', async () => { + const seen = []; + const gateway = new ToolGateway({ + tool: { + async list() { + return { + tools: [ + { + name: 'mcp__minimax-bot__dispatch_tasks', + server: 'minimax-bot', + tool: 'dispatch_tasks', + inputSchema: DISPATCH_SCHEMA, + }, + ], + partial: false, + }; + }, + async call(name, args) { + seen.push({ name, arguments: args }); + return { kind: 'mcp', isError: false, content: [] }; + }, + }, + }); + await gateway.refresh(); + + const result = await gateway.call('dispatch_tasks', { + botIds: ['bot_b1'], + prompt: 'write the changelog', + }); + + assert.equal(result.ok, true); + assert.equal(seen[0].arguments.tasks.length, 1); + assert.equal(seen[0].arguments.tasks[0].to, 'bot_b1'); +});