From 16fb0d1d9c72ee5c284593c5b5e1f82e78097d52 Mon Sep 17 00:00:00 2001 From: Chen Date: Thu, 10 Sep 2026 19:07:43 +0800 Subject: [PATCH 1/5] docs: add bot platform capability guide Generated-by: Codex --- docs/README.md | 1 + docs/bot-platforms.md | 364 ++++++++++++++++++++++++++++++++++++ docs/bot-platforms.zh-CN.md | 302 ++++++++++++++++++++++++++++++ 3 files changed, 667 insertions(+) create mode 100644 docs/bot-platforms.md create mode 100644 docs/bot-platforms.zh-CN.md diff --git a/docs/README.md b/docs/README.md index a34778cccd..3441d116a7 100644 --- a/docs/README.md +++ b/docs/README.md @@ -69,6 +69,7 @@ This page is the authority map for Maka documentation. Code and contract tests r - [Side Conversation](./side-conversation.md) - [Agent Graph stream scheduling](./architecture/agent-graph-stream-scheduling-draft.md) ([中文](./architecture/agent-graph-stream-scheduling-draft.zh-CN.md)) - [IM 扫码接入 runtime architecture](./architecture/bot-onboarding-runtime.zh-CN.md) +- [Bot platforms](./bot-platforms.md) ([中文](./bot-platforms.zh-CN.md)) - [Backend architecture chapters](./architecture/) ### Computer use diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md new file mode 100644 index 0000000000..da8cc517f9 --- /dev/null +++ b/docs/bot-platforms.md @@ -0,0 +1,364 @@ +--- +doc_id: bot.platforms +title: "Bot platforms" +language: en +source_language: en +implementation_status: current +document_status: draft +counterpart: ./bot-platforms.zh-CN.md +translation_status: synced +last_verified: 2026-09-09 +owners: + - maka-backend +--- + + +# Bot platforms + +This document describes the capabilities and security boundaries of Maka's +chat-platform bridges. It covers the Runtime bridge after a channel is +configured. QR and other assisted onboarding flows are specified separately in +[IM onboarding runtime architecture](./architecture/bot-onboarding-runtime.zh-CN.md). + +## Supported channels and capability matrix + +The current `BotProvider` contract contains eight channels: + +| Channel | Normal conversation reply | Scheduled bot delivery | Progressive reply stream | Typing indicator | Ephemeral reply cleanup | Non-text message classification | +| --- | --- | --- | --- | --- | --- | --- | +| Telegram | Yes | Yes | Yes | Yes | Yes | Yes | +| WeChat | Yes | Yes | No | No | No | Yes | +| Discord | Yes | Yes | No | Yes | No | No | +| DingTalk | Yes | Yes | No | No | No | No | +| QQ | Yes | Yes | No | Yes | No | No | +| Slack | Yes | Yes | No | No | No | No | +| Feishu / Lark | Yes | No | No | No | No | No | +| WeCom | Yes | No | No | No | No | No | + +This table is derived from the current source, not from live provider tests. +"Normal conversation reply" means that the Runtime bridge implements the +shared text-send path. "Scheduled bot delivery" is a narrower product +capability: scheduled tasks currently accept only Telegram, WeChat, Discord, +DingTalk, QQ, and Slack. A `No` in the optional-feature columns means that the +shared Maka bridge does not implement that feature; it does not claim that the +external provider could never support it. + +The platform count and the scheduled-delivery set are intentionally separate. +Do not describe the repository as supporting nine platforms, and do not infer +scheduled-task support from the existence of a bridge alone. + +## Configuration overview + +The settings object uses a small common vocabulary. The exact provider API +names differ, but the Runtime bridge reads the following values: + +| Channel | Required or provider-specific values | Runtime transport in Maka | +| --- | --- | --- | +| Telegram | Bot token; optional proxy URL | Long polling | +| WeChat | Bridge URL and, for the iLink path, a bot token | Local bridge or iLink polling | +| Discord | Bot token | Gateway | +| DingTalk | App ID and app secret | Stream/WebSocket | +| QQ | App ID and app secret | Gateway | +| Slack | Bot token and app-level token | Gateway | +| Feishu / Lark | App ID and app secret; optional `domain` selects Feishu or Lark | Official Channel WebSocket | +| WeCom | Bot ID in `appId` and bot secret in `appSecret` | Official AI Bot WebSocket | + +Provider setup instructions must name the provider's own console terms and +link to its official documentation. This table only describes what the Maka +Runtime consumes; it is not a claim that the credentials have been validated +against a live provider. + +The source of truth for this matrix is the [`BotProvider` and +`BOT_DELIVERY_PROVIDERS`](../packages/core/src/bot-chat-settings.ts) contract, +the shared [`SendCapable`](../packages/runtime/src/bots/types.ts) interface, +the implementations under +[`packages/runtime/src/bots`](../packages/runtime/src/bots), and their focused +[bridge tests](../packages/runtime/src/bots/__tests__). When this document and +the implementation disagree, code and contract tests win. + +## Platform setup checklists + +These checklists describe the smallest provider-side setup needed before +entering values in Maka. Provider consoles, permission names, app review, and +regional availability can change; follow the linked official documentation for +the current portal sequence. Never put the resulting secrets in this document. + +### Telegram + +1. Use [BotFather](https://core.telegram.org/bots#how-do-i-create-a-bot) to + create a bot with `/newbot` and copy its token. +2. Put the token in Maka's `token` field. Set `proxyUrl` only when the Telegram + API must be reached through a proxy. +3. Send the bot a direct text message first. For a group smoke test, add the + bot to a test group and account for Telegram group privacy and mention rules. + +Telegram is the only current bridge with native progressive reply streaming, +typing indicators, and scheduled ephemeral cleanup. These features are +optional delivery enhancements; a normal text reply remains the baseline. + +### Discord + +1. Create an application and bot in the [Discord developer + documentation](https://docs.discord.com/developers/quick-start/getting-started). +2. Copy the bot token into Maka's `token` field, install the bot into a private + test server, and grant the minimum message permissions needed for the test. +3. Enable the privileged **Message Content** intent. Maka requests guild + messages, direct messages, and message content over the Gateway; Discord can + close the connection when a requested privileged intent is not enabled. + +Maka uses Discord's Gateway for inbound events and REST calls for sends. It +does not use Discord's HTTP interactions endpoint for ordinary bot messages. +When Discord access requires a proxy, the channel proxy setting covers Bot +authentication only; the Gateway WebSocket still requires a system-level route +such as TUN, followed by an app restart. + +### Slack + +1. Create and install a Slack app in a development workspace. +2. Enable [Socket Mode](https://api.slack.com/apis/connections/socket), create + an app-level token with `connections:write`, and obtain the bot token from + the app installation. +3. Enter the bot token in Maka's `token` field and the app-level token in + `appSecret`. Subscribe the app to the message events required by the + workspace test. + +Slack uses a WebSocket connection for events and a Web API client for replies. +Keep the app in a private development workspace while validating scopes and +allowlist behavior. + +### DingTalk + +1. Create a self-built application, obtain its Client ID and Client Secret, + and create a bot in the application. The [official Stream bot + tutorial](https://opensource.dingtalk.com/developerpedia/docs/explore/tutorials/stream/bot/nodejs/create-bot/) + describes this flow. +2. Choose **Stream** as the message reception mode. Enter the Client ID in + `appId` and the Client Secret in `appSecret`. +3. Test direct messages first. In a group, add the bot and mention it; DingTalk + only delivers the relevant group messages to the bot. + +The bridge uses DingTalk Stream/WebSocket for inbound events and platform API +calls for replies. Do not document card or typing-style updates as Maka +capabilities unless a separate bridge implementation adds them. + +### QQ + +1. Create an official QQ bot in the [QQ bot developer + portal](https://q.qq.com/qqbot/) and copy its AppID and AppSecret. +2. Enter AppID in `appId` and AppSecret in `appSecret`. +3. Add the bot to a private test scene permitted by the QQ platform, then + exercise direct, group, or channel messages only where that bot is allowed. + +Maka uses QQ's Gateway for inbound events and the platform REST API for sends. +The platform may restrict which scenes are available before review or release; +that platform policy is not a Maka readiness state. + +### Feishu and Lark + +1. Create an enterprise self-built app in the [Feishu Open + Platform](https://open.feishu.cn/document/home/index) or [Lark Developer + site](https://open.larksuite.com/document/home/index). +2. Enable the bot and the event/long-connection capabilities required by the + provider, then copy the App ID and App Secret into Maka's `appId` and + `appSecret`. +3. For Lark, set `domain` to `larksuite.com`; keep the default Feishu domain + for Feishu accounts. Test in a private conversation or development group. + +Feishu and Lark share the `feishu` Maka channel. The bridge uses the provider's +official Channel WebSocket. The channel can handle normal replies, but it is +not currently a scheduled-task notification target. + +### WeCom + +1. Create an AI Bot in the [WeCom developer + documentation](https://developer.work.weixin.qq.com/), choose API mode with + a long connection, and copy the Bot ID and Secret. +2. Enter Bot ID in Maka's `appId` and Secret in `appSecret`. +3. Validate with a direct message first, then use a private test group if the + organization permits the bot there. + +Maka uses the official AI Bot WebSocket. The bridge's successful credential + handshake does not make WeCom a scheduled-task notification target. + +### WeChat + +WeChat has two bridge paths in the current implementation: + +- **Local bridge:** provide the local bridge URL in `webhookUrl` and use the + bridge's own authentication/configuration contract. +- **iLink:** provide the iLink-compatible base URL in `webhookUrl` and the bot + token in `token`; `botUserId` may identify the bot in the channel settings. + +Use a local test bridge or the provider-approved iLink flow, send a direct text +message, and only then test a group mention. The bridge can classify several +media kinds, but it does not download their bytes for model input. + +## Security and privacy + +### Every inbound message is untrusted input + +An enabled bot channel is an input surface: text from a direct message or a +group conversation becomes agent context. Treat it with the same care as a web +page, tool result, or pasted file. In particular, a message's claimed author, +instructions, links, and attachments do not grant it permission to read files, +run commands, disclose data, or change Maka settings. + +The normal Session permission mode and its operating-system execution boundary +remain the authority for agent actions. A bot allowlist narrows who may start a +conversation; it is not a sandbox, an authorization system for tools, or a +substitute for reviewing permission prompts. See the project +[Security Policy](../SECURITY.md) for Maka's trust model and reporting process. + +### Keep platform credentials out of the renderer and out of public records + +Bot tokens, application IDs and secrets, webhook verification values, device +codes, poll tokens, and raw provider responses are sensitive configuration. Do +not paste them into a chat, issue, pull request, screenshot, test fixture, or +documentation example. + +Maka treats the renderer as semi-trusted. Settings reads project masked values; +the renderer must not receive a cleartext bot token or application secret from +main-process settings APIs. Assisted onboarding keeps device-code and polling +credentials in the main-process session and exposes only a renderer-safe +snapshot. These are confidentiality boundaries, not a promise that every +platform's credential is encrypted at rest: the current at-rest protection is +the owner-only local settings store and the user's operating-system account. + +When changing a bridge or an onboarding flow: + +- redact credential-bearing URLs, headers, request bodies, and provider errors + before they reach logs or user-facing diagnostics; +- keep the preload IPC surface narrow; renderer code must not gain arbitrary + HTTP access to a bot provider or an adapter-injection path; and +- use masked placeholders in settings forms to mean "keep the existing value", + never as a real credential value. + +### Restrict who can contact a bot when the deployment needs it + +`allowedUserIds` is an optional per-channel allowlist. When it is absent or +empty, the bridge preserves the existing unrestricted behavior. When it is +non-empty, a bridge silently drops messages from other platform-native user +IDs; it does not send a rejection that could help an unauthorized sender probe +the policy. IDs are stored as strings because some platform identifiers exceed +JavaScript's safe integer range. + +Configure an allowlist for a personal, development, or otherwise restricted +bot. For a public bot, leave it empty only when the owner intentionally accepts +untrusted messages and has chosen an appropriate Session permission policy. + +### Non-text messages are not model attachments + +Some bridges can recognize an incoming photo, voice message, document, or +other non-text payload. Recognition is used to send a clear textual notice that +Maka cannot process that content. It does not download the binary, make it +available to the model, or establish a multimodal-input trust boundary. + +Documentation and UI must therefore say "non-text message detection" rather +than "file attachment support" unless a platform has an explicit, reviewed +binary-ingestion path. + +### Report a suspected vulnerability privately + +Do not use a public issue for a leaked credential, missing authentication or +signature verification, an IPC secret-exposure path, or a bypass of a stated +security boundary. Follow the private reporting channel in +[SECURITY.md](../SECURITY.md#reporting-a-vulnerability), including the affected +bridge, a minimal reproduction, and the Maka version or commit. + +## Operational status and limitations + +The Runtime reports channel health with explicit states rather than a single +boolean: `scaffolded`, `configured`, `credentials_valid`, `operational`, and +`degraded`. A configured channel is not necessarily connected, and a successful +credential check is not proof that an end-to-end reply was delivered. UI and +setup documentation should preserve that distinction. + +The current shared Bot contract is text-first: + +- text messages can enter a Session and receive a text reply; +- some bridges classify non-text messages so the user receives a helpful + limitation notice; +- the bridge does not make photos, voice messages, or document bytes available + to the model; and +- optional features must be described per channel, not inferred from the + provider's general product capabilities. + +Scheduled delivery is also deliberately narrower than channel support. A +bridge can support a live conversation while remaining unavailable as a +scheduled-task notification target. The scheduled-task UI and protocol should +use the six-provider delivery set from the core contract rather than all +configured channels. + +## Verification status + +This document's capability matrix and configuration overview are source-derived +and were checked against the current provider contract, bridge implementations, +and focused Runtime tests. They are not a record of live credentials or a +promise that every provider account can be created in every region. + +### Live smoke check + +Feishu (mainland China account, manually configured) was live-tested on +2026-09-10. The channel connected successfully; a direct text message received +one reply; a group text message mentioning Maka received one reply with its +content preserved; and a second group message still received one reply after +Maka was restarted. No credentials, user IDs, or provider payloads were +recorded. This validates normal text conversation and restart recovery only; +the optional capabilities in the matrix remain source-derived. + +Discord (mainland China network, manually configured) was live-tested on +2026-09-10 using the Discord web client. A group message mentioning Maka +reached the bridge and the bot sent one reply back to the same Discord channel +with the requested text preserved. The test required a working system-level +TUN route for the Gateway WebSocket. No credentials, user IDs, or provider +payloads were recorded. This validates one normal group-message round trip; +restart recovery, duplicate suppression, and scheduled delivery remain +untested for this live entry. + +Telegram (mainland China network, manually configured) was live-tested on +2026-09-10. Direct messages, including `/start` and a text smoke marker, +reached the bridge and the bot sent one reply back with the marker preserved. +No credentials, user IDs, or provider payloads were recorded. This validates +one normal direct-message round trip; restart recovery, group behavior, +duplicate suppression, and scheduled delivery remain untested for this live +entry. + +WeChat is currently **source-verified, not live-verified**. The focused tests +cover iLink/local-bridge URL restrictions, QR-login cancellation, message +mapping, media classification, and SSE parsing. They do not exercise a real +iLink `sendmessage` round trip. The current iLink adapter also does not retain +the inbound `context_token` and sends an empty token on outbound replies, so +the reply path needs an implementation fix and a live round-trip check before +WeChat is described as verified. This is an implementation-risk note, not a +claim about provider account restrictions or ban safety. + +Any additional live-validation entry should record only whether the +following smoke checks passed, without including credentials or raw provider +payloads: + +1. channel configuration reaches the expected readiness state; +2. a direct text message reaches Maka and receives one reply; +3. the bridge does not answer its own echoed message; +4. a restart or reconnect does not duplicate the reply; and +5. scheduled delivery is tested only for platforms in the scheduled-delivery + set. + +Until those checks are performed, use "source-verified" rather than "live +verified" in platform-specific setup documentation. diff --git a/docs/bot-platforms.zh-CN.md b/docs/bot-platforms.zh-CN.md new file mode 100644 index 0000000000..8fb5dad0ca --- /dev/null +++ b/docs/bot-platforms.zh-CN.md @@ -0,0 +1,302 @@ +--- +doc_id: bot.platforms +title: "Bot 平台" +language: zh-CN +source_language: en +counterpart: ./bot-platforms.md +implementation_status: current +document_status: draft +translation_status: synced +last_verified: 2026-09-09 +owners: + - maka-backend +--- + + +# Bot 平台 + +本文档描述 Maka 聊天平台 bridge 的能力与安全边界,覆盖 channel 完成配置后的 +Runtime bridge。二维码和其他辅助接入流程另见 +[IM onboarding runtime architecture](./architecture/bot-onboarding-runtime.zh-CN.md)。 + +## 支持的 channel 与能力矩阵 + +当前 `BotProvider` 契约包含 8 个 channel: + +| Channel | 普通对话回复 | 定时 Bot 推送 | 渐进式回复流 | 输入中提示 | 临时回复清理 | 非文本消息分类 | +| --- | --- | --- | --- | --- | --- | --- | +| Telegram | 是 | 是 | 是 | 是 | 是 | 是 | +| 微信 | 是 | 是 | 否 | 否 | 否 | 是 | +| Discord | 是 | 是 | 否 | 是 | 否 | 否 | +| 钉钉 | 是 | 是 | 否 | 否 | 否 | 否 | +| QQ | 是 | 是 | 否 | 是 | 否 | 否 | +| Slack | 是 | 是 | 否 | 否 | 否 | 否 | +| 飞书 / Lark | 是 | 否 | 否 | 否 | 否 | 否 | +| 企业微信 | 是 | 否 | 否 | 否 | 否 | 否 | + +这张表来自当前源码,而不是线上平台测试。“普通对话回复”表示 Runtime bridge +实现了共享的文本发送路径。“定时 Bot 推送”是更窄的产品能力:定时任务目前只 +接受 Telegram、微信、Discord、钉钉、QQ 和 Slack。可选能力列中的“否”表示 +Maka 的共享 bridge 没有实现该能力,不表示外部平台永远不可能支持它。 + +平台数量和定时推送集合必须分开描述。不要把仓库写成支持 9 个平台,也不要因为 +某个平台存在 bridge 就推断它支持定时任务推送。 + +## 配置概览 + +设置对象使用一套较小的通用字段。平台 API 的叫法不同,但 Runtime bridge 会读取 +以下值: + +| Channel | 必需或平台专用的值 | Maka 中的 Runtime 传输 | +| --- | --- | --- | +| Telegram | Bot token;可选代理 URL | 长轮询 | +| 微信 | Bridge URL;iLink 路径还需要 Bot token | 本地 bridge 或 iLink 轮询 | +| Discord | Bot token | Gateway | +| 钉钉 | App ID 和 App Secret | Stream/WebSocket | +| QQ | App ID 和 App Secret | Gateway | +| Slack | Bot token 和 app-level token | Gateway | +| 飞书 / Lark | App ID 和 App Secret;可选 `domain` 用于选择飞书或 Lark | 官方 Channel WebSocket | +| 企业微信 | `appId` 中填写 Bot ID,`appSecret` 中填写 Bot Secret | 官方 AI Bot WebSocket | + +平台接入说明应使用平台自己的控制台术语,并链接官方文档。此表只说明 Maka +Runtime 消费什么值,不代表这些凭据已经通过线上平台验证。 + +这张矩阵的源码依据包括 [`BotProvider` 与 +`BOT_DELIVERY_PROVIDERS`](../packages/core/src/bot-chat-settings.ts) 契约、共享的 +[`SendCapable`](../packages/runtime/src/bots/types.ts) 接口、 +[`packages/runtime/src/bots`](../packages/runtime/src/bots) 下的实现,以及对应的 +[bridge 测试](../packages/runtime/src/bots/__tests__)。当本文档与实现不一致时, +以代码和契约测试为准。 + +## 平台接入清单 + +下面只列出在 Maka 中填值前所需的最小平台侧准备。平台控制台、权限名称、应用审核 +和地区可用性都可能变化;当前操作顺序应以链接的官方文档为准。不要把生成的 +secret 写入本文档。 + +### Telegram + +1. 使用 [BotFather](https://core.telegram.org/bots#how-do-i-create-a-bot) 的 + `/newbot` 创建 Bot,复制 token。 +2. 将 token 填入 Maka 的 `token`;只有 Telegram API 必须通过代理访问时才设置 + `proxyUrl`。 +3. 先向 Bot 发送一条私聊文本消息。测试群聊时,将 Bot 加入测试群,并注意 + Telegram 的群隐私和提及规则。 + +Telegram 是当前唯一同时实现原生渐进式回复、输入中提示和定时临时清理的 bridge。 +这些是可选的投递增强能力,普通文本回复才是基础能力。 + +### Discord + +1. 按照 [Discord Bot 文档](https://docs.discord.com/developers/quick-start/getting-started) + 创建 application 和 Bot。 +2. 将 Bot token 填入 Maka 的 `token`,把 Bot 安装到私有测试服务器,并只授予 + 测试所需的最小消息权限。 +3. 开启 **Message Content** 特权 intent。Maka 通过 Gateway 请求 guild message、 + direct message 和 message content;如果未开启所请求的特权 intent,Discord + 可能关闭连接。 + +Maka 使用 Discord Gateway 接收事件,并使用 REST 调用发送回复;普通 Bot 消息不走 + Discord HTTP interactions endpoint。 +如果 Discord 访问需要代理,channel 中的代理设置只覆盖 Bot 凭据认证;Gateway +WebSocket 仍需要系统级路由(例如 TUN),并且需要重启 Maka。 + +### Slack + +1. 在开发 workspace 中创建并安装 Slack app。 +2. 开启 [Socket Mode](https://api.slack.com/apis/connections/socket),创建具有 + `connections:write` 的 app-level token,并取得 app 安装后的 Bot token。 +3. 将 Bot token 填入 Maka 的 `token`,将 app-level token 填入 `appSecret`,并为 + workspace 测试订阅所需的消息事件。 + +Slack 使用 WebSocket 接收事件,使用 Web API client 回复。验证权限和 allowlist +行为时,应使用私有开发 workspace。 + +### 钉钉 + +1. 创建自建应用,取得 Client ID 和 Client Secret,并在应用内创建 Bot。可参考 + [官方 Stream Bot 教程](https://opensource.dingtalk.com/developerpedia/docs/explore/tutorials/stream/bot/nodejs/create-bot/)。 +2. 将消息接收模式设置为 **Stream**。在 Maka 中将 Client ID 填入 `appId`,将 + Client Secret 填入 `appSecret`。 +3. 先测试私聊。测试群聊时把 Bot 加入群并 @Bot;钉钉只会向 Bot 投递相关的群聊 + 消息。 + +Bridge 使用钉钉 Stream/WebSocket 接收事件,并通过平台 API 回复。除非另有 bridge +实现,不要把卡片或打字机式更新写成 Maka 已支持的能力。 + +### QQ + +1. 在 [QQ Bot 开放平台](https://q.qq.com/qqbot/) 创建官方 Bot,复制 AppID 和 + AppSecret。 +2. 将 AppID 填入 `appId`,将 AppSecret 填入 `appSecret`。 +3. 将 Bot 添加到 QQ 平台允许的私有测试场景,只在该 Bot 获准的范围内测试私聊、 + 群聊或频道消息。 + +Maka 使用 QQ Gateway 接收事件,并使用平台 REST API 发送消息。在审核或正式发布 +前,平台可能限制可用场景;这种平台策略不等同于 Maka 的 readiness 状态。 + +### 飞书与 Lark + +1. 在[飞书开放平台](https://open.feishu.cn/document/home/index)或 + [Lark Developer](https://open.larksuite.com/document/home/index) 创建企业自建应用。 +2. 开启 Bot 以及平台要求的事件/长连接能力,把 App ID 和 App Secret 填入 Maka + 的 `appId` 和 `appSecret`。 +3. 对 Lark,将 `domain` 设置为 `larksuite.com`;飞书账号使用默认的飞书域名。 + 在私聊或开发群中测试。 + +飞书和 Lark 共用 Maka 的 `feishu` channel。Bridge 使用平台官方 Channel WebSocket。 +该 channel 可以正常回复,但目前不是定时任务通知目标。 + +### 企业微信 + +1. 在[企业微信开发者文档](https://developer.work.weixin.qq.com/)创建 AI Bot, + 选择 API 模式和长连接,并复制 Bot ID 与 Secret。 +2. 将 Bot ID 填入 Maka 的 `appId`,将 Secret 填入 `appSecret`。 +3. 先用私聊验证;如果组织允许,再使用私有测试群。 + +Maka 使用官方 AI Bot WebSocket。凭据握手成功并不意味着企业微信可以作为定时任务 +通知目标。 + +### 微信 + +当前实现有两条微信 bridge 路径: + +- **本地 bridge:** 在 `webhookUrl` 提供本地 bridge URL,并遵循该 bridge 自己的 + 认证/配置契约。 +- **iLink:** 在 `webhookUrl` 提供兼容 iLink 的基础 URL,在 `token` 提供 Bot token; + `botUserId` 可用于标识设置中的 Bot。 + +使用本地测试 bridge 或平台批准的 iLink 流程,先发送一条私聊文本消息,再测试群聊 +提及。Bridge 可以分类多种媒体类型,但不会下载媒体字节作为模型输入。 + +## 安全与隐私 + +### 所有进入的消息都是不可信输入 + +启用的 Bot channel 是一个输入面:私聊或群聊中的文本会进入 Agent context。应像 +对待网页、工具结果或粘贴的文件一样谨慎处理。消息声明的作者、指令、链接和附件 +都不能授予它读取文件、执行命令、披露数据或修改 Maka 设置的权限。 + +普通 Session permission mode 与操作系统执行边界仍然是 Agent 行为的权威。Bot +allowlist 只能缩小谁可以发起会话;它不是 sandbox、工具授权系统,也不能替代用户 +审查 permission prompt。完整信任模型和报告流程见项目的 +[Security Policy](../SECURITY.md)。 + +### 凭据不能进入 renderer 或公开记录 + +Bot token、应用 ID 和 secret、Webhook 校验值、device code、poll token 以及原始 +provider response 都属于敏感配置。不要将其粘贴到聊天、Issue、PR、截图、测试 +fixture 或文档示例中。 + +Maka 将 renderer 视为半可信。设置读取会返回掩码值;main-process settings API +不能向 renderer 返回明文 Bot token 或应用 secret。辅助接入会把 device code 和 +poll credential 保留在 main-process session,只暴露 renderer-safe snapshot。这些 +是机密性边界,但不承诺每个平台的凭据都在磁盘上加密:当前 at-rest 保护是仅 owner +可访问的本地设置存储和用户操作系统账户。 + +修改 bridge 或 onboarding flow 时: + +- 在日志或用户诊断到达前,脱敏包含凭据的 URL、header、request body 和 provider error; +- 保持 preload IPC surface 狭窄,不能让 renderer 获得任意访问 provider 的 HTTP 能力 + 或 adapter 注入路径; +- 设置表单中的掩码占位符只表示“保留现有值”,不能当成真实凭据。 + +### 需要时限制谁能联系 Bot + +`allowedUserIds` 是可选的按 channel allowlist。缺失或为空时保持现有的开放行为;非空 +时,bridge 会静默丢弃其他平台原生用户 ID 的消息,不发送可能帮助未授权发送者探测 +策略的拒绝消息。ID 以字符串存储,因为部分平台 ID 超过 JavaScript 安全整数范围。 + +个人、开发或其他受限 Bot 应配置 allowlist。公共 Bot 只有在所有者有意接受不可信消息 +并选择了合适的 Session permission policy 时,才应保持为空。 + +### 非文本消息不是模型附件 + +部分 bridge 能识别收到的图片、语音、文档或其他非文本 payload。识别结果用于发送 +清晰的文字提示,告诉用户 Maka 无法处理这些内容;它不会下载二进制、把内容提供给 +模型,也不会建立多模态输入边界。 + +因此,文档和 UI 应写“非文本消息检测”,而不是“文件附件支持”,除非某个平台有明确 +且经过审查的二进制输入链路。 + +### 疑似漏洞应私下报告 + +凭据泄露、缺失认证或签名校验、IPC 暴露 secret,或绕过既定安全边界的问题,不要通过 +公开 Issue 报告。请按照 [SECURITY.md](../SECURITY.md#reporting-a-vulnerability) 的 +私下报告流程,提供 bridge、最小复现和 Maka 版本或 commit。 + +## 运行状态与限制 + +Runtime 使用明确状态而不是单一布尔值报告 channel 健康:`scaffolded`、`configured`、 +`credentials_valid`、`operational` 和 `degraded`。已配置不等于已连接,凭据检查成功 +也不证明端到端回复已经送达。UI 和接入文档应保留这个区别。 + +当前共享 Bot 契约以文本为主: + +- 文本消息可以进入 Session 并收到文本回复; +- 部分 bridge 会分类非文本消息,让用户收到限制提示; +- bridge 不会让模型获得图片、语音消息或文档字节; +- 可选能力必须按 channel 描述,不能从 provider 的一般产品能力推断。 + +定时推送也比 channel 支持范围更窄。Bridge 可以支持实时对话,但仍可能不能作为 +定时任务通知目标。定时任务 UI 和协议应使用 core contract 中的 6 个投递平台,而不是 +所有已配置 channel。 + +## 验证状态 + +本文档的能力矩阵和配置概览来自源码,并与当前 provider contract、bridge 实现和 +Runtime 聚焦测试核对过。它不是线上凭据记录,也不保证每个平台账号在每个地区都能 +创建。 + +### 线上实测 + +飞书(中国区账号,手动配置)已于 2026-09-10 完成实测。Channel 成功连接; +私聊文本消息收到一条回复;在群聊中 @Maka 发送文本消息后收到一条回复,且正文 +内容保持正确;重启 Maka 后再次发送群消息仍收到一条回复。实测没有记录凭据、用户 +ID 或平台原始 payload。本次仅验证普通文本对话和重启恢复,能力矩阵中的可选能力 +仍以源码验证为准。 + +Discord(中国大陆网络,手动配置)已于 2026-09-10 使用 Discord 网页版完成实测。 +在测试服务器群聊中 @Maka 发送文本消息后,消息成功到达 bridge,Bot 在同一 Discord +频道返回一条回复,且指定正文保持正确。本次实测需要可用的系统级 TUN 路由来承载 +Gateway WebSocket。实测没有记录凭据、用户 ID 或平台原始 payload;本条仅验证一次 +普通群消息收发,尚未验证重启恢复、重复消息抑制或定时推送。 + +Telegram(中国大陆网络,手动配置)已于 2026-09-10 完成实测。私聊中的 `/start` 和 +文本测试消息成功到达 bridge,Bot 返回一条回复,且测试正文保持正确。实测没有记录 +凭据、用户 ID 或平台原始 payload;本条仅验证一次普通私聊收发,尚未验证重启恢复、 +群聊行为、重复消息抑制或定时推送。 + +微信目前标记为“源码验证,未线上实测”。聚焦测试覆盖了 iLink/本地 bridge 地址限制、 +二维码登录取消、消息映射、媒体分类和 SSE 解析,但没有真正调用 iLink +`sendmessage` 完成收发闭环。当前 iLink bridge 也没有保留入站消息的 `context_token`, +出站回复固定发送空 token;因此在修复并完成真实收发验证前,不应把微信描述为“已验证可用”。 +这只是实现风险说明,不代表对平台账号限制或封号风险作出结论。 + +后续其他平台完成线上验证时,只记录以下脱敏后的 smoke check 结果,不记录凭据或原始 +provider payload: + +1. channel 配置达到预期 readiness 状态; +2. 一条私聊文本消息到达 Maka 并收到一条回复; +3. bridge 不回复自己的 echo 消息; +4. 重启或重连不会重复发送回复; +5. 只有定时投递集合中的平台才测试定时推送。 + +在这些检查完成前,平台专属接入文档应使用“源码验证”,而不是“线上验证”。 From ba46809b33c7b335213883bbcd2678053b9f191d Mon Sep 17 00:00:00 2001 From: Chen Date: Mon, 14 Sep 2026 21:35:43 +0800 Subject: [PATCH 2/5] docs: link bot platform guide Generated-by: Codex --- README.md | 1 + docs/bot-platforms.md | 2 +- docs/bot-platforms.zh-CN.md | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 81c955da3a..c5f68ae4d2 100644 --- a/README.md +++ b/README.md @@ -242,6 +242,7 @@ Before submitting code, run typecheck, build, and focused tests proportionate to - [Website](https://maka.apache.org/en/) - [Documentation index and authority map](./docs/README.md) +- [Bot platform guide](./docs/bot-platforms.md) ([中文](./docs/bot-platforms.zh-CN.md)) - [Backend architecture](./ARCHITECTURE.md) - [Product design](./DESIGN.md) - [Contributing guide](./CONTRIBUTING.md) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index da8cc517f9..9c20087038 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -7,7 +7,7 @@ implementation_status: current document_status: draft counterpart: ./bot-platforms.zh-CN.md translation_status: synced -last_verified: 2026-09-09 +last_verified: 2026-09-10 owners: - maka-backend --- diff --git a/docs/bot-platforms.zh-CN.md b/docs/bot-platforms.zh-CN.md index 8fb5dad0ca..06d1a3be68 100644 --- a/docs/bot-platforms.zh-CN.md +++ b/docs/bot-platforms.zh-CN.md @@ -7,7 +7,7 @@ counterpart: ./bot-platforms.md implementation_status: current document_status: draft translation_status: synced -last_verified: 2026-09-09 +last_verified: 2026-09-10 owners: - maka-backend --- From 1c0c8f00f4761b00f107c1004019d569f302a1d1 Mon Sep 17 00:00:00 2001 From: Chen Date: Mon, 14 Sep 2026 22:16:09 +0800 Subject: [PATCH 3/5] docs: clarify bot platform boundaries Generated-by: Codex --- docs/bot-platforms.md | 60 ++++++++++++++++++++++++------------- docs/bot-platforms.zh-CN.md | 42 ++++++++++++++++---------- 2 files changed, 66 insertions(+), 36 deletions(-) diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index 9c20087038..97382ecc9c 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -43,11 +43,11 @@ The current `BotProvider` contract contains eight channels: | Channel | Normal conversation reply | Scheduled bot delivery | Progressive reply stream | Typing indicator | Ephemeral reply cleanup | Non-text message classification | | --- | --- | --- | --- | --- | --- | --- | -| Telegram | Yes | Yes | Yes | Yes | Yes | Yes | +| Telegram | Yes | Yes | Yes (private chat only) | Yes | Yes | Yes | | WeChat | Yes | Yes | No | No | No | Yes | | Discord | Yes | Yes | No | Yes | No | No | | DingTalk | Yes | Yes | No | No | No | No | -| QQ | Yes | Yes | No | Yes | No | No | +| QQ | Yes | Yes | No | Yes (guild channel only) | No | No | | Slack | Yes | Yes | No | No | No | No | | Feishu / Lark | Yes | No | No | No | No | No | | WeCom | Yes | No | No | No | No | No | @@ -60,6 +60,11 @@ DingTalk, QQ, and Slack. A `No` in the optional-feature columns means that the shared Maka bridge does not implement that feature; it does not claim that the external provider could never support it. +Progressive reply streaming is currently private-chat only for Telegram; group +conversations do not receive a stream. QQ's typing indicator is limited to +guild-channel conversations (`channel:` targets); it is not available for QQ +groups or C2C chats. + The platform count and the scheduled-delivery set are intentionally separate. Do not describe the repository as supporting nine platforms, and do not infer scheduled-task support from the existence of a bridge alone. @@ -71,7 +76,7 @@ names differ, but the Runtime bridge reads the following values: | Channel | Required or provider-specific values | Runtime transport in Maka | | --- | --- | --- | -| Telegram | Bot token; optional proxy URL | Long polling | +| Telegram | Bot token | Long polling | | WeChat | Bridge URL and, for the iLink path, a bot token | Local bridge or iLink polling | | Discord | Bot token | Gateway | | DingTalk | App ID and app secret | Stream/WebSocket | @@ -85,6 +90,12 @@ link to its official documentation. This table only describes what the Maka Runtime consumes; it is not a claim that the credentials have been validated against a live provider. +Bot HTTP requests currently use the active global network proxy resolved by +`proxiedFetch()`. The channel-level `proxyUrl` setting is not consumed by the +current Telegram or Discord bridges, so do not rely on it for routing. Long- +lived WebSocket transports, including Discord Gateway, may still require a +system-level route such as TUN. + The source of truth for this matrix is the [`BotProvider` and `BOT_DELIVERY_PROVIDERS`](../packages/core/src/bot-chat-settings.ts) contract, the shared [`SendCapable`](../packages/runtime/src/bots/types.ts) interface, @@ -104,8 +115,9 @@ the current portal sequence. Never put the resulting secrets in this document. 1. Use [BotFather](https://core.telegram.org/bots#how-do-i-create-a-bot) to create a bot with `/newbot` and copy its token. -2. Put the token in Maka's `token` field. Set `proxyUrl` only when the Telegram - API must be reached through a proxy. +2. Put the token in Maka's `token` field. For network routing, configure Maka's + active global network proxy; the channel-level `proxyUrl` field is not + currently consumed by this bridge. 3. Send the bot a direct text message first. For a group smoke test, add the bot to a test group and account for Telegram group privacy and mention rules. @@ -125,9 +137,10 @@ optional delivery enhancements; a normal text reply remains the baseline. Maka uses Discord's Gateway for inbound events and REST calls for sends. It does not use Discord's HTTP interactions endpoint for ordinary bot messages. -When Discord access requires a proxy, the channel proxy setting covers Bot -authentication only; the Gateway WebSocket still requires a system-level route -such as TUN, followed by an app restart. +When Discord access requires a proxy, configure Maka's active global network +proxy for HTTP requests. The channel-level `proxyUrl` field is not consumed by +the current Telegram or Discord bridges; the Gateway WebSocket still requires +a system-level route such as TUN, followed by an app restart. ### Slack @@ -221,9 +234,10 @@ instructions, links, and attachments do not grant it permission to read files, run commands, disclose data, or change Maka settings. The normal Session permission mode and its operating-system execution boundary -remain the authority for agent actions. A bot allowlist narrows who may start a -conversation; it is not a sandbox, an authorization system for tools, or a -substitute for reviewing permission prompts. See the project +remain the authority for agent actions. Where a bridge implements a bot +allowlist, it can narrow who may start a conversation; it is not a sandbox, an +authorization system for tools, or a substitute for reviewing permission +prompts. See the project [Security Policy](../SECURITY.md) for Maka's trust model and reporting process. ### Keep platform credentials out of the renderer and out of public records @@ -252,16 +266,20 @@ When changing a bridge or an onboarding flow: ### Restrict who can contact a bot when the deployment needs it -`allowedUserIds` is an optional per-channel allowlist. When it is absent or -empty, the bridge preserves the existing unrestricted behavior. When it is -non-empty, a bridge silently drops messages from other platform-native user -IDs; it does not send a rejection that could help an unauthorized sender probe -the policy. IDs are stored as strings because some platform identifiers exceed -JavaScript's safe integer range. - -Configure an allowlist for a personal, development, or otherwise restricted -bot. For a public bot, leave it empty only when the owner intentionally accepts -untrusted messages and has chosen an appropriate Session permission policy. +`allowedUserIds` is not a universal per-channel allowlist. The current +bridge-specific enforcement covers Telegram, Feishu / Lark, and WeCom. +Discord, DingTalk, QQ, Slack, and WeChat do not currently apply a central +Runtime filter for this field. Use the field only where the corresponding +bridge documents enforcement; for other channels, do not treat it as a +security boundary. + +For an enforcing bridge, an absent or empty list preserves the existing +unrestricted behavior. A non-empty list makes the bridge silently drop +messages from other platform-native user IDs; it does not send a rejection +that could help an unauthorized sender probe the policy. IDs are stored as +strings because some platform identifiers exceed JavaScript's safe integer +range. For unsupported channels, use provider-side membership or permissions +and a restricted development resource instead. ### Non-text messages are not model attachments diff --git a/docs/bot-platforms.zh-CN.md b/docs/bot-platforms.zh-CN.md index 06d1a3be68..81fc3116a5 100644 --- a/docs/bot-platforms.zh-CN.md +++ b/docs/bot-platforms.zh-CN.md @@ -42,11 +42,11 @@ Runtime bridge。二维码和其他辅助接入流程另见 | Channel | 普通对话回复 | 定时 Bot 推送 | 渐进式回复流 | 输入中提示 | 临时回复清理 | 非文本消息分类 | | --- | --- | --- | --- | --- | --- | --- | -| Telegram | 是 | 是 | 是 | 是 | 是 | 是 | +| Telegram | 是 | 是 | 是(仅私聊) | 是 | 是 | 是 | | 微信 | 是 | 是 | 否 | 否 | 否 | 是 | | Discord | 是 | 是 | 否 | 是 | 否 | 否 | | 钉钉 | 是 | 是 | 否 | 否 | 否 | 否 | -| QQ | 是 | 是 | 否 | 是 | 否 | 否 | +| QQ | 是 | 是 | 否 | 是(仅频道) | 否 | 否 | | Slack | 是 | 是 | 否 | 否 | 否 | 否 | | 飞书 / Lark | 是 | 否 | 否 | 否 | 否 | 否 | | 企业微信 | 是 | 否 | 否 | 否 | 否 | 否 | @@ -59,6 +59,9 @@ Maka 的共享 bridge 没有实现该能力,不表示外部平台永远不可 平台数量和定时推送集合必须分开描述。不要把仓库写成支持 9 个平台,也不要因为 某个平台存在 bridge 就推断它支持定时任务推送。 +Telegram 的渐进式回复流目前仅适用于私聊,群聊不会收到回复流。QQ 的输入中提示 +目前仅限频道消息(`channel:` 目标),不适用于 QQ 群或 C2C 私聊。 + ## 配置概览 设置对象使用一套较小的通用字段。平台 API 的叫法不同,但 Runtime bridge 会读取 @@ -66,7 +69,7 @@ Maka 的共享 bridge 没有实现该能力,不表示外部平台永远不可 | Channel | 必需或平台专用的值 | Maka 中的 Runtime 传输 | | --- | --- | --- | -| Telegram | Bot token;可选代理 URL | 长轮询 | +| Telegram | Bot token | 长轮询 | | 微信 | Bridge URL;iLink 路径还需要 Bot token | 本地 bridge 或 iLink 轮询 | | Discord | Bot token | Gateway | | 钉钉 | App ID 和 App Secret | Stream/WebSocket | @@ -78,6 +81,11 @@ Maka 的共享 bridge 没有实现该能力,不表示外部平台永远不可 平台接入说明应使用平台自己的控制台术语,并链接官方文档。此表只说明 Maka Runtime 消费什么值,不代表这些凭据已经通过线上平台验证。 +当前 Bot 的 HTTP 请求使用 `proxiedFetch()` 解析出的全局活动网络代理。当前 Telegram +和 Discord bridge 都不会读取 channel 级别的 `proxyUrl`,因此不要依赖该字段完成网络 +转发。包括 Discord Gateway 在内的长连接 WebSocket 传输,仍可能需要 TUN 这样的系统级 +路由。 + 这张矩阵的源码依据包括 [`BotProvider` 与 `BOT_DELIVERY_PROVIDERS`](../packages/core/src/bot-chat-settings.ts) 契约、共享的 [`SendCapable`](../packages/runtime/src/bots/types.ts) 接口、 @@ -95,8 +103,8 @@ secret 写入本文档。 1. 使用 [BotFather](https://core.telegram.org/bots#how-do-i-create-a-bot) 的 `/newbot` 创建 Bot,复制 token。 -2. 将 token 填入 Maka 的 `token`;只有 Telegram API 必须通过代理访问时才设置 - `proxyUrl`。 +2. 将 token 填入 Maka 的 `token`。如需代理网络,请配置 Maka 的全局活动网络代理;当前 + bridge 不会读取 channel 级别的 `proxyUrl`。 3. 先向 Bot 发送一条私聊文本消息。测试群聊时,将 Bot 加入测试群,并注意 Telegram 的群隐私和提及规则。 @@ -115,8 +123,9 @@ Telegram 是当前唯一同时实现原生渐进式回复、输入中提示和 Maka 使用 Discord Gateway 接收事件,并使用 REST 调用发送回复;普通 Bot 消息不走 Discord HTTP interactions endpoint。 -如果 Discord 访问需要代理,channel 中的代理设置只覆盖 Bot 凭据认证;Gateway -WebSocket 仍需要系统级路由(例如 TUN),并且需要重启 Maka。 +如果 Discord 访问需要代理,请为 HTTP 请求配置 Maka 的全局活动网络代理。当前 Telegram +和 Discord bridge 都不会读取 channel 级别的 `proxyUrl`;Gateway WebSocket 仍需要系统级 +路由(例如 TUN),并且需要重启 Maka。 ### Slack @@ -194,9 +203,9 @@ Maka 使用官方 AI Bot WebSocket。凭据握手成功并不意味着企业微 对待网页、工具结果或粘贴的文件一样谨慎处理。消息声明的作者、指令、链接和附件 都不能授予它读取文件、执行命令、披露数据或修改 Maka 设置的权限。 -普通 Session permission mode 与操作系统执行边界仍然是 Agent 行为的权威。Bot -allowlist 只能缩小谁可以发起会话;它不是 sandbox、工具授权系统,也不能替代用户 -审查 permission prompt。完整信任模型和报告流程见项目的 +普通 Session permission mode 与操作系统执行边界仍然是 Agent 行为的权威。在 bridge +实际实现 allowlist 的平台上,它可以缩小谁能发起会话;它不是 sandbox、工具授权系统, +也不能替代用户审查 permission prompt。完整信任模型和报告流程见项目的 [Security Policy](../SECURITY.md)。 ### 凭据不能进入 renderer 或公开记录 @@ -220,12 +229,15 @@ poll credential 保留在 main-process session,只暴露 renderer-safe snapsho ### 需要时限制谁能联系 Bot -`allowedUserIds` 是可选的按 channel allowlist。缺失或为空时保持现有的开放行为;非空 -时,bridge 会静默丢弃其他平台原生用户 ID 的消息,不发送可能帮助未授权发送者探测 -策略的拒绝消息。ID 以字符串存储,因为部分平台 ID 超过 JavaScript 安全整数范围。 +`allowedUserIds` 不是所有 channel 通用的 allowlist。当前明确实现 bridge 级别过滤的是 +Telegram、飞书 / Lark 和企业微信;Discord、钉钉、QQ、Slack 与微信当前不会对该字段 +应用统一的 Runtime 过滤。因此只能在对应 bridge 明确实现的情况下使用该字段,其他平台 +不能把它当成安全边界。 -个人、开发或其他受限 Bot 应配置 allowlist。公共 Bot 只有在所有者有意接受不可信消息 -并选择了合适的 Session permission policy 时,才应保持为空。 +对于实际执行过滤的 bridge,列表缺失或为空时保持现有的开放行为;列表非空时,bridge +会静默丢弃其他平台原生用户 ID 的消息,不发送可能帮助未授权发送者探测策略的拒绝消息。 +ID 以字符串存储,因为部分平台 ID 超过 JavaScript 安全整数范围。对于不支持该字段的 +平台,应使用平台侧成员/权限控制,并限制在受限的开发资源中。 ### 非文本消息不是模型附件 From 57caebc06d986f3b3960d6f37332669a2491af16 Mon Sep 17 00:00:00 2001 From: Chen Date: Tue, 15 Sep 2026 00:12:54 +0800 Subject: [PATCH 4/5] fix(bots): apply configured proxy to client traffic Refs #5091 --- .../__tests__/client-network-proxy.test.ts | 177 +++++++++++++++ .../runtime-host-settings-ipc-main.test.ts | 17 ++ apps/desktop/src/main/client-network-proxy.ts | 117 ++++++++++ apps/desktop/src/main/runtime-host-boot.ts | 13 ++ apps/desktop/src/main/runtime-host-client.ts | 4 + .../main/runtime-host-settings-ipc-main.ts | 6 + .../network-proxy-coordinator.test.ts | 203 ++++++++++++++++++ packages/runtime-host/src/protocol/index.ts | 6 +- .../src/protocol/network-proxy.ts | 93 +++++++- .../runtime-host/src/protocol/operations.ts | 1 + .../src/server/network-proxy-coordinator.ts | 40 ++++ packages/runtime/package.json | 1 + 12 files changed, 676 insertions(+), 2 deletions(-) create mode 100644 apps/desktop/src/main/__tests__/client-network-proxy.test.ts create mode 100644 apps/desktop/src/main/client-network-proxy.ts create mode 100644 packages/runtime-host/src/__tests__/network-proxy-coordinator.test.ts diff --git a/apps/desktop/src/main/__tests__/client-network-proxy.test.ts b/apps/desktop/src/main/__tests__/client-network-proxy.test.ts new file mode 100644 index 0000000000..7c7b11b010 --- /dev/null +++ b/apps/desktop/src/main/__tests__/client-network-proxy.test.ts @@ -0,0 +1,177 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from "node:assert/strict"; +import { describe, test } from "node:test"; +import type { ProxySettings } from "@maka/core/settings/network-settings"; +import type { NetworkProxyResolveResult } from "@maka/runtime-host/protocol"; +import { createClientNetworkProxyApplier } from "../client-network-proxy.js"; + +const RESOLVED: NetworkProxyResolveResult = { + kind: "ready", + proxy: { + enabled: true, + type: "http", + host: "127.0.0.1", + port: 7897, + bypassList: ["localhost"], + }, +}; + +function harness( + profileKind: "local" | "environment" | "remote", + resolve: () => Promise, +) { + const applied: (ProxySettings | null)[] = []; + const errors: unknown[] = []; + const scheduled: { run: () => void; delayMs: number }[] = []; + const applier = createClientNetworkProxyApplier({ + profileKind, + resolve, + apply: (proxy) => applied.push(proxy), + onError: (error) => errors.push(error), + schedule: (run, delayMs) => scheduled.push({ run, delayMs }), + }); + return { applier, applied, errors, scheduled }; +} + +const flush = () => new Promise((resolve) => setImmediate(resolve)); + +describe("createClientNetworkProxyApplier", () => { + test("applies the resolved proxy for a local Host", async () => { + const { applier, applied } = harness("local", async () => RESOLVED); + await applier.refresh(); + assert.deepStrictEqual(applied, [RESOLVED.proxy]); + }); + + test("applies direct when the policy disables the proxy", async () => { + const { applier, applied } = harness("local", async () => ({ kind: "ready" })); + await applier.refresh(); + assert.deepStrictEqual(applied, [null]); + }); + + test("applies direct when the proxy credential is missing", async () => { + const { applier, applied } = harness("local", async () => ({ + kind: "credential_not_configured", + })); + await applier.refresh(); + assert.deepStrictEqual(applied, [null]); + }); + + test("never adopts a non-local Host's proxy policy", async () => { + for (const kind of ["remote", "environment"] as const) { + let resolved = false; + const { applier, applied } = harness(kind, async () => { + resolved = true; + return RESOLVED; + }); + await applier.refresh(); + // The bot bridges dial out from this machine, so a Host describing a + // different network must not be asked, let alone applied. + assert.strictEqual(resolved, false, `${kind} resolved the proxy`); + assert.deepStrictEqual(applied, [null]); + } + }); + + test("keeps the last applied proxy when resolution fails", async () => { + let fail = false; + const { applier, applied, errors } = harness("local", async () => { + if (fail) throw new Error("Host unreachable"); + return RESOLVED; + }); + await applier.refresh(); + fail = true; + await applier.refresh(); + // A brief Host outage, or an older Host without the operation, is not + // evidence that the user wants direct connections. + assert.deepStrictEqual(applied, [RESOLVED.proxy]); + assert.strictEqual(errors.length, 1); + }); + + test("reports a repeated resolution failure once", async () => { + const { applier, errors } = harness("local", async () => { + throw new Error("Host unreachable"); + }); + await applier.refresh(); + await applier.refresh(); + assert.strictEqual(errors.length, 1); + }); + + test("retries a failed first resolution instead of settling on direct", async () => { + // The first refresh runs while the Host connection is still settling, so + // losing that race must not leave the bot bridges direct until the user + // next edits the proxy. + let fail = true; + const { applier, applied, scheduled } = harness("local", async () => { + if (fail) throw new Error("host_not_ready"); + return RESOLVED; + }); + await applier.refresh(); + assert.deepStrictEqual(applied, []); + assert.strictEqual(scheduled.length, 1); + + fail = false; + scheduled[0]?.run(); + await flush(); + assert.deepStrictEqual(applied, [RESOLVED.proxy]); + }); + + test("bounds the retry budget instead of reconnecting forever", async () => { + const { applier, scheduled } = harness("local", async () => { + throw new Error("host_not_ready"); + }); + await applier.refresh(); + for (let index = 0; index < 6; index += 1) { + const pending = scheduled[index]; + if (!pending) break; + pending.run(); + await flush(); + } + assert.deepStrictEqual( + scheduled.map((entry) => entry.delayMs), + [1_000, 5_000, 15_000], + ); + }); + + test("serializes concurrent refreshes so the last resolution wins", async () => { + const gates: (() => void)[] = []; + const order: string[] = []; + let call = 0; + const { applier, applied } = harness("local", async () => { + const index = call++; + order.push(`start:${index}`); + await new Promise((resolve) => gates.push(resolve)); + order.push(`end:${index}`); + return index === 0 ? RESOLVED : { kind: "ready" }; + }); + + const first = applier.refresh(); + const second = applier.refresh(); + await flush(); + // The second resolution must not have begun while the first is in flight. + assert.deepStrictEqual(order, ["start:0"]); + gates[0]?.(); + await first; + await flush(); + gates[1]?.(); + await second; + assert.deepStrictEqual(order, ["start:0", "end:0", "start:1", "end:1"]); + assert.deepStrictEqual(applied, [RESOLVED.proxy, null]); + }); +}); diff --git a/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts b/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts index 3901456cc9..5729b5bbcf 100644 --- a/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts +++ b/apps/desktop/src/main/__tests__/runtime-host-settings-ipc-main.test.ts @@ -250,6 +250,7 @@ function createModuleFixture(options: { }, }; + let networkProxyChanges = 0; const module = createRuntimeHostSettingsModule({ client: client as never, settingsStore: { @@ -261,6 +262,9 @@ function createModuleFixture(options: { }, } as never, async applyClientSettings() {}, + onNetworkProxyChanged: () => { + networkProxyChanges += 1; + }, }); return { @@ -268,9 +272,22 @@ function createModuleFixture(options: { events, policy: () => policy, secret: () => secret, + networkProxyChanges: () => networkProxyChanges, }; } +test("a proxy patch notifies Client-owned traffic so it re-resolves", async () => { + const fixture = createModuleFixture(); + + // Without this the bot bridges keep the proxy they were started with, which + // in practice means none at all (apache/maka#5091). + await fixture.module.update({ network: { proxy: { host: "127.0.0.1" } } }); + assert.equal(fixture.networkProxyChanges(), 1); + + await fixture.module.update({ personalization: { displayName: "Operator" } }); + assert.equal(fixture.networkProxyChanges(), 1); +}); + test("runtime settings project credential status without a password value", async () => { const fixture = createModuleFixture({ configured: true }); diff --git a/apps/desktop/src/main/client-network-proxy.ts b/apps/desktop/src/main/client-network-proxy.ts new file mode 100644 index 0000000000..5aec5aca81 --- /dev/null +++ b/apps/desktop/src/main/client-network-proxy.ts @@ -0,0 +1,117 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Applies the Runtime Policy network proxy to the Client's own outbound + * traffic. + * + * Model execution resolves the proxy inside the Host and injects a transport + * per connection. The bot bridges do not: `BotRegistry` is constructed in this + * process, so its `proxiedFetch` reads process-wide state that only this module + * writes. Without it the seam stays empty and every bot request goes direct, + * whatever the Network settings say (apache/maka#5091). + */ + +import type { ProxySettings } from "@maka/core/settings/network-settings"; +import type { RuntimeHostProfileKind } from "@maka/runtime-host/profile-kind"; +import type { NetworkProxyResolveResult } from "@maka/runtime-host/protocol"; +import { setActiveProxy } from "@maka/runtime/network/active-proxy-state"; + +/** + * The first resolution runs while the Host connection is still settling, so a + * `host_not_ready` refusal is expected rather than terminal. Without a retry a + * Client that loses that race stays direct until the user next edits the proxy. + */ +const RETRY_DELAYS_MS = [1_000, 5_000, 15_000] as const; + +export interface ClientNetworkProxyDeps { + readonly profileKind: RuntimeHostProfileKind; + readonly resolve: () => Promise; + readonly apply?: (proxy: ProxySettings | null) => void; + readonly onError?: (error: unknown) => void; + readonly schedule?: (run: () => void, delayMs: number) => void; +} + +export interface ClientNetworkProxyApplier { + /** Re-resolves and applies. Safe to call concurrently; calls are serialized. */ + refresh(): Promise; +} + +export function createClientNetworkProxyApplier( + deps: ClientNetworkProxyDeps, +): ClientNetworkProxyApplier { + const apply = deps.apply ?? setActiveProxy; + const schedule = + deps.schedule ?? + ((run, delayMs) => { + setTimeout(run, delayMs).unref?.(); + }); + let lane: Promise = Promise.resolve(); + let lastReportedError: string | undefined; + let attempt = 0; + + const refreshWithoutLane = async (): Promise => { + // A non-local Host describes a different machine's network. The bot + // bridges dial out from this one, so its proxy policy does not apply and + // guessing would be worse than staying direct. + if (deps.profileKind !== "local") { + apply(null); + return; + } + let resolved: NetworkProxyResolveResult; + try { + resolved = await deps.resolve(); + } catch (error) { + // Keep the last applied proxy. A Host that is briefly unreachable is not + // evidence that the user wants direct connections. + const message = error instanceof Error ? error.message : String(error); + if (message !== lastReportedError) { + lastReportedError = message; + deps.onError?.(error); + } + const delayMs = RETRY_DELAYS_MS[attempt]; + if (delayMs !== undefined) { + attempt += 1; + schedule(() => void enqueue(), delayMs); + } + return; + } + lastReportedError = undefined; + attempt = 0; + apply(resolved.kind === "ready" ? (resolved.proxy ?? null) : null); + }; + + const enqueue = (): Promise => { + const result = lane.then(refreshWithoutLane, refreshWithoutLane); + lane = result.then( + () => undefined, + () => undefined, + ); + return result; + }; + + return { + refresh() { + // An explicit refresh means the policy changed, so the pending retry + // budget from an earlier failure no longer applies. + attempt = 0; + return enqueue(); + }, + }; +} diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 6757924296..5b4f5a722e 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -245,6 +245,7 @@ import { registerRuntimeHostPermissionsIpc } from "./runtime-host-permissions-ip import { registerRuntimeHostRendererIpc } from "./runtime-host-renderer-ipc-main.js"; import { registerRuntimeHostSearchIpc } from "./runtime-host-search-ipc-main.js"; import { createRuntimeHostProjectCatalog } from "./runtime-host-project-catalog.js"; +import { createClientNetworkProxyApplier } from "./client-network-proxy.js"; import { createRuntimeHostDefaultRecovery } from "./runtime-host-default-recovery.js"; import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js"; import { @@ -1691,12 +1692,24 @@ function registerHostClientIpc( openPath: (path) => shell.openPath(path), allowLocalPaths: !usesHostWorkspace, }); + // Client-owned outbound traffic (the bot bridges) is proxied here, not in the + // Host: it runs in this process and the Host never sees it. + const clientNetworkProxy = createClientNetworkProxyApplier({ + profileKind: target.kind, + resolve: () => client.resolveNetworkProxy(), + onError: (error) => + console.error("[runtime-host] Client network proxy resolution failed:", error), + }); + void clientNetworkProxy.refresh(); const runtimeHostSettings = createRuntimeHostSettingsModule({ client, settingsStore, applyClientSettings: async (settings) => { await clientSettingsEffects.apply(settings, true); }, + onNetworkProxyChanged: () => { + void clientNetworkProxy.refresh(); + }, }); registerRuntimeHostSettingsIpc({ ipcMain: scopedIpc, diff --git a/apps/desktop/src/main/runtime-host-client.ts b/apps/desktop/src/main/runtime-host-client.ts index ab21f66cd7..fcaac9e8f8 100644 --- a/apps/desktop/src/main/runtime-host-client.ts +++ b/apps/desktop/src/main/runtime-host-client.ts @@ -1348,6 +1348,10 @@ export class DesktopRuntimeHostClient { return this.request("network-proxy.test", input); } + resolveNetworkProxy(): Promise> { + return this.request("network-proxy.resolve", {}); + } + exportConfigurationCredentials( input: OperationInput<"configuration.credentials.export">, ): Promise> { diff --git a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts index 1afdb3dbd0..278b55541b 100644 --- a/apps/desktop/src/main/runtime-host-settings-ipc-main.ts +++ b/apps/desktop/src/main/runtime-host-settings-ipc-main.ts @@ -78,6 +78,11 @@ export interface RuntimeHostSettingsIpcDeps { readonly client: RuntimeHostSettingsClient; readonly settingsStore: SettingsStore; readonly applyClientSettings: (settings: AppSettings) => Promise; + /** + * Called after a patch writes the network proxy policy, so Client-owned + * traffic can pick the new proxy up without a restart. + */ + readonly onNetworkProxyChanged?: () => void; } export type RuntimeHostSettingsModuleDeps = Omit< @@ -308,6 +313,7 @@ async function updateRuntimeHostSettingsForImportWithoutLane( ): Promise { validateProxyPatch(patch.network?.proxy); const skippedCredentials = await applyHostPatchWithoutLane(deps.client, patch, guard); + if (patch.network?.proxy) deps.onNetworkProxyChanged?.(); const clientPatch = clientOwnedSettingsPatch(patch); const local = hasSettingsPatch(clientPatch) ? await deps.settingsStore.update(clientPatch) diff --git a/packages/runtime-host/src/__tests__/network-proxy-coordinator.test.ts b/packages/runtime-host/src/__tests__/network-proxy-coordinator.test.ts new file mode 100644 index 0000000000..dcd76d76c5 --- /dev/null +++ b/packages/runtime-host/src/__tests__/network-proxy-coordinator.test.ts @@ -0,0 +1,203 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import assert from 'node:assert/strict'; +import { describe, test } from 'node:test'; +import { createDefaultRuntimePolicy, type RuntimePolicy } from '@maka/core/runtime-policy'; +import { HOST_OPERATION_SPECS } from '../protocol/operations.js'; +import { HostNetworkProxyCoordinator } from '../server/network-proxy-coordinator.js'; +import type { ConnectionContext } from '../server/operation-dispatcher.js'; + +const CONNECTION: ConnectionContext = { + hostEpoch: 'host-epoch-1', + connectionId: 'connection-1', + principal: 'local_os_user', + acquireResidency: () => ({ release: () => undefined }), +}; + +function coordinatorFor( + networkProxy: Partial, + secret?: string, +): HostNetworkProxyCoordinator { + const policy = createDefaultRuntimePolicy(); + return new HostNetworkProxyCoordinator({ + async resolveNetworkProxyExecution() { + return { + kind: 'ready', + networkProxy: { ...policy.networkProxy, ...networkProxy }, + secretMaterial: secret === undefined ? {} : { networkProxy: { secret } }, + }; + }, + } as never); +} + +async function resolve(coordinator: HostNetworkProxyCoordinator) { + const outcome = await coordinator.handlers['network-proxy.resolve']({}, CONNECTION); + assert.ok(outcome.ok, 'network-proxy.resolve failed'); + return outcome.result; +} + +describe('network-proxy.resolve', () => { + test('serves the enabled proxy with its merged bypass list', async () => { + const result = await resolve( + coordinatorFor({ + enabled: true, + protocol: 'http', + host: '127.0.0.1', + port: 7897, + authEnabled: false, + username: '', + bypassList: ['localhost'], + autoBypassDomains: ['metaso.cn', 'localhost'], + }), + ); + assert.strictEqual(result.kind, 'ready'); + assert.strictEqual(result.proxy?.host, '127.0.0.1'); + assert.strictEqual(result.proxy?.port, 7897); + // The Client applies the list verbatim, so the automatic domains have to be + // merged here and duplicates dropped. + assert.deepStrictEqual(result.proxy?.bypassList, ['localhost', 'metaso.cn']); + }); + + test('carries the credential an authenticated proxy cannot be dialled without', async () => { + const result = await resolve( + coordinatorFor( + { + enabled: true, + protocol: 'http', + host: 'proxy.test', + port: 8080, + authEnabled: true, + username: 'operator', + bypassList: [], + autoBypassDomains: [], + }, + 'secret-value', + ), + ); + assert.strictEqual(result.proxy?.username, 'operator'); + assert.strictEqual(result.proxy?.password, 'secret-value'); + }); + + test('omits the proxy when the policy disables it', async () => { + const result = await resolve(coordinatorFor({ enabled: false })); + assert.strictEqual(result.kind, 'ready'); + assert.strictEqual(result.proxy, undefined); + }); + + test('reports an unconfigured credential instead of a proxy', async () => { + const coordinator = new HostNetworkProxyCoordinator({ + async resolveNetworkProxyExecution() { + return { kind: 'credential_not_configured' }; + }, + } as never); + const result = await resolve(coordinator); + assert.strictEqual(result.kind, 'credential_not_configured'); + assert.strictEqual(result.proxy, undefined); + }); + + test('never reports the underlying failure, which can carry the credential', async () => { + const coordinator = new HostNetworkProxyCoordinator({ + async resolveNetworkProxyExecution() { + throw new Error('proxy://operator:secret-value@proxy.test:8080 is unreadable'); + }, + } as never); + const outcome = await coordinator.handlers['network-proxy.resolve']({}, CONNECTION); + assert.strictEqual(outcome.ok, false); + assert.ok(!outcome.ok && !outcome.error.message.includes('secret-value')); + }); +}); + +describe('network-proxy.resolve codec', () => { + const spec = HOST_OPERATION_SPECS['network-proxy.resolve']; + + test('round-trips a resolved proxy', () => { + const decoded = spec.decodeOutput({ + kind: 'ready', + proxy: { + enabled: true, + type: 'socks5', + host: '127.0.0.1', + port: 7897, + username: 'operator', + password: 'secret-value', + bypassList: ['localhost'], + }, + }); + assert.deepStrictEqual(decoded, { + kind: 'ready', + proxy: { + enabled: true, + type: 'socks5', + host: '127.0.0.1', + port: 7897, + username: 'operator', + password: 'secret-value', + bypassList: ['localhost'], + }, + }); + }); + + test('rejects a proxy that is not usable', () => { + assert.throws(() => + spec.decodeOutput({ + kind: 'ready', + proxy: { + enabled: false, + type: 'http', + host: '127.0.0.1', + port: 7897, + bypassList: [], + }, + }), + ); + assert.throws(() => + spec.decodeOutput({ + kind: 'ready', + proxy: { enabled: true, type: 'ftp', host: 'h', port: 1, bypassList: [] }, + }), + ); + assert.throws(() => + spec.decodeOutput({ + kind: 'ready', + proxy: { enabled: true, type: 'http', host: '', port: 1, bypassList: [] }, + }), + ); + assert.throws(() => + spec.decodeOutput({ + kind: 'ready', + proxy: { enabled: true, type: 'http', host: 'h', port: 0, bypassList: [] }, + }), + ); + }); + + test('rejects an unresolved result that still carries a configuration', () => { + assert.throws(() => + spec.decodeOutput({ + kind: 'credential_not_configured', + proxy: { enabled: true, type: 'http', host: 'h', port: 1, bypassList: [] }, + }), + ); + }); + + test('rejects an unknown kind and unexpected input', () => { + assert.throws(() => spec.decodeOutput({ kind: 'ready_ish' })); + assert.throws(() => spec.decodeInput({ networkProxy: {} })); + }); +}); diff --git a/packages/runtime-host/src/protocol/index.ts b/packages/runtime-host/src/protocol/index.ts index b1f16e7e5b..1238e3cc68 100644 --- a/packages/runtime-host/src/protocol/index.ts +++ b/packages/runtime-host/src/protocol/index.ts @@ -83,6 +83,7 @@ export * from './project-catalog-change.js'; export * from './execution-inspect.js'; export * from './external-session.js'; export * from './message.js'; +export * from './network-proxy.js'; export * from './operations.js'; export * from './runtime-resource.js'; export * from './session-continuity.js'; @@ -101,7 +102,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const; export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const; // Increment when the same protocol version no longer guarantees safe Client-Host // interoperability. Mismatches are rejected before domain commands are admitted. -export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 154 as const; +export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 155 as const; +// 155: `network-proxy.resolve` serves the effective proxy to Clients that own +// outbound traffic the Host never sees. Epoch-154 peers do not answer it, and +// a Client cannot tell that apart from a proxy that is genuinely unset. // 154: External Session import results distinguish committed Sessions from typed source limits. // 153: Sessions may select plugin executors and Plugin Platform queries expose them. // 152: Assistant completions and transcript rows preserve interrupted responses. diff --git a/packages/runtime-host/src/protocol/network-proxy.ts b/packages/runtime-host/src/protocol/network-proxy.ts index 0fb0e1818a..b2277e8caa 100644 --- a/packages/runtime-host/src/protocol/network-proxy.ts +++ b/packages/runtime-host/src/protocol/network-proxy.ts @@ -18,7 +18,7 @@ */ import type { RuntimePolicy } from '@maka/core/runtime-policy'; -import type { TestProxyResult } from '@maka/core/settings/network-settings'; +import type { ProxyType, TestProxyResult } from '@maka/core/settings/network-settings'; import { requireEncodedByteLimit, requireExactRecord, @@ -46,6 +46,36 @@ export interface NetworkProxyTestInput { export type NetworkProxyTestResult = TestProxyResult; +export type NetworkProxyResolveInput = Record; + +/** + * The effective proxy a Client must apply to the network it owns, already + * resolved against Runtime Policy. `bypassList` is the merged configured and + * automatic list, so the Client never re-derives policy. Carries the secret: + * only the Host can read it, and a Client that runs its own outbound traffic + * (bot bridges) cannot dial an authenticated proxy without it. + */ +export interface ResolvedNetworkProxy { + readonly enabled: true; + readonly type: ProxyType; + readonly host: string; + readonly port: number; + readonly username?: string; + readonly password?: string; + readonly bypassList: string[]; +} + +/** + * `proxy` is absent when the policy disables the proxy — a positive "send + * everything direct", distinct from `credential_not_configured`, which means + * the policy wants an authenticated proxy whose secret is missing and so + * cannot be honoured. + */ +export interface NetworkProxyResolveResult { + readonly kind: 'ready' | 'credential_not_configured'; + readonly proxy?: ResolvedNetworkProxy; +} + export const NETWORK_PROXY_OPERATION_SPECS = { 'network-proxy.test': defineOperation< NetworkProxyTestInput, @@ -58,6 +88,20 @@ export const NETWORK_PROXY_OPERATION_SPECS = { decodeInput: decodeNetworkProxyTestInput, decodeOutput: decodeNetworkProxyTestResult, }), + 'network-proxy.resolve': defineOperation< + NetworkProxyResolveInput, + NetworkProxyResolveResult, + (typeof ERRORS)[number] + >({ + mode: 'query', + availability: 'ready', + errors: ERRORS, + decodeInput: (value) => { + requireExactRecord(value, 'network proxy resolve input', []); + return {}; + }, + decodeOutput: decodeNetworkProxyResolveResult, + }), } as const; function decodeNetworkProxyTestInput(value: unknown): NetworkProxyTestInput { @@ -137,6 +181,53 @@ function decodeNetworkProxyTestResult(value: unknown): NetworkProxyTestResult { return decoded; } +function decodeNetworkProxyResolveResult(value: unknown): NetworkProxyResolveResult { + const result = requireShapedRecord(value, 'network proxy resolve result', ['kind'], ['proxy']); + if (result.kind !== 'ready' && result.kind !== 'credential_not_configured') { + throw invalidProtocolFrame('Invalid network proxy resolve kind'); + } + if (result.kind === 'credential_not_configured' && result.proxy !== undefined) { + throw invalidProtocolFrame('Unresolved network proxy must not carry a configuration'); + } + const decoded: NetworkProxyResolveResult = { + kind: result.kind, + ...(result.proxy === undefined ? {} : { proxy: decodeResolvedNetworkProxy(result.proxy) }), + }; + requireEncodedByteLimit(decoded, 'network proxy resolve result', RESULT_MAX_BYTES); + return decoded; +} + +function decodeResolvedNetworkProxy(value: unknown): ResolvedNetworkProxy { + const proxy = requireShapedRecord( + value, + 'resolved network proxy', + ['enabled', 'type', 'host', 'port', 'bypassList'], + ['username', 'password'], + ); + if ( + proxy.enabled !== true || + (proxy.type !== 'http' && proxy.type !== 'https' && proxy.type !== 'socks5') || + typeof proxy.host !== 'string' || + proxy.host.length === 0 || + proxy.host.length > 255 + ) { + throw invalidProtocolFrame('Invalid resolved network proxy'); + } + return { + enabled: true, + type: proxy.type, + host: proxy.host, + port: boundedInteger(proxy.port, 1, 65_535, 'resolved network proxy port'), + ...(proxy.username === undefined + ? {} + : { username: requireUtf8String(proxy.username, 'resolved network proxy username', 256) }), + ...(proxy.password === undefined + ? {} + : { password: requireUtf8String(proxy.password, 'resolved network proxy password', 1_024) }), + bypassList: stringList(proxy.bypassList, 'resolved network proxy bypass list'), + }; +} + function decodeProbeUrl(value: unknown): string { const raw = requireUtf8String(value, 'network proxy probe URL', 2_048); let parsed: URL; diff --git a/packages/runtime-host/src/protocol/operations.ts b/packages/runtime-host/src/protocol/operations.ts index deb62c8327..81390abed2 100644 --- a/packages/runtime-host/src/protocol/operations.ts +++ b/packages/runtime-host/src/protocol/operations.ts @@ -291,6 +291,7 @@ export const REMOTE_OWNER_OPERATION_GRANTS = Object.freeze([ 'interaction.query', 'memory.mutate', 'memory.query', + 'network-proxy.resolve', 'network-proxy.test', 'oauth.enrollment.query', 'oauth.login.cancel', diff --git a/packages/runtime-host/src/server/network-proxy-coordinator.ts b/packages/runtime-host/src/server/network-proxy-coordinator.ts index 19008b95f6..1021c09c86 100644 --- a/packages/runtime-host/src/server/network-proxy-coordinator.ts +++ b/packages/runtime-host/src/server/network-proxy-coordinator.ts @@ -23,10 +23,12 @@ import { testProxyConnection } from '@maka/runtime/network/proxy-test'; import type { RuntimePolicyOperationCoordinator } from '@maka/storage/runtime-policy-stores'; import type { NetworkProxyTestInput, OperationOutcome } from '../protocol/index.js'; import type { NetworkProxyOperationHandlerMap } from './operation-dispatcher.js'; +import { toRuntimePolicyProxy } from './runtime-policy-proxy.js'; export class HostNetworkProxyCoordinator { readonly handlers: NetworkProxyOperationHandlerMap = { 'network-proxy.test': (input) => this.#test(input), + 'network-proxy.resolve': () => this.#resolve(), }; constructor( @@ -73,6 +75,44 @@ export class HostNetworkProxyCoordinator { }; } } + + /** + * Serves the effective proxy to a Client that owns outbound traffic the Host + * never sees. Model execution resolves this Host-side and injects a + * transport; the bot bridges run in the Client process, so without this the + * configured proxy cannot reach them at all. + */ + async #resolve(): Promise> { + try { + const resolved = await this.policy.resolveNetworkProxyExecution(); + if (resolved.kind === 'credential_not_configured') { + return { ok: true, result: { kind: 'credential_not_configured' } }; + } + const proxy = toRuntimePolicyProxy( + resolved.networkProxy, + resolved.secretMaterial.networkProxy?.secret, + ); + return { + ok: true, + result: { + kind: 'ready', + ...(proxy === null + ? {} + : { proxy: { ...proxy, enabled: true, bypassList: [...proxy.bypassList] } }), + }, + }; + } catch { + // The message is deliberately fixed: the underlying failure can carry + // proxy credential material. + return { + ok: false, + error: { + code: 'internal_failure', + message: 'Network proxy resolution failed', + }, + }; + } + } } function toProxySettings( diff --git a/packages/runtime/package.json b/packages/runtime/package.json index b45bb28223..04221f0129 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -29,6 +29,7 @@ "./test-only/invocation-fixture": "./dist/__tests__/invocation-fixture.js", "./filesystem-worker": "./dist/filesystem-worker/index.js", "./sandbox": "./dist/sandbox/index.js", + "./network/active-proxy-state": "./dist/network/active-proxy-state.js", "./network/proxy-test": "./dist/network/proxy-test.js", "./telemetry": "./dist/telemetry/index.js", "./bots": "./dist/bots/index.js", From 3fbc11424897c74a3048f6fb4f57d5d1f784c282 Mon Sep 17 00:00:00 2001 From: Chen Date: Tue, 15 Sep 2026 10:49:39 +0800 Subject: [PATCH 5/5] fix: harden client bot proxy routing --- .../__tests__/client-network-proxy.test.ts | 45 ++++++++++++++++--- apps/desktop/src/main/client-network-proxy.ts | 45 ++++++++++++++++--- apps/desktop/src/main/runtime-host-boot.ts | 33 +++++++++++++- .../main/runtime-host-desktop-candidate.ts | 9 +++- docs/bot-platforms.md | 4 +- docs/bot-platforms.zh-CN.md | 5 ++- .../src/bots/__tests__/proxied-fetch.test.ts | 14 +++++- packages/runtime/src/bots/bot-test.ts | 4 +- packages/runtime/src/bots/proxied-fetch.ts | 5 ++- packages/runtime/src/bots/slack-bridge.ts | 4 +- .../runtime/src/network/active-proxy-state.ts | 16 +++++++ 11 files changed, 162 insertions(+), 22 deletions(-) diff --git a/apps/desktop/src/main/__tests__/client-network-proxy.test.ts b/apps/desktop/src/main/__tests__/client-network-proxy.test.ts index 7c7b11b010..b946836fef 100644 --- a/apps/desktop/src/main/__tests__/client-network-proxy.test.ts +++ b/apps/desktop/src/main/__tests__/client-network-proxy.test.ts @@ -37,18 +37,24 @@ const RESOLVED: NetworkProxyResolveResult = { function harness( profileKind: "local" | "environment" | "remote", resolve: () => Promise, + options: { active?: () => boolean } = {}, ) { const applied: (ProxySettings | null)[] = []; + let blocked = 0; const errors: unknown[] = []; const scheduled: { run: () => void; delayMs: number }[] = []; const applier = createClientNetworkProxyApplier({ profileKind, resolve, + isAuthoritativeTarget: options.active, apply: (proxy) => applied.push(proxy), + applyBlocked: () => { blocked += 1; }, onError: (error) => errors.push(error), - schedule: (run, delayMs) => scheduled.push({ run, delayMs }), + schedule: (run, delayMs) => { + scheduled.push({ run, delayMs }); + }, }); - return { applier, applied, errors, scheduled }; + return { applier, applied, blocked: () => blocked, errors, scheduled }; } const flush = () => new Promise((resolve) => setImmediate(resolve)); @@ -66,12 +72,13 @@ describe("createClientNetworkProxyApplier", () => { assert.deepStrictEqual(applied, [null]); }); - test("applies direct when the proxy credential is missing", async () => { - const { applier, applied } = harness("local", async () => ({ + test("blocks client-owned requests when the proxy credential is missing", async () => { + const { applier, applied, blocked } = harness("local", async () => ({ kind: "credential_not_configured", })); await applier.refresh(); - assert.deepStrictEqual(applied, [null]); + assert.deepStrictEqual(applied, []); + assert.equal(blocked(), 1); }); test("never adopts a non-local Host's proxy policy", async () => { @@ -174,4 +181,32 @@ describe("createClientNetworkProxyApplier", () => { assert.deepStrictEqual(order, ["start:0", "end:0", "start:1", "end:1"]); assert.deepStrictEqual(applied, [RESOLVED.proxy, null]); }); + + test("fences a late resolution after the target stops being authoritative", async () => { + let active = true; + let resolveRequest!: (result: NetworkProxyResolveResult) => void; + const { applier, applied } = harness( + "local", + () => new Promise((resolve) => { resolveRequest = resolve; }), + { active: () => active }, + ); + const refresh = applier.refresh(); + await flush(); + active = false; + resolveRequest(RESOLVED); + await refresh; + assert.deepStrictEqual(applied, []); + }); + + test("disposes pending retries and ignores a late retry callback", async () => { + const { applier, scheduled, applied } = harness("local", async () => { + throw new Error("host_not_ready"); + }); + await applier.refresh(); + assert.equal(scheduled.length, 1); + applier.dispose(); + scheduled[0]?.run(); + await flush(); + assert.deepStrictEqual(applied, []); + }); }); diff --git a/apps/desktop/src/main/client-network-proxy.ts b/apps/desktop/src/main/client-network-proxy.ts index 5aec5aca81..af1a08b49e 100644 --- a/apps/desktop/src/main/client-network-proxy.ts +++ b/apps/desktop/src/main/client-network-proxy.ts @@ -31,7 +31,10 @@ import type { ProxySettings } from "@maka/core/settings/network-settings"; import type { RuntimeHostProfileKind } from "@maka/runtime-host/profile-kind"; import type { NetworkProxyResolveResult } from "@maka/runtime-host/protocol"; -import { setActiveProxy } from "@maka/runtime/network/active-proxy-state"; +import { + setActiveProxy, + setActiveProxyBlocked, +} from "@maka/runtime/network/active-proxy-state"; /** * The first resolution runs while the Host connection is still settling, so a @@ -43,30 +46,42 @@ const RETRY_DELAYS_MS = [1_000, 5_000, 15_000] as const; export interface ClientNetworkProxyDeps { readonly profileKind: RuntimeHostProfileKind; readonly resolve: () => Promise; + /** The default Host owns the process-wide BotRegistry transport policy. */ + readonly isAuthoritativeTarget?: () => boolean; readonly apply?: (proxy: ProxySettings | null) => void; + readonly applyBlocked?: () => void; readonly onError?: (error: unknown) => void; - readonly schedule?: (run: () => void, delayMs: number) => void; + readonly schedule?: (run: () => void, delayMs: number) => void | (() => void); } export interface ClientNetworkProxyApplier { /** Re-resolves and applies. Safe to call concurrently; calls are serialized. */ refresh(): Promise; + /** Cancels retries and fences any in-flight result from this target. */ + dispose(): void; } export function createClientNetworkProxyApplier( deps: ClientNetworkProxyDeps, ): ClientNetworkProxyApplier { const apply = deps.apply ?? setActiveProxy; + const applyBlocked = deps.applyBlocked ?? setActiveProxyBlocked; + const isAuthoritativeTarget = deps.isAuthoritativeTarget ?? (() => true); const schedule = deps.schedule ?? ((run, delayMs) => { - setTimeout(run, delayMs).unref?.(); + const timer = setTimeout(run, delayMs); + timer.unref?.(); + return () => clearTimeout(timer); }); let lane: Promise = Promise.resolve(); let lastReportedError: string | undefined; let attempt = 0; + let disposed = false; + let cancelRetry: (() => void) | undefined; const refreshWithoutLane = async (): Promise => { + if (disposed || !isAuthoritativeTarget()) return; // A non-local Host describes a different machine's network. The bot // bridges dial out from this one, so its proxy policy does not apply and // guessing would be worse than staying direct. @@ -78,6 +93,7 @@ export function createClientNetworkProxyApplier( try { resolved = await deps.resolve(); } catch (error) { + if (disposed || !isAuthoritativeTarget()) return; // Keep the last applied proxy. A Host that is briefly unreachable is not // evidence that the user wants direct connections. const message = error instanceof Error ? error.message : String(error); @@ -88,16 +104,27 @@ export function createClientNetworkProxyApplier( const delayMs = RETRY_DELAYS_MS[attempt]; if (delayMs !== undefined) { attempt += 1; - schedule(() => void enqueue(), delayMs); + cancelRetry?.(); + const cancel = schedule(() => { + cancelRetry = undefined; + void enqueue(); + }, delayMs); + cancelRetry = typeof cancel === "function" ? cancel : undefined; } return; } + if (disposed || !isAuthoritativeTarget()) return; lastReportedError = undefined; attempt = 0; - apply(resolved.kind === "ready" ? (resolved.proxy ?? null) : null); + if (resolved.kind === "credential_not_configured") { + applyBlocked(); + } else { + apply(resolved.proxy ?? null); + } }; const enqueue = (): Promise => { + if (disposed) return Promise.resolve(); const result = lane.then(refreshWithoutLane, refreshWithoutLane); lane = result.then( () => undefined, @@ -108,10 +135,18 @@ export function createClientNetworkProxyApplier( return { refresh() { + if (disposed) return Promise.resolve(); // An explicit refresh means the policy changed, so the pending retry // budget from an earlier failure no longer applies. + cancelRetry?.(); + cancelRetry = undefined; attempt = 0; return enqueue(); }, + dispose() { + disposed = true; + cancelRetry?.(); + cancelRetry = undefined; + }, }; } diff --git a/apps/desktop/src/main/runtime-host-boot.ts b/apps/desktop/src/main/runtime-host-boot.ts index 5b4f5a722e..fd10de20dc 100644 --- a/apps/desktop/src/main/runtime-host-boot.ts +++ b/apps/desktop/src/main/runtime-host-boot.ts @@ -245,7 +245,11 @@ import { registerRuntimeHostPermissionsIpc } from "./runtime-host-permissions-ip import { registerRuntimeHostRendererIpc } from "./runtime-host-renderer-ipc-main.js"; import { registerRuntimeHostSearchIpc } from "./runtime-host-search-ipc-main.js"; import { createRuntimeHostProjectCatalog } from "./runtime-host-project-catalog.js"; -import { createClientNetworkProxyApplier } from "./client-network-proxy.js"; +import { setActiveProxyBlocked } from "@maka/runtime/network/active-proxy-state"; +import { + createClientNetworkProxyApplier, + type ClientNetworkProxyApplier, +} from "./client-network-proxy.js"; import { createRuntimeHostDefaultRecovery } from "./runtime-host-default-recovery.js"; import { toDesktopHostSessionSummary } from "./runtime-host-session-catalog-ipc-main.js"; import { @@ -346,6 +350,10 @@ const runtimeHostStartup = await resolveDesktopRuntimeHostStartup(userDataDir, { credentialStore: runtimeHostCredentialStore, }); let runtimeHostManager: RuntimeHostDesktopManager | undefined; +// BotRegistry is process-wide, so exactly one current Host target may own its +// proxy state. Reconnecting candidates for the same profile replace the map +// entry; the old applier is then fenced even before its cleanup runs. +const clientNetworkProxyAppliers = new Map(); function activeRuntimeHostRef(): DesktopTargetScope | undefined { const current = runtimeHostManager?.current(); return current?.hostId @@ -1354,6 +1362,12 @@ const startLocalRuntimeHostManager = () => startRuntimeHostDesktopManager( } }, onDefaultProfileChanged: (profileId) => { + void clientNetworkProxyAppliers + .get(profileId) + ?.refresh() + .catch((error) => + console.error("[runtime-host] default Bot proxy refresh failed:", error), + ); const state = runtimeHostManager?.entries().find( (candidate) => candidate.target.profile.id === profileId, ); @@ -1539,6 +1553,7 @@ function registerHostClientIpc( target: DesktopRuntimeHostTargetPolicy, scope: DesktopTargetScope, isTargetActive: () => boolean, + profileId: string, ): () => Promise { const usesHostWorkspace = runtimeHostProfileUsesHostWorkspace(target.kind); const sendToRenderer = (channel: string, ...args: unknown[]): void => { @@ -1694,12 +1709,19 @@ function registerHostClientIpc( }); // Client-owned outbound traffic (the bot bridges) is proxied here, not in the // Host: it runs in this process and the Host never sees it. - const clientNetworkProxy = createClientNetworkProxyApplier({ + let clientNetworkProxy!: ClientNetworkProxyApplier; + const isAuthoritativeTarget = () => + (runtimeHostManager?.defaultProfileId() ?? runtimeHostStartup.preferences.defaultProfileId) === + profileId && + clientNetworkProxyAppliers.get(profileId) === clientNetworkProxy; + clientNetworkProxy = createClientNetworkProxyApplier({ profileKind: target.kind, resolve: () => client.resolveNetworkProxy(), + isAuthoritativeTarget, onError: (error) => console.error("[runtime-host] Client network proxy resolution failed:", error), }); + clientNetworkProxyAppliers.set(profileId, clientNetworkProxy); void clientNetworkProxy.refresh(); const runtimeHostSettings = createRuntimeHostSettingsModule({ client, @@ -1899,6 +1921,13 @@ function registerHostClientIpc( } capabilityBinding.dispose(); await capabilityBinding.aligned.catch(() => undefined); + if (clientNetworkProxyAppliers.get(profileId) === clientNetworkProxy) { + clientNetworkProxyAppliers.delete(profileId); + // No replacement target owns the process-wide transport anymore. Keep + // client-owned requests fail-closed until the default target returns. + setActiveProxyBlocked(); + } + clientNetworkProxy.dispose(); }; } diff --git a/apps/desktop/src/main/runtime-host-desktop-candidate.ts b/apps/desktop/src/main/runtime-host-desktop-candidate.ts index 041300e768..32e4d9a328 100644 --- a/apps/desktop/src/main/runtime-host-desktop-candidate.ts +++ b/apps/desktop/src/main/runtime-host-desktop-candidate.ts @@ -34,6 +34,7 @@ import { type RuntimeHostSshOperatorActivationInput, connectOrSpawnRuntimeHost, connectRuntimeHostProfile, + LOCAL_RUNTIME_HOST_PROFILE, type RuntimeHostPeerClient, type RuntimeHostConnectionPhase, type RuntimeHostSshInteraction, @@ -120,6 +121,8 @@ import { type CandidateIpcMain = ReconnectableReadIpcMain & Pick; export interface DesktopRuntimeHostCandidateDeps { + /** Stable profile key used to coordinate process-wide client transports. */ + readonly profileId?: string; readonly terminalCloses?: import('./terminal-close-intents.js').TerminalCloseIntents; readonly cacheTranscript?: (scope: DesktopTargetScope, snapshot: DesktopTranscriptReplicaSnapshot) => void; readonly ipcMain: RuntimeHostTargetIpcMain; @@ -196,6 +199,7 @@ export interface DesktopRuntimeHostCandidateDeps { target: DesktopRuntimeHostTargetPolicy, scope: DesktopTargetScope, isTargetActive: () => boolean, + profileId: string, ) => void | (() => void | Promise); } @@ -390,7 +394,7 @@ export async function startDesktopRuntimeHostCandidate( kind: "ready", candidate: await createDesktopRuntimeHostCandidate( connection.connection, - { ...input, ipcMain }, + { ...input, ipcMain, profileId: LOCAL_RUNTIME_HOST_PROFILE.id }, observationRegistry, connection.registration.lifecycleMode === 'ephemeral' ? 'owned_ephemeral' @@ -508,7 +512,7 @@ async function startProfileDesktopRuntimeHostCandidate( kind: "ready", candidate: await createDesktopRuntimeHostCandidate( connection, - { ...input, ipcMain }, + { ...input, ipcMain, profileId: profileTarget.profile.id }, observationRegistry, 'external', profileTarget.profile.kind, @@ -856,6 +860,7 @@ export async function createDesktopRuntimeHostCandidate( target, scope, isTargetActive, + deps.profileId ?? LOCAL_RUNTIME_HOST_PROFILE.id, ) : undefined; disposeClientIpc = target.access === 'session_guest' diff --git a/docs/bot-platforms.md b/docs/bot-platforms.md index 97382ecc9c..6da7cda1f4 100644 --- a/docs/bot-platforms.md +++ b/docs/bot-platforms.md @@ -154,7 +154,9 @@ a system-level route such as TUN, followed by an app restart. Slack uses a WebSocket connection for events and a Web API client for replies. Keep the app in a private development workspace while validating scopes and -allowlist behavior. +message-event permissions. Slack's HTTP API requests use Maka's active global +network proxy, while the Socket Mode WebSocket may still require a system-level +route such as TUN. ### DingTalk diff --git a/docs/bot-platforms.zh-CN.md b/docs/bot-platforms.zh-CN.md index 81fc3116a5..6931c9a6b4 100644 --- a/docs/bot-platforms.zh-CN.md +++ b/docs/bot-platforms.zh-CN.md @@ -135,8 +135,9 @@ Maka 使用 Discord Gateway 接收事件,并使用 REST 调用发送回复; 3. 将 Bot token 填入 Maka 的 `token`,将 app-level token 填入 `appSecret`,并为 workspace 测试订阅所需的消息事件。 -Slack 使用 WebSocket 接收事件,使用 Web API client 回复。验证权限和 allowlist -行为时,应使用私有开发 workspace。 +Slack 使用 WebSocket 接收事件,使用 Web API client 回复。Slack 的 HTTP API 请求会使用 +Maka 的全局活动网络代理,但 Socket Mode WebSocket 仍可能需要 TUN 这样的系统级路由。 +验证权限和消息事件订阅时,应使用私有开发 workspace。 ### 钉钉 diff --git a/packages/runtime/src/bots/__tests__/proxied-fetch.test.ts b/packages/runtime/src/bots/__tests__/proxied-fetch.test.ts index 1855a6fd20..489969fe5f 100644 --- a/packages/runtime/src/bots/__tests__/proxied-fetch.test.ts +++ b/packages/runtime/src/bots/__tests__/proxied-fetch.test.ts @@ -24,7 +24,7 @@ import { getEventListeners } from 'node:events'; import { createServer } from 'node:http'; import net from 'node:net'; import { PROXY_DEFAULTS } from '@maka/core/settings/network-settings'; -import { setActiveProxy } from '../../network/active-proxy-state.js'; +import { setActiveProxy, setActiveProxyBlocked } from '../../network/active-proxy-state.js'; import { proxiedFetch } from '../proxied-fetch.js'; // A minimal HTTP proxy that is also the fake upstream. It accepts either // proxy request form (CONNECT tunneling, or the absolute-form forwarding @@ -96,6 +96,18 @@ function startStreamingProxy(): Promise<{ } describe('proxiedFetch', () => { + test('fails closed when the configured proxy credentials are unavailable', async () => { + setActiveProxyBlocked(); + try { + await assert.rejects( + () => proxiedFetch('http://127.0.0.1:1'), + /configured network proxy is unavailable/iu, + ); + } finally { + setActiveProxy(null); + } + }); + test('returns a streaming proxied response at headers, before body EOF', async () => { const proxy = await startStreamingProxy(); setActiveProxy({ diff --git a/packages/runtime/src/bots/bot-test.ts b/packages/runtime/src/bots/bot-test.ts index 82a6ebd144..1049e476f5 100644 --- a/packages/runtime/src/bots/bot-test.ts +++ b/packages/runtime/src/bots/bot-test.ts @@ -97,9 +97,9 @@ async function testSlack(channel: BotChannelSettings): Promise { const { WebClient } = createRequire(import.meta.url)( '@slack/web-api', ) as typeof import('@slack/web-api'); - const identity = await new WebClient(botToken).auth.test(); + const identity = await new WebClient(botToken, { fetch: proxiedFetch }).auth.test(); if (!identity.ok) return { ok: false, error: identity.error ?? 'Slack auth.test failed' }; - const socket = await new WebClient(appToken).apps.connections.open(); + const socket = await new WebClient(appToken, { fetch: proxiedFetch }).apps.connections.open(); if (!socket.ok || !socket.url) { return { ok: false, error: socket.error ?? 'Slack Socket Mode connection failed' }; } diff --git a/packages/runtime/src/bots/proxied-fetch.ts b/packages/runtime/src/bots/proxied-fetch.ts index 0377e9eb9d..c78e731d87 100644 --- a/packages/runtime/src/bots/proxied-fetch.ts +++ b/packages/runtime/src/bots/proxied-fetch.ts @@ -20,7 +20,7 @@ import { fetch, type Dispatcher, type RequestInit as UndiciRequestInit } from 'undici'; import { matchesBypassList } from '../network/bypass-matcher.js'; import { buildProxyDispatcher } from '../network/proxy-dispatcher.js'; -import { resolveActiveProxy } from '../network/active-proxy-state.js'; +import { isActiveProxyBlocked, resolveActiveProxy } from '../network/active-proxy-state.js'; import { FETCH_PROXY_SNAPSHOT } from '../network/scoped-fetch-transport.js'; const DEFAULT_TIMEOUT_MS = 15_000; @@ -37,6 +37,9 @@ export async function proxiedFetch( input: Parameters[0], init: ProxiedFetchInit = {}, ): Promise { + if (isActiveProxyBlocked()) { + throw new Error('Configured network proxy is unavailable'); + } const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url; const proxy = resolveActiveProxy(); diff --git a/packages/runtime/src/bots/slack-bridge.ts b/packages/runtime/src/bots/slack-bridge.ts index 977520891b..2ae4f0bf69 100644 --- a/packages/runtime/src/bots/slack-bridge.ts +++ b/packages/runtime/src/bots/slack-bridge.ts @@ -22,6 +22,7 @@ import type { BotChannelSettings } from '@maka/core/bot-chat-settings'; import type { SocketModeClient } from '@slack/socket-mode'; import type { WebClient } from '@slack/web-api'; import { BaseBotAdapter, botReadinessFromSettings } from './base-adapter.js'; +import { proxiedFetch } from './proxied-fetch.js'; import type { BotSendOptions, SendCapable } from './types.js'; interface SlackMessageEvent { @@ -92,7 +93,7 @@ export class SlackBotBridge extends BaseBotAdapter implements SendCapable { const { WebClient } = require('@slack/web-api') as typeof import('@slack/web-api'); const { SocketModeClient } = require('@slack/socket-mode') as typeof import('@slack/socket-mode'); - this.web = new WebClient(botToken); + this.web = new WebClient(botToken, { fetch: proxiedFetch }); const identity = await this.web.auth.test(); if (!identity.ok) throw new Error(identity.error ?? 'Slack auth.test failed'); this.identity = { @@ -102,6 +103,7 @@ export class SlackBotBridge extends BaseBotAdapter implements SendCapable { const socket = new SocketModeClient({ appToken, autoReconnectEnabled: true, + clientOptions: { fetch: proxiedFetch }, }); socket.on('slack_event', (envelope: SlackEventEnvelope) => { void envelope.ack().catch(() => {}); diff --git a/packages/runtime/src/network/active-proxy-state.ts b/packages/runtime/src/network/active-proxy-state.ts index 40fd24bb9b..a017122549 100644 --- a/packages/runtime/src/network/active-proxy-state.ts +++ b/packages/runtime/src/network/active-proxy-state.ts @@ -20,11 +20,27 @@ import type { ProxySettings } from '@maka/core/settings/network-settings'; let activeProxy: ProxySettings | null = null; +let activeProxyBlocked = false; export function setActiveProxy(proxy: ProxySettings | null): void { activeProxy = proxy?.enabled ? proxy : null; + activeProxyBlocked = false; +} + +/** + * Keeps client-owned requests fail-closed when policy requires a proxy but + * the Host cannot provide its credentials. This must not be represented by + * `null`, because `null` deliberately means that direct routing is allowed. + */ +export function setActiveProxyBlocked(): void { + activeProxy = null; + activeProxyBlocked = true; } export function resolveActiveProxy(): ProxySettings | null { return activeProxy; } + +export function isActiveProxyBlocked(): boolean { + return activeProxyBlocked; +}