diff --git a/AGENTS-CN.md b/AGENTS-CN.md index c72b2df44c..2e977ddd8d 100644 --- a/AGENTS-CN.md +++ b/AGENTS-CN.md @@ -25,7 +25,7 @@ Stable Contracts and Security Control Plane 的边界以 |---|---|---|---|---|---| | 1 | 接口与入口层 | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | 产品宿主、命令、UI 入口、协议接口和跨形态测试 | desktop、CLI、server、relay、Web UI、mobile web、installer、E2E、`acp` | 最近的本地 `AGENTS.md`;[interfaces](src/crates/interfaces/AGENTS.md) | | 2 | 产品组装层 | `src/crates/assembly` | 兼容导出、产品能力选择、product-full 接线、adapter/service 注册和生态无关的来源协调 | `core`, `external-sources`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | -| 3 | 适配层 | `src/crates/adapters` | AI/transport/WebDriver/OpenCode 协议 adapter 和外部 provider 转换 | `ai-adapters`, `opencode-adapter`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | +| 3 | 适配层 | `src/crates/adapters` | AI/transport/WebDriver/OpenCode 协议 adapter 和外部 provider 转换 | `agent-runtime-ipc`、`ai-adapters`, `opencode-adapter`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | | 4 | 服务实现层 | `src/crates/services` | 可复用 OS、filesystem、terminal、MCP、remote、git、watch、process、LSP plugin registry、session persistence primitives、network 和 MiniApp runtime IO 实现 | `services-core`, `services-integrations`, `relay-service`, `page-function-runtime`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | 执行原语层 | `src/crates/execution` | 可移植 agent、harness、stream、DeepReview policy/report、插件运行时客户端、typed-service、tool-contract、tool-group 和 tool-execution 构件 | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `plugin-runtime-client`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | 稳定契约与产品领域层 | `src/crates/contracts` | 跨层共享 DTO、事件形状、runtime port、LSP protocol/plugin DTO、产品领域契约和策略 | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | @@ -192,8 +192,8 @@ await api.invoke('your_command', { request: { ... } }); - 产品表面可以有差异;共享稳定 facts 或 ports,不共享 UI、protocol、lifecycle 或平台实现。 - 迁移 runtime owner 必须有评审过的 port/provider 设计、旧路径兼容、行为等价测试;如果可能改变行为边界,还需要先确认。 -涉及 Local Agent Host、多 GUI/TUI/Remote 实例、共享 Session 控制或进程拓扑时,还必须阅读 -[`docs/architecture/local-agent-host-multi-instance-design.md`](docs/architecture/local-agent-host-multi-instance-design.md)。 +涉及 Agent Runtime 部署、多 GUI/TUI/Remote 实例、共享 Session 控制或进程拓扑时,还必须阅读 +[`docs/architecture/agent-runtime-deployment-design.md`](docs/architecture/agent-runtime-deployment-design.md)。 Rust Runtime 或 Node/Bun Plugin Host 不得默认按 Client、workspace、session 或 plugin 分进程;进程边界必须来自真实状态 owner、execution/security domain、可兼容的安全条件和测量后的容量事实。 diff --git a/AGENTS.md b/AGENTS.md index b732db70fa..a782c2f0f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -26,7 +26,7 @@ Keep crate dependencies inside each layer to the smallest set needed. |---|---|---|---|---|---| | 1 | Interfaces and entrypoints | `src/apps/*`, `src/web-ui`, `src/mobile-web`, `BitFun-Installer`, `tests/e2e`, `src/crates/interfaces` | Product hosts, commands, UI entrypoints, protocol interfaces, and cross-surface tests | desktop, CLI, server, relay, Web UI, mobile web, installer, E2E, `acp` | nearest local `AGENTS.md`; [interfaces](src/crates/interfaces/AGENTS.md) | | 2 | Product assembly | `src/crates/assembly` | Compatibility exports, product capability selection, product-full wiring, adapter/service registration, and ecosystem-neutral source coordination | `core`, `external-sources`, `product-capabilities` | [AGENTS.md](src/crates/assembly/AGENTS.md) | -| 3 | Adapters | `src/crates/adapters` | AI/transport/WebDriver/OpenCode protocol adapters and external-provider translation | `ai-adapters`, `opencode-adapter`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | +| 3 | Adapters | `src/crates/adapters` | AI/transport/WebDriver/OpenCode protocol adapters and external-provider translation | `agent-runtime-ipc`, `ai-adapters`, `opencode-adapter`, `transport`, `webdriver` | [AGENTS.md](src/crates/adapters/AGENTS.md) | | 4 | Services | `src/crates/services` | Reusable OS, filesystem, terminal, MCP, remote, git, watch, process, LSP plugin registry, session persistence primitives, MiniApp runtime IO, and network implementations | `services-core`, `services-integrations`, `relay-service`, `page-function-runtime`, `terminal` | [AGENTS.md](src/crates/services/AGENTS.md) | | 5 | Execution primitives | `src/crates/execution` | Portable agent, harness, stream, DeepReview policy/report, plugin runtime client, typed-service, tool-contract, tool-group, and tool-execution building blocks | `agent-runtime`, `agent-stream`, `tool-contracts`, `harness`, `plugin-runtime-client`, `runtime-services`, `tool-provider-groups`, `tool-execution` | [AGENTS.md](src/crates/execution/AGENTS.md) | | 6 | Stable contracts and product domains | `src/crates/contracts` | Shared DTOs, event shapes, runtime ports, LSP protocol/plugin DTOs, and product domain contracts/policies | `core-types`, `events`, `runtime-ports`, `product-domains` | [AGENTS.md](src/crates/contracts/AGENTS.md) | @@ -207,9 +207,9 @@ Repository-level decomposition rules: compatibility, behavior equivalence tests, and explicit confirmation when a behavior boundary could change. -For Local Agent Host, multi-GUI/TUI/Remote instances, shared Session control, -or process-topology changes, also read -[`docs/architecture/local-agent-host-multi-instance-design.md`](docs/architecture/local-agent-host-multi-instance-design.md). +For Agent Runtime deployment, multi-GUI/TUI/Remote instances, shared Session +control, or process-topology changes, also read +[`docs/architecture/agent-runtime-deployment-design.md`](docs/architecture/agent-runtime-deployment-design.md). Do not key Rust Runtime or Node/Bun Plugin Host processes by client, workspace, session, or plugin by default; use the responsible state module, execution and security conditions, and measured capacity. diff --git a/Cargo.toml b/Cargo.toml index 2471e24eb9..ae6cace31b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "src/apps/relay-server", "src/crates/interfaces/acp", "src/crates/interfaces/sdk-host", + "src/crates/adapters/agent-runtime-ipc", "src/crates/assembly/core", "src/crates/assembly/external-sources", "src/crates/adapters/ai-adapters", diff --git a/docs/architecture/agent-runtime-deployment-design.md b/docs/architecture/agent-runtime-deployment-design.md new file mode 100644 index 0000000000..7cb828607b --- /dev/null +++ b/docs/architecture/agent-runtime-deployment-design.md @@ -0,0 +1,203 @@ +# Agent Runtime 部署与多实例边界 + +本文定义 Desktop、TUI、Headless CLI、Agent SDK 与本机控制端并存时,BitFun Agent Runtime 的部署、所有权和隔离边界。 + +Agent Runtime 的模块职责见 [`agent-runtime-services-design.md`](agent-runtime-services-design.md),公开 SDK 见 +[`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md),第三方 JS/TS 进程见 +[`extensions/plugin-runtime-design.md`](extensions/plugin-runtime-design.md)。 + +## 1. 决策与当前状态 + +BitFun 只有一套 Agent Runtime 行为。`Embedded` 和 `Shared` 只描述同一套 Runtime 的物理部署方式,不是两套实现。 + +```mermaid +flowchart LR + subgraph "产品入口" + GUI["GUI / TUI"] + CLI["Headless CLI"] + SDK["Agent SDK"] + end + + GUI --> Adapter["first-party adapter"] + CLI --> Adapter + SDK --> SDKAdapter["SDK Host adapter"] + Adapter --> API["Agent Runtime API"] + SDKAdapter --> API + API --> Owners["Session / Tool / Permission / MCP owners"] +``` + +当前代码状态必须和目标设计分开阅读: + +| 范围 | 当前状态 | +|---|---| +| Embedded GUI/TUI/CLI/ACP/SDK Host | 保持现状;本设计没有改变其依赖或生命周期 | +| Runtime ownership | 已有可选的 Embedded 共享锁 / Shared 独占锁原语;尚未接入产品入口 | +| Shared local IPC | 已有未发布、仅 crate 内可见的 discovery、实例锁、严格握手、Health 和 cleanup 基础;尚无生产 consumer | +| Shared Session/Turn/Tool/Permission | 尚未设计为稳定 wire,也没有产品 consumer | +| Shared GUI/TUI/Remote | 尚未交付,没有 `--shared` 或隐藏 Host 命令 | + +因此当前新增的是基础设施,不是用户可用的 Shared Runtime 产品。 + +## 2. 最少名词 + +| 名词 | 唯一含义 | 不等于 | +|---|---|---| +| Agent Runtime | 负责 Session、Turn、Tool、MCP、Permission、Hook、事件和持久化行为的既有模块 | 进程名、Server 或 SDK | +| Embedded deployment | Runtime 与调用入口位于同一 Rust 进程 | 简化版 Runtime | +| Shared deployment | 同一 Runtime 未来由一个本机进程承载,多个第一方 Client 通过私有 IPC 使用 | 新 Runtime、公开 Server 或 Agent SDK | +| Agent SDK Host | 将公开 SDK 合同映射到 Runtime API 的私有进程/adapter | CLI、Shared deployment 或 Plugin Host | +| Plugin Host | 运行 Node/Bun 和第三方插件代码的受监督子进程 | Agent Runtime 或 Rust IPC client | + +`Host` 只表示“一个进程承载某些模块”的内部关系,不新增普通用户必须理解或管理的产品入口。 + +## 3. 逻辑复用与物理部署 + +```mermaid +flowchart TB + subgraph "逻辑层:始终只有一套" + API["Agent Runtime API"] --> Session["Session / Turn"] + API --> Permission["Permission"] + API --> Tool["Tool / MCP"] + API --> Events["Authoritative events"] + end + + Embedded["Embedded adapter"] --> API + Shared["Shared local IPC adapter · future"] -.-> API + SDK["SDK Host adapter"] --> API + Remote["Remote adapter"] --> API +``` + +复用的是 Runtime API、权威事实和 owner;不复用 renderer、CLI 参数、SDK wire、远程认证或平台窗口生命周期。任何新能力必须先进入既有 Runtime owner,再由需要它的 adapter 映射,禁止在 Shared 路径复制业务实现。 + +## 4. 当前基础架构 + +### 4.1 Runtime ownership + +`services-core::runtime_ownership` 提供进程级 RAII 文件锁: + +```mermaid +flowchart LR + E1["Embedded A"] -->|"shared lock"| Key["workspace + product ownership key"] + E2["Embedded B"] -->|"shared lock"| Key + S["Shared deployment"] -->|"exclusive lock"| Key +``` + +- 多个 Embedded 进程可继续并存。 +- Shared 与任何 Embedded owner 互斥,避免同一工作区出现两个 Runtime owner。 +- 当前没有入口调用该原语,所以现有产品行为不变。 +- 该锁不选择 workspace、不启动 Runtime、不缓存实例,也不替代 Session 写入权或文件冲突控制。 + +### 4.2 私有本机 IPC + +```mermaid +sequenceDiagram + participant C as Foundation client + participant D as User-private discovery + participant S as Foundation server + + C->>D: read endpoint + token + identity + protocol + C->>S: connect via Named Pipe / UDS + C->>S: initialize(identity, protocol, token) + alt valid + S-->>C: initialized(capabilities = health) + C->>S: health + S-->>C: instance identity + PID + else invalid + S-->>C: typed error and close + end +``` + +当前协议刻意只有 Health。它验证以下地基,而不提前冻结业务 wire: + +- workspace、产品、release channel、用户和协议版本共同生成实例身份; +- instance lock 而不是 PID/discovery 文件决定唯一 server owner; +- Windows 使用拒绝远程连接的 Named Pipe;Unix 使用短且由 instance identity 决定的稳定 Domain Socket 名称,权限为 `0600`; +- discovery 所在目录必须由未来 composition 选择为当前用户私有目录; +- discovery 通过同目录临时文件原子替换;Unix endpoint 保留原生路径字节,路径过长时在 bind 前返回明确错误; +- 第一帧必须完成 token、instance identity 和 protocol version 校验; +- JSON frame 使用 4-byte 长度前缀,并在分配前执行 64 KiB 硬上限; +- 未认证连接也计入有界 connection budget,单个客户端不能无限制造 server task; +- 未知字段、未知 operation、错误身份和不兼容版本 fail closed; +- 无连接后按调用方配置的 idle timeout 退出,并只删除自己发布的 discovery;Unix 下继任 owner 会在持有实例锁后清理同一 identity 的陈旧 socket。 + +这是一条本机同用户边界,不是沙箱、远程协议或公开兼容承诺。 + +## 5. 产品入口保持同级 + +```mermaid +flowchart LR + GUI["GUI"] --> GA["GUI adapter"] + TUI["TUI"] --> TA["TUI adapter"] + CLI["Headless CLI"] --> CA["CLI adapter"] + SDK["Agent SDK"] --> SA["SDK Host adapter"] + ACP["ACP"] --> AA["ACP adapter"] + + GA --> API["Agent Runtime API"] + TA --> API + CA --> API + SA --> API + AA --> API +``` + +- CLI 不依赖 SDK Host,GUI/TUI 也不依赖公开 SDK package。 +- TUI 不是 Server;未来是否连接 Shared deployment 是部署选择,不改变 TUI 的 renderer/键位职责。 +- Agent SDK Host 只服务外部 SDK 合同,不成为第一方 rich-client 的通用底座。 +- Headless CLI 默认继续 Embedded;CI 或测试可保持独立进程和独立 workspace,不承担后台实例成本。 +- Tauri 仍负责窗口和桌面能力;未来它可以管理 Shared process 的启动/重连,但不拥有 Agent Runtime 业务生命周期。 + +## 6. 隔离和生命周期原则 + +实例身份与 ownership key 分工不同: + +| 事实 | 用途 | +|---|---| +| canonical workspace + product | 防止 Embedded 与 Shared 同时拥有同一工作区 Runtime | +| workspace + product + release channel + user + protocol | 定位兼容的本机 Shared instance | +| stable local endpoint + bearer token + owner id | endpoint 定位同一 instance;随机 token 认证本轮 server;owner id 防止旧实例误删新 discovery | +| Session identity | 未来 Runtime 内的持久化和写入隔离;不由 IPC foundation 定义 | + +一个 Client 关闭不应推导 Session 或 Runtime 必须退出;真正的 Shared lifecycle 需要综合 Client、活动 Query、后台任务和 Remote 引用。当前 Health-only server 没有这些业务引用,因此只实现“无连接后 idle 退出”。后续接入 Runtime 时必须替换为 Runtime-aware drain,不能直接复用 Health server 的简单空闲条件。 + +对普通单实例用户,未显式启用 Shared deployment 时不增加后台进程、连接、发现扫描或常驻内存。 + +## 7. 能力扩展原则 + +未来每增加一类 Shared 能力,都必须同时满足: + +1. 已有明确第一方 consumer 和用户旅程; +2. 行为由现有 Runtime owner 提供,IPC 只映射 typed request/result/event; +3. 定义权限、取消、deadline、断线、背压和副作用结果不确定性; +4. Embedded 与 Shared 使用同一行为 fixture; +5. 新能力不被顺带发布为 Agent SDK、Remote 或浏览器 API。 + +Session/Turn、事件恢复、Permission/UserInput、Controller、配置管理和 Remote 应分别通过上述门槛,不能一次性加入一个“全量 Shared API”。 + +当前 IPC crate 只是一条可删除的预集成边界: + +| 约束 | 当前决定 | +|---|---| +| 首个候选 consumer | 仅限另行评审的第一方交互式 TUI attach adapter;不自动包含 GUI、Headless CLI、Remote 或 SDK Host | +| 稳定测试合同 | 本机 endpoint、initialize-first、64 KiB frame、Health、连接上限、owner-checked cleanup | +| 接入门槛 | 必须复用既有 Runtime owners,并用同一 fixture 证明 Embedded/Shared 行为等价 | +| 删除条件 | 若首个 consumer 选择其他 transport,或 Shared 在产品接入前取消,则直接删除该 crate,不保留“未来可能使用”的 API | + +在首个 consumer 通过评审前,crate 保持 `publish = false`,所有 Rust API 保持 crate 内可见;架构守卫禁止增加 Runtime、SDK Host、services、CLI/TUI、远程网络依赖及 Health 之外的 operation。 + +## 8. 与竞品的取舍 + +| 产品 | 已验证做法 | BitFun 采用 | 不照搬 | +|---|---|---|---| +| OpenCode | Core/Server 支持 TUI/Web/Desktop/SDK 多客户端 | 一个 Runtime owner 可服务多个第一方 Client | 不把全量 HTTP/OpenAPI route 提前固化为 Shared 或公开 SDK | +| Codex | App Server 面向 rich client;SDK 面向自动化 | rich-client 私有协议与公开 SDK 分层 | 不让 CLI 默认依赖 Server,也不把 App Server schema 原样复制 | +| Claude Code | 默认单进程;Remote/SDK 路径显式启用 | 默认单实例无额外常驻成本,Shared 必须显式且有真实收益 | 不提前引入云中继、移动端或全机器 daemon 心智 | + +当前先落 identity、ownership、local transport 和 handshake,与这些产品共同采用的“先稳定宿主边界,再开放能力”一致;没有为了追赶功能表一次性增加 Session/Tool/Permission 超集。 + +## 9. 不变量 + +- 只有一套 Agent Runtime 业务实现;部署差异不能产生第二套 Session、Tool、Permission 或 MCP owner。 +- Client、窗口、Session 或 workspace 数量不会自动等量增加 Runtime 或 Plugin Host 进程。 +- 私有 IPC 不成为公开 SDK、Remote、Peer、HTTP 或浏览器协议。 +- 默认 GUI/TUI/Headless CLI 在 Shared 产品能力正式交付前保持现有 Embedded 行为。 +- Remote workspace 的文件、凭据、进程和 Runtime 位于目标执行域,禁止静默回落本机。 +- 未经真实 consumer 验证的接口不进入 wire;当前唯一 operation 是 Health。 diff --git a/docs/architecture/agent-runtime-services-design.md b/docs/architecture/agent-runtime-services-design.md index a77d78907d..645755e773 100644 --- a/docs/architecture/agent-runtime-services-design.md +++ b/docs/architecture/agent-runtime-services-design.md @@ -10,8 +10,8 @@ CLI Agent 体验边界见 [`cli-product-line-design.md`](cli-product-line-design [`capability-runtime-integration-design.md`](extensions/capability-runtime-integration-design.md);公开 BitFun Agent SDK 的 用户心智、SDK Host、Headless CLI/ACP/Server 关系、竞品基线和能力发布门槛见 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md);第一方 GUI/TUI/Remote 多实例、Headless CLI Embedded、 -Local Agent Host 与 Plugin Host 的进程关系见 -[`local-agent-host-multi-instance-design.md`](local-agent-host-multi-instance-design.md)。 +Shared Agent Runtime 与 Plugin Host 的进程关系见 +[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md)。 本文中的接口片段只说明依赖方向和职责,不自动构成当前 API 或实施承诺。当前接口名称、字段和消费方以代码为准; 新增公共类型前必须有真实生产调用方、版本边界和验证路径。现有 `agent-runtime::sdk` 是 @@ -64,7 +64,7 @@ Python/TypeScript SDK 通过匹配版本的本地 SDK Host 调用共享 Agent Ru ACP 和 Server 使用各自 adapter,不反向依赖公开语言 package。所有 wire DTO 可序列化,运行时句柄不进入 schema;SDK Host 不拥有 Session、Tool、MCP、Permission、Hook 或 Event 状态。 -Agent Runtime API 的逻辑归属与物理部署分离:相同归属模块可以嵌入入口进程,也可以组装在第一方 Local Agent Host、 +Agent Runtime API 的逻辑归属与物理部署分离:相同归属模块可以嵌入入口进程,也可以组装为第一方 Shared deployment、 私有 SDK Host 或目标机器 Runtime 中。任何 Rust 部署都只管理自己进程树内的服务与 Node/Bun Plugin Host;不能因为多个 GUI/TUI/Remote Client 连接就复制 Runtime 状态模块,或按 Client/Workspace 创建 Plugin Host。 diff --git a/docs/architecture/agent-sdk-product-architecture.md b/docs/architecture/agent-sdk-product-architecture.md index cbb08f13e0..8696771329 100644 --- a/docs/architecture/agent-sdk-product-architecture.md +++ b/docs/architecture/agent-sdk-product-architecture.md @@ -6,8 +6,8 @@ [`product-architecture.md`](product-architecture.md) 定义;CLI/TUI 体验由 [`cli-product-line-design.md`](cli-product-line-design.md) 定义;扩展能力导入和宿主适配由 [`capability-runtime-integration-design.md`](extensions/capability-runtime-integration-design.md) 定义;多个第一方界面与 SDK/CLI -并存时的 Local Agent Host、共享和进程隔离由 -[`local-agent-host-multi-instance-design.md`](local-agent-host-multi-instance-design.md) 定义。 +并存时的 Runtime 部署、共享和进程隔离由 +[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md) 定义。 本文只记录长期产品心智、架构边界和发布门槛。当前代码中的 `agent-runtime::sdk` 是供 BitFun 内部入口和受控 Rust 嵌入使用的低层 Rust Runtime SDK; @@ -178,7 +178,7 @@ flowchart LR UIA --> API["Runtime API"] CLIA --> API SDKA --> API - API --> Runtime["Shared Runtime"] + API --> Runtime["Agent Runtime owners"] ``` ### 4.2 互操作入口 @@ -199,10 +199,13 @@ flowchart LR - 各入口共享业务事实和 owner,不共享 renderer、命令行参数、wire protocol 或平台生命周期。 - 增加 SDK 不得让 CLI、GUI/TUI 或 Server 的底层依赖变深;它只增加一个同级入口。 -该图表达逻辑依赖,不要求所有入口位于同一进程。目标部署中,GUI/TUI/本机 Remote 可以连接第一方 Local Agent Host; -一次性 Headless CLI 继续 Embedded;公开 SDK 默认连接私有 SDK Host。Local Agent Host 和 SDK Host 都是 Rust 产品进程, +该图表达逻辑依赖,不要求所有入口位于同一进程。目标部署中,GUI/TUI/本机 Remote 可以连接第一方 Shared Agent Runtime; +一次性 Headless CLI 继续 Embedded;公开 SDK 默认连接私有 SDK Host。Shared Agent Runtime process 和 SDK Host 都是 Rust 产品进程, 与运行第三方 JS/TS 的 Node/Bun Plugin Host 不同;三者不能共享名称或业务归属。 +当前代码只具备 Shared deployment 的本机 IPC、身份、握手、Health 和 ownership 基础;没有 GUI/TUI/Remote consumer, +也没有 Shared Session/Turn 协议。图中 Shared deployment 是目标架构,不是已交付产品能力。 + ### 4.3 各形态能做什么 | 形态 | 最适合 | 共享能力 | 形态特有职责 | @@ -465,7 +468,7 @@ CLI 和 SDK 共享能力事实,但不是上下层关系: 因此: - CLI 不默认依赖 SDK Host,也不通过 SDK package 运行。 -- 一次性 `bitfun exec` 默认使用 Embedded Runtime;只有恢复或控制 Local Agent Host 中的共享 Session 时,才使用第一方 +- 一次性 `bitfun exec` 默认使用 Embedded Runtime;只有恢复或控制 Shared Agent Runtime 中的共享 Session 时,才使用第一方 client adapter attach,且不经过 SDK Host。 - SDK 不解析 CLI `stream-json` 作为正式双向协议。 - 普通脚本/CI 不需要为了“架构统一”改写成 SDK;复杂生产自动化才选择 SDK。 diff --git a/docs/architecture/cli-product-line-design.md b/docs/architecture/cli-product-line-design.md index 74cd371cea..737d0a335e 100644 --- a/docs/architecture/cli-product-line-design.md +++ b/docs/architecture/cli-product-line-design.md @@ -11,8 +11,8 @@ CLI/TUI 的目标、问题和风险规约见 [`platform-portability-design.md`]( CLI 产品入口、配置兼容、TUI 布局消费和 CLI Agent 体验,不重复定义这些文档中的通用契约或内部 ABI。公开 BitFun Agent SDK 与 Headless CLI 的产品选择、能力一致性和 SDK Host 边界见 [`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md);多个 GUI/TUI/Remote/CLI 实例并存时,交互式 TUI -连接 Local Agent Host、一次性 CLI 保留 Embedded 的部署边界见 -[`local-agent-host-multi-instance-design.md`](local-agent-host-multi-instance-design.md)。 +连接 Shared Agent Runtime、一次性 CLI 保留 Embedded 的部署边界见 +[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md)。 OpenCode 的完整扩展矩阵、配置资产、插件执行和 TUI Plugin 映射分别见 [`opencode-extension-compatibility.md`](extensions/opencode-extension-compatibility.md)、 [`opencode-config-assets-adapter-design.md`](extensions/opencode-config-assets-adapter-design.md)、 @@ -305,7 +305,7 @@ flowchart LR ``` Embedded 只意味着 Runtime 与 CLI 同进程,不意味着绕过持久化单写规则。新 Session 取得自己的写入权;恢复既有 Session 时, -CLI 必须先取得该 Session 的写入权。如果 Local Agent Host 或另一个 `exec` 已持有,CLI 连接现有 Host 或返回明确的 +CLI 必须先取得该 Session 的写入权。如果 Shared Agent Runtime 或另一个 `exec` 已持有,CLI 连接现有 Runtime 或返回明确的 “Session 已占用”,不能并发写入同一 Session。 CLI/TUI 的会话创建、列出、删除、恢复和历史转录读取通过 Rust Runtime SDK 的类型化端口完成;TUI 只把 diff --git a/docs/architecture/extensions/plugin-runtime-design.md b/docs/architecture/extensions/plugin-runtime-design.md index 64a0c4c778..bcaada2a78 100644 --- a/docs/architecture/extensions/plugin-runtime-design.md +++ b/docs/architecture/extensions/plugin-runtime-design.md @@ -6,7 +6,7 @@ [`external-ai-work-sources-design.md`](external-ai-work-sources-design.md)。详细设计与 [`../product-architecture.md`](../product-architecture.md) 冲突时,以产品运行时架构为准。多个 GUI/TUI/Remote/CLI/SDK 实例并存时,Rust Runtime 部署与 Plugin Host 复用关系见 -[`../local-agent-host-multi-instance-design.md`](../local-agent-host-multi-instance-design.md)。 +[`../agent-runtime-deployment-design.md`](../agent-runtime-deployment-design.md)。 本文同时区分目标设计和当前实现;目标不能被写成已经交付的能力。 @@ -77,7 +77,7 @@ flowchart TD workspace、session、插件和贡献数量都不是默认进程键。一个 Plugin Host 可以承载多个来源、多个 workspace 的逻辑实例和多个 session 的调用;这些身份必须随请求显式传递,进程内状态仍按生态的真实语义分区。 -因此 Local Agent Host 中多个 GUI/TUI/Remote Client 不会各自创建 Plugin Host;一次性 Embedded CLI、私有 SDK Host 和 +因此 Shared Agent Runtime 中多个 GUI/TUI/Remote Client 不会各自创建 Plugin Host;一次性 Embedded CLI、私有 SDK Host 和 目标机器 Runtime 则各自只管理自己的子进程树,不跨 Rust 进程或 execution domain 共享模块实例。 容量压力本身不直接创建进程。只有测量证明单进程队列不足,并且待拆调用同时满足“无共享模块状态、无顺序 Hook @@ -205,7 +205,7 @@ flowchart LR 内存收益高于冷启动成本时,才可以增加有期限的休眠;恢复时重新读取当前插件内容,并在新连接完成初始化后接受调用。 承载 RuntimeServices 的 Rust 进程退出时按以下顺序处理:停止新调用、取消可取消请求、有限等待正在执行的调用、逆序 dispose、 -终止完整进程树。Local Agent Host 的单个 Client 断线不是该生命周期事件;只要仍有活动插件实例、在途调用、事件订阅、 +终止完整进程树。Shared Agent Runtime 的单个 Client 断线不是该生命周期事件;只要仍有活动插件实例、在途调用、事件订阅、 后台任务或其他 Client,兼容 Plugin Host 继续复用。清理超时不能阻止监督进程退出。 ## 5. 状态归属与恢复 diff --git a/docs/architecture/local-agent-host-multi-instance-design.md b/docs/architecture/local-agent-host-multi-instance-design.md deleted file mode 100644 index 6159a16eea..0000000000 --- a/docs/architecture/local-agent-host-multi-instance-design.md +++ /dev/null @@ -1,451 +0,0 @@ -# Local Agent Host 与多实例运行架构 - -本文定义多个 Desktop GUI、交互式 TUI、本机 Remote Control、Headless CLI 与公开 Agent SDK 同时运行时的进程关系、 -状态归属、共享范围、隔离边界和容量治理。Agent Runtime 的职责与 crate 归属继续以 -[`agent-runtime-services-design.md`](agent-runtime-services-design.md) 为准;公开 SDK 与 SDK Host 以 -[`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md) 为准;第三方 JS/TS 插件进程以 -[`plugin-runtime-design.md`](extensions/plugin-runtime-design.md) 为准。 - -本文描述目标部署。当前 Desktop、TUI 和普通 CLI 仍主要在各自进程内组装 Runtime;`bitfun-sdk-host` 只是公开 SDK -路径的内部 preview。没有完成行为等价、生命周期、安全、重连和性能验证前,不得把 Local Agent Host 写成已交付能力。 - -## 1. 决策摘要 - -BitFun 采用“一个 Runtime 语义,多种物理部署”的方案 C: - -> 第一方交互入口可以共享一个 Local Agent Host;一次性 Headless CLI 保留 Embedded;公开 SDK 默认使用私有 -> SDK Host;Remote Workspace 的 Runtime 位于目标机器。所有形态复用同一组 Session、Tool、MCP、Permission、Hook、 -> Event 和持久化实现。 - -```mermaid -flowchart TB - subgraph FirstParty["First-party clients"] - GUI["Desktop GUI 1..N"] - TUI["TUI 1..N"] - Remote["Remote clients"] - end - - GUI --> Local["Local Agent Host"] - TUI --> Local - Remote --> Local - - Exec["bitfun exec"] --> Embedded["Embedded Runtime"] - SharedExec["CLI attach"] --> Local - - SDK["Agent SDK"] --> SDKHost["Private SDK Host"] - - Local --> Owners["Same API + modules"] - Embedded --> Owners - SDKHost --> Owners -``` - -稳定结论: - -- `Agent Runtime` 是一组负责 Session、Tool、Permission 等行为的模块,不是某个进程名。Embedded、Local Agent Host、SDK Host 和目标机器上的 Runtime - 只改变部署,不复制 Agent loop。 -- `Local Agent Host` 与 `SDK Host` 是 Rust 产品进程;`Plugin Host` 只指运行 Node/Bun 与第三方 JS/TS 的受监督子进程。 - Rust 插件调用端称为 `PluginRuntimeClient`,不存在 Rust `Plugin Host` 对象。 -- GUI/TUI/Remote 使用第一方 rich-client adapter,不依赖公开 Python/TypeScript SDK;公开 SDK 不获得第一方私有 route。 -- Workspace、Session、Client 或插件都不是默认进程键。只有负责模块的状态、并发或安全约束要求时,它们才进入 - 对应状态键或隔离条件。 -- 多个客户端不会自动增加 Rust Runtime 或 Plugin Host 进程。先复用异步连接、有界队列和现有实现;只有测量与兼容事实 - 证明必须隔离时才增加进程。 - -## 2. 名词与产品边界 - -| 名称 | 唯一含义 | 不等于 | -|---|---|---| -| Agent Runtime | 负责 Session、Turn、Tool、MCP、Permission、Hook、Event 和持久化行为的既有模块 | 单一 crate、Server 或进程 | -| Embedded Runtime | Runtime 与调用入口位于同一 Rust 进程的部署 | 简化版 Runtime | -| Local Agent Host | 为第一方 GUI/TUI/Remote 多实例托管 Runtime 的目标本地 Rust 进程 | 公开 SDK、Plugin Host 或全机器单例 | -| SDK Host | 向公开 Agent SDK 提供精选版本化协议的 Rust 进程,默认由 SDK 私有管理 | BitFun CLI、Local Agent Host rich-client 协议或 Plugin Host | -| Plugin Host | 运行 Node/Bun 与第三方 JS/TS 插件的受监督子进程 | Rust client、Agent Runtime、Local Agent Host 或 SDK Host | -| Session Controller | 当前有权回答一个 Session 的 Permission、UserInput、确认和 steering 的单一 Client | Session 持久化模块或第一个响应者 | -| execution domain | 文件、进程、凭据和副作用实际所在的位置 | 一个布尔 `remote` 标记或 Workspace 别名 | - -普通用户仍只需要选择 GUI/TUI、`bitfun exec` 或 Agent SDK。Local Agent Host、SDK Host、Plugin Host 和协议细节属于贡献者 -文档与高级诊断,不新增普通用户必须手工管理的产品。 - -## 3. 部署选择 - -```mermaid -flowchart TD - Start["Operation"] --> Shared{"Shared Session?"} - Shared -->|"yes"| Local["Local Agent Host"] - Shared -->|"no"| Caller{"Caller"} - Caller -->|"GUI / TUI / Remote"| Local - Caller -->|"one-shot CLI"| Embedded["Embedded"] - Caller -->|"Agent SDK"| SDKHost["Private SDK Host"] -``` - -```mermaid -flowchart LR - Operation["Operation"] --> Location{"Workspace"} - Location -->|"local"| Local["Local execution"] - Location -->|"remote"| Remote["Remote Runtime"] -``` - -| 使用形态 | 默认部署 | 状态与进程边界 | -|---|---|---| -| Desktop GUI / 交互式 TUI | Local Agent Host | 多实例共享 Session 和兼容的重资源;renderer 与输入状态留在各 Client | -| 本机 Remote Control | 经认证连接 Local Agent Host | Runtime、文件与凭据仍在本机;远端只控制和呈现 | -| 一次性 `bitfun exec` | Embedded Runtime | 命令结束后回收,不为普通 shell/CI 启动常驻 Host | -| CLI 恢复或控制共享 Session | attach Local Agent Host | 从开始即选择 Host-backed,避免第二个 Session 写入者 | -| BitFun Agent SDK | 私有 SDK Host | 默认使用独立状态范围、凭据和生命周期,不自动访问用户 GUI/TUI 状态 | -| SDK 显式访问 BitFun 用户状态 | 经授权连接 Local Agent Host 的 SDK adapter | 仍只使用公开 SDK capability,不开放 rich-client 私有 route | -| Remote Workspace | 目标机器上的 Runtime 或 Local Agent Host | 文件、凭据、进程和 Plugin Host 位于目标机器 | -| 高风险或并发写入任务 | 独立 worktree、容器、VM 或远程 execution domain | 隔离副作用与故障域,不为普通 UI 实例默认创建 | - -不采用“第一个 GUI/TUI 先 Embedded,第二个实例出现时在线迁移到 Local Agent Host”。活动 Turn、Tool/PTY 子进程、 -Plugin Host 模块实例、Permission、callback、事件 sequence 和取消树不能低风险热迁移。部署必须在开始执行前确定。 - -## 4. 状态共享与隔离 - -### 4.1 五类使用范围 - -```mermaid -flowchart LR - User["User / product"] --> UserState["Policy · credentials"] - Workspace["Workspace"] --> ProjectState["Index · project memory"] - Session["Session"] --> SessionState["Transcript · Controller"] - Query["Query"] --> QueryState["Snapshot · result"] - Client["Client"] --> ClientState["Draft · layout"] -``` - -| 使用范围 | 可以共享 | 必须隔离 | -|---|---|---| -| OS 用户与产品通道 | 产品策略、凭据 broker、Provider/模型缓存、全局容量和审计索引 | 不同 OS 用户、发行通道、协议 major 或安全策略 | -| Workspace 事实 | 只有已有模块明确按项目持有的 watcher、索引、Git、LSP、项目记忆和扩展目录 | 不同执行位置、凭据归属或确有独立状态的项目 | -| Session | transcript、Turn queue、usage/audit、观察者与 Session Controller | 不同 Session 的可变上下文和写入顺序 | -| Query / Turn | 接受后固定的上下文、deadline、取消树、callback、子进程引用与最终状态屏障 | 不同 Query 的缓冲、callback identity 和结果提交 | -| Client | 连接、renderer、草稿、布局、滚动、焦点和未提交表单 | 不进入 Session 权威状态,不自动同步给其他 Client | - -Workspace 只在具体负责模块确有独立配置、状态、版本、文件身份或并发单例时作为该模块的键。它不产生一个新的 -“Workspace Runtime”产品对象,也不默认决定 Rust Runtime、Plugin Host 或 MCP 进程数量。 - -### 4.2 Session 单写与多观察者 - -```mermaid -stateDiagram-v2 - [*] --> Observer - Observer --> Controller: acquire - Controller --> Observer: release / timeout - Controller --> Controller: interact - Observer --> Observer: observe -``` - -- 一个 Session 只有一个权威写入者,可以有多个 Observer,但同一待处理交互只有一个 Session Controller。 -- start/cancel/steer/answer 先由现有 Session/Turn 模块校验状态版本、操作身份和当前状态;Local Agent Host - 不增加平行调度器。 -- 禁止“第一个响应获胜”。非 Controller 响应返回类型化错误;Controller 断线后,未决交互进入 `action_required`,完成显式 - handoff 后才允许其他 Client 回答。 -- Session 锁不能解决两个 Session 同时修改同一目录的问题。并发写入必须选择串行提交、独立 worktree 或隔离 execution domain。 - -### 4.3 Client 专属平台能力 - -窗口、文件选择器、剪贴板、浏览器呈现和部分 Computer Use 能力只能由具体 Desktop Client 提供,不能搬入无界面的 Local -Agent Host。这些能力通过现有 provider/adapter 注册,并绑定到具体 Client、授权版本和能力集合;授权有明确期限: - -```mermaid -sequenceDiagram - participant R as Runtime - participant H as Host adapter - participant D as Bound Client - participant O as Other Client - - D->>H: Register capability - R->>H: Invoke - H->>D: Request - D-->>H: Result - H-->>R: Accepted - O--xH: Wrong client - D--xH: Disconnect - H-->>R: Unavailable -``` - -- Session/Query 在开始前绑定所需的 Client 能力授权;请求不能广播给“任意在线 GUI”,也不能采用“最先响应者获胜”。 -- app-local adapter 只执行其真实平台能力;Local Agent Host 只路由请求,并校验操作、Client、授权版本、期限和结果类型。 -- Client 断线、授权过期或能力撤回后立即拒绝调用,返回 `unavailable` 或 `action_required`;不得自动切换到另一个 Client。 -- 切换 provider 需要显式重新选择或授权,并生成新的授权版本;已开始且可能产生副作用的操作不自动重放。 -- Plugin Host 与第三方代码不能直接定位或调用 Desktop Client。它们只能提交候选请求,由既有 Tool/Permission/平台能力归属模块 - 决定是否允许并经绑定 provider 执行。 - -### 4.4 记忆 - -- 用户记忆只在同一 OS 用户与安全域共享。 -- 项目记忆按已有负责模块认可的 Workspace identity 与执行位置共享。 -- 会话上下文只属于一个 Session;多个 Client 观察同一 Session 不复制上下文。 -- Turn 接受后使用不可变输入快照。 -- UI 草稿与布局只属于 Client。 -- Plugin Host 内的 `globalThis`、闭包、模块缓存和未声明单例是易失实现状态,不是 BitFun Memory,也不跨进程复制。 -- 跨机器记忆同步需要独立的数据、加密、冲突和删除设计,不能从本地共享自然推导。 - -## 5. Plugin Host 在多实例 Runtime 中的位置 - -Local Agent Host 的引入不能把 Plugin Host 改成按 GUI/TUI、Workspace、Session 或 SDK 调用方创建的进程。物理关系是: - -```mermaid -flowchart TB - subgraph RustProcess["One Rust Runtime process"] - Clients["Clients"] - Owners["Runtime"] - Client["PluginRuntimeClient"] - end - - Clients --> Owners --> Client - Client --> Service["Process service"] - Service --> P1["Plugin Host A"] - Service --> P2["Plugin Host B"] - P1 --> Plugins["Compatible plugins"] -``` - -### 5.1 默认复用 - -同一实际承载 RuntimeServices 的 Rust 进程内,满足以下事实兼容的插件默认共享一个 Plugin Host: - -- 位于同一实际执行机器,并使用同一 OS 用户; -- 使用相同脚本后端及兼容版本; -- 进程级沙箱、网络、环境变量、凭据可见范围和数据分类兼容; -- 原生依赖、架构、启动参数和进程级单例可以安全共存。 - -Local Agent Host 中多个 GUI/TUI/Remote Client 因此可以复用同一批兼容 Plugin Host。一次性 Embedded CLI、私有 SDK Host -和目标机器 Runtime 位于不同 Rust 进程或 execution domain,不跨进程透明共享 Plugin Host。 - -只有以下事实不兼容时才拆分 Plugin Host: - -1. 实际执行机器或 OS 用户不同; -2. 后端、版本、架构、原生依赖或启动参数无法共存; -3. 沙箱、网络、环境变量、凭据或数据隔离要求不能安全合并; -4. 固定兼容样例证明某插件必须独占进程级单例; -5. 测量证明单进程达到容量瓶颈,且被拆调用明确无共享模块状态、无顺序 Hook 语义并可独立恢复。 - -第五项不是通用 worker pool。没有显式可序列化状态和兼容测试时,闭包、模块实例、`globalThis` 或隐式单例不能在进程间迁移。 - -### 5.2 生命周期联动 - -| 事件 | Rust Runtime / Client | Plugin Host | -|---|---|---| -| 新 GUI/TUI 连接 Local Agent Host | 增加 Client 订阅;不重建 Runtime | 不因 Client 数增加进程 | -| Client 断线 | 回收其订阅与 UI capability;按 Session 策略处理任务 | 只要仍有活动插件、调用、订阅或候选加载就继续运行 | -| SDK 启动 | SDK Host 使用私有 Runtime 数据空间 | 由该 SDK Host 的进程服务启动或复用自己的兼容子进程 | -| Remote Workspace | 控制端只持认证连接和只读状态 | 在目标机器 Runtime 下启动,不回落控制端 | -| Plugin Host 崩溃 | services 将 `process-lost` 绑定到失效连接;`PluginRuntimeClient` 据此结束在途请求并通知能力归属模块,旧连接的迟到事件只记诊断 | 同一活动进程承载的插件实例共同失效,Rust Runtime 本身继续运行 | -| Local Agent Host / SDK Host 退出 | 停止新调用、取消可取消请求、排空并提交权威状态 | 进程服务逆序 dispose 并回收完整子进程树 | - -Plugin Host 失败不应终止 Local Agent Host;Local Agent Host 失败时则必须由进程监督与恢复路径清理其所有 Plugin Host、Tool、 -MCP 和 PTY 子进程。已经发生但无法确认结果的外部副作用必须标记为“结果未知”,不能自动重放。 - -## 6. 连接、事件与流量控制 - -Local Agent Host 不建立第二套业务事件总线。现有 Event/Session 模块产生权威事实;Host 只管理连接认证、订阅、顺序、 -有界缓冲和恢复位置。 - -```mermaid -flowchart LR - Owners["Runtime"] --> Journal["Ordered events"] - Journal --> A["Client A queue"] - Journal --> B["Client B queue"] - Journal --> C["Remote queue"] - B -->|"overflow"| Resync["Reload snapshot"] -``` - -- 同一 Query 使用递增序号;Session 状态写入使用版本号,不承诺不同 Query 的全局顺序。 -- 每 Client 同时限制条目数和字节数;慢 Client 不能阻塞 Runtime、Plugin Host 或其他 Client。 -- 权威事件不能静默丢失;溢出后返回结构化原因,Client 使用完整快照和记录的恢复位置重新同步。 -- Query 只产生一个 `Result`,或一个“结果是否生效无法确认”的最终状态;重连不能重放 Tool、Permission、Plugin 调用或可能重复产生副作用的写入。 -- toast、焦点、滚动、动画等 UI 事件不进入权威事件记录。 - -## 7. 进程、线程与容量 - -多个 TUI 不对应多个 Server 或 Plugin Host。一个 Local Agent Host 内按工作性质使用不同执行资源: - -```mermaid -flowchart LR - Clients["Clients"] --> Async["Async I/O"] - Async --> Session["Session / Turn"] - Session --> CPU["Bounded CPU pool"] - Session --> Children["Supervised children"] -``` - -| 工作 | 机制 | 约束 | -|---|---|---| -| Client 连接、心跳、订阅与 I/O | 异步任务 | 每连接有界,不为每 Client 占固定线程 | -| Session/Turn 状态变更 | 现有模块的串行提交与有界队列 | 同一 Session 单写,不堆叠跨模块全局锁 | -| 索引、解析、压缩与编码 | 有界 CPU 线程池 | 不阻塞异步事件循环 | -| Tool、PTY、MCP、Plugin Host | 受监督子进程 | 期限、取消、进程树和资源预算由现有进程服务管理 | - -容量至少分 Host、Client、Session、Query、Provider 与子进程层统计连接数、活动/排队 Query、正在执行的调用、待发送 -字节、CPU 队列和子进程数。达到容量时返回类型化的使用范围、阶段与恢复动作,不能无限排队或通过增加 executor 线程掩盖流量控制。 - -```mermaid -flowchart TD - Load["Pressure"] --> Incompatible{"Isolation required?"} - Incompatible -->|"yes"| Split["Split process"] - Incompatible -->|"no"| Measured{"Capacity exceeded?"} - Measured -->|"no"| Reuse["Reuse process"] - Measured -->|"yes"| Serializable{"State movable?"} - Serializable -->|"no"| Optimize["Optimize first"] - Serializable -->|"yes"| Split -``` - -只有上述判定证明单 Rust 进程不足时,才增加额外 Runtime 进程。不默认按 Client、TUI、Workspace、Session 或插件分片。 -同一 Session 在任一时刻只能有一个写入者;跨进程迁移需要持久检查点,并确保旧进程不能继续写入。首版不做在线迁移。 - -## 8. Host 发现、安全与生命周期 - -Local Agent Host 不是无条件机器全局单例。实例身份至少包含: - -```text -(OS user, product identity, data storage scope, release channel, protocol major, execution domain) -``` - -- 产品身份和数据空间必须来自产品组装结果;不同品牌、便携 profile 或数据目录不得因为使用同一用户和发行通道而命中同一个可写 Host。 -- 不兼容的主协议版本可以使用不同 endpoint,但同一持久 Session 在任一时刻只有一个写入者。现有 Session 持久化模块 - 在打开 Session 时授予写入权,并拒绝其他进程同时写入;Project/User Memory、目录和其他共享数据继续使用各自归属模块 - 已有的版本检查或事务,不增加覆盖全部数据的统一编号。 -- 新建 Session 的 Embedded `bitfun exec` 取得该 Session 自己的写入权,因此不同 Session 可以并行。恢复既有 Session 前必须 - 取得它的写入权;如果 Local Agent Host 或另一个 `exec` 已持有,CLI 必须 attach 现有 Host 或返回明确的“Session 已占用”, - 不能启动第二个写入者。 -- Windows 使用当前用户 SID 限制的 Named Pipe;macOS/Linux 使用用户私有目录下的 Unix Domain Socket 和 peer credential。 -- 默认不监听 localhost TCP。发现记录只包含 endpoint、PID、build/protocol 和健康检查所需最小事实,不包含凭据或 Session 内容。 -- 并发启动通过实例锁、进程归属校验和认证握手避免重复启动;不能仅凭 PID 或发现文件终止进程。 -- Remote Control 使用现有安全远程/中继能力或出站认证连接,不把本地 IPC token 暴露到网络。 -- WebView 和浏览器不直接持有 Local Agent Host 或 SDK Host 凭据;由可信 Rust 主进程或开发者后端只开放所需能力。 - -Host 退出不能只看窗口数。只有以下引用均归零并经过短暂 grace period 后才退出: - -```mermaid -flowchart LR - Clients["Clients"] --> Refs["Host refs"] - Queries["Queries"] --> Refs - Background["Background"] --> Refs - Remote["Remote"] --> Refs - Refs --> Grace["Wait briefly"] -``` - -| 引用 | 负责模块 | 释放条件 | -|---|---|---| -| Client | Host 连接管理模块 | 连接关闭且订阅已撤销 | -| Query | Session/Turn 模块 | 排队与执行均最终状态结算 | -| Background | 对应任务模块 | 后台任务完成或显式停止 | -| Remote | Remote adapter | 监听/出站控制要求撤销 | - -```mermaid -stateDiagram-v2 - [*] --> Discover - Discover --> Connect: compatible Host - Discover --> Launch: none found - Launch --> Active: endpoint ready - Connect --> Active - Active --> Active: references remain - Active --> Grace: zero references - Grace --> Active: new reference - Grace --> Drain: grace expired - Drain --> Exit: cleanup done - Exit --> [*] -``` - -Drain 由生命周期模块回收 Host 仍持有的子进程、Workspace 服务和发现记录;任何失败都停留在 Drain 并进入可见诊断,不能误报退出。 -Workspace 相关 watcher、索引、LSP 或可回收 MCP 可以由各自负责模块按空闲策略更早卸载;这不建立 Workspace 级进程,也不删除 -持久 Session 或 Memory。启用 Remote Control 或后台任务导致 Host 继续运行时,产品必须显示常驻原因和停止方式。 - -```mermaid -sequenceDiagram - participant Old as Old Host - participant Persist as Session persistence - participant New as New Host - - Old->>Persist: Release write access - alt Release confirmed - New->>Persist: Acquire write access - Persist-->>New: Granted - else Old writer may remain - Persist-->>New: Denied - end - Note over Old,New: no live migration -``` - -现有 Session/Turn 模块先停止该 Session 的新工作,并处理正在执行的操作,再让 Session 持久化模块转移写入权。只有旧写入者 -已经释放,或持久化模块能证明它不能继续提交时,新 Host 才能取得写入权;无法证明安全时,新 Host 保持只读并提示 Session 已占用。 - -升级和回滚使用同一套规则阻止旧进程继续写入。已开始的 Query、PTY、MCP 或 Plugin Host 模块状态不在线迁移;只有旧 Host -释放已经确认,或持久化模块能证明它不能继续提交时,新 Host 才能接管持久写入。无法确认的副作用单独标记为“结果未知”, -不能作为接管写入的依据。版本不兼容的旧 Client 要么连接兼容只读端点,要么收到明确升级提示, -不能自行启动第二个可写 Runtime。 - -## 9. 故障与恢复 - -```mermaid -flowchart TB - ClientCrash["Client"] --> ClientScope["Client scope"] - PluginCrash["Plugin Host"] --> PluginScope["Hosted plugins"] - ChildCrash["Tool / MCP / PTY"] --> ChildScope["Owned child"] - LocalCrash["Local Agent Host"] --> LocalScope["Local Runtime"] - RemoteCrash["Remote Runtime"] --> RemoteScope["Remote domain"] -``` - -| 故障 | 处理 | -|---|---| -| GUI/TUI Client 崩溃 | 回收 Client 订阅和平台能力;Session/Query 按前台/后台策略继续或停止 | -| Session Controller 断线 | 未决交互进入 `action_required`;显式 handoff 后恢复,不能自动允许 | -| Local Agent Host 崩溃 | 进程监督模块清理子进程树;从持久事实恢复;未确认副作用标记为“结果未知” | -| Plugin Host 崩溃 | 暂停其承载插件的后续调用;能力归属模块撤下贡献;Rust Runtime 和其他兼容组继续运行 | -| Tool/MCP/PTY 子进程崩溃 | 对应进程服务隔离、诊断和有限恢复,不重启整个 Local Agent Host | -| Session 持久化不可用 | 停止新的持久提交;内存结果不能冒充已经持久保存 | -| Client 落后或断网 | 暂停或断开其有界队列,并用完整快照和恢复位置重新同步;不拖慢其他 Client | - -## 10. 产品入口职责 - -| 入口 | 保留在 Client | 进入共享 Runtime | -|---|---|---| -| Desktop / Tauri | 窗口、菜单、托盘、通知、文件选择器、桌面能力呈现与 Host 监督 | Session、索引、Tool/MCP/Plugin 生命周期和持久状态 | -| TUI | renderer、键位、输入编辑、终端尺寸、剪贴板和终端恢复 | 状态快照、事件、Session 控制、Agent 执行 | -| Headless CLI | 参数、stdout/stderr、JSON/JSONL 与退出码 | Embedded 或 attach 后的同一 Runtime 行为 | -| Agent SDK | 语言 API、异步迭代、callback 与私有 Host 生命周期 | 精选 SDK adapter 后的同一 Runtime 行为 | -| Remote | 设备呈现、连接状态与受限交互 | 目标机器上的 Session、文件、凭据和执行模块 | - -Tauri 可以启动、监控和重连 Local Agent Host,但不能负责 Agent Runtime 生命周期。TUI/GUI 不通过公开 SDK package -绕行。Local Agent Host、SDK Host、Plugin Host、Server/Relay 和 Remote execution Host 在进程名、诊断和文档中必须可区分。 - -## 11. 性能与完成条件 - -方案 C 会让单 GUI/TUI 增加一个轻量 Rust Host 进程和本地 IPC,但应避免复制索引、LSP、MCP、模型缓存与 Plugin Host。 -发布前必须在同一机器和 fixture 下比较 Embedded 与 Host-backed: - -| 指标 | 验证问题 | -|---|---| -| 首次可输入与首个 Query 事件 | 发现、启动、握手和 IPC 是否产生不可接受延迟 | -| 单 Client 总 RSS | Client + Local Agent Host 是否显著高于现有 Embedded 基线 | -| 第二个 Client 增量 RSS | 是否只增加 renderer、连接和缓冲,而未复制 Runtime/Plugin Host | -| 第二个真实状态域增量 | watcher、LSP、MCP 和插件进程是否按各自负责模块的实际键复用或隔离 | -| 多 Observer 与多 Query 延迟 | 事件 fan-out、Provider、CPU 与 Plugin Host 队列是否公平且有界 | -| 空闲回收 | 各负责模块能否卸载重资源,Host 能否无孤儿子进程退出 | -| Client/Host/Plugin Host 崩溃 | Session 连续性、进程树回收、故障暂停和“结果未知”状态是否准确 | - -只有满足以下条件,才可把入口标记为 Host-backed 已交付: - -- Embedded 与 Host-backed 通过同一 Session/Turn/Tool/MCP/Permission/Hook/Plugin/Usage 行为 fixture。 -- GUI、TUI 和 Remote 同时附着同一 Session 时,revision、最终状态、Controller 与权限结论一致。 -- Desktop 专属能力在绑定 Client 上执行;断线、撤回、授权版本变化或另一个 Client 抢答时均拒绝执行,且不会重放 - 已经发生的外部副作用。 -- 慢 Client、断线、重连、过期恢复位置、并发回答和控制权移交不产生重复提交,也不采用“最先响应者获胜”。 -- Client 数不会改变 Plugin Host 数;只有执行机器、OS 用户、脚本后端、沙箱、网络、环境变量和凭据都兼容时才复用 Plugin Host,是否拆分还要依据真实状态和测量结果。 -- Local Agent Host、SDK Host 与 Remote Runtime 各自只能管理自己进程树内的 Plugin Host,不跨执行域共享句柄或模块状态。 -- Windows Named Pipe 与 Unix Domain Socket 的用户隔离、身份校验、启动竞争和旧发现记录恢复通过跨平台测试。 -- 新旧协议版本与升级/回滚交错时,同一 Session 始终只有一个写入者;不同 Session 可以并行写入,各共享数据 - 继续由自己的归属模块控制版本或事务。旧 Host 即使读到最新数据也不能继续写;失败路径不能双写,也不在线迁移活动 - Query 或 Plugin Host 模块状态。 -- 单实例与第二 Client 的启动、RSS、CPU、队列和卸载数据均已记录,未用愿望型阈值替代测量。 - -## 12. 明确非目标 - -- 不建设覆盖所有内部能力的统一 Server API、Service Locator 或新的全局 Runtime manager。 -- 不把 Local Agent Host、SDK Host 和 Plugin Host 合并成一个进程职责或公开产品概念。 -- 不让 GUI/TUI/CLI 依赖公开 SDK package,也不把 rich-client 私有 route 发布为 Agent SDK。 -- 不按 Client、窗口、TUI、Workspace、Session 或 plugin 默认创建 Rust Runtime 或 Plugin Host 进程。 -- 不把 Workspace 当作所有运行时状态的默认使用范围;只有具体负责模块能证明时才使用。 -- 不实现 Embedded Runtime 到 Local Agent Host 的在线迁移。 -- 不允许多个 Rust 进程同时写一个 Session,也不把 Session 独占写入权冒充 Workspace 文件冲突保护。 -- 不为普通本地交互默认创建容器、VM 或 worktree。 -- 不透明共享跨机器 Memory、凭据、路径或 Plugin Host 模块状态。 -- 不在没有测量数据前预设进程数、线程数、Session 容量或自动扩容阈值。 diff --git a/docs/architecture/product-architecture.md b/docs/architecture/product-architecture.md index fd7f97cdc5..5be8294062 100644 --- a/docs/architecture/product-architecture.md +++ b/docs/architecture/product-architecture.md @@ -17,9 +17,9 @@ 可装配并双向接入 Claude Code、Codex、OpenCode、Trae 等宿主见 [`capability-runtime-integration-design.md`](extensions/capability-runtime-integration-design.md);公开 BitFun Agent SDK、SDK Host、 Headless CLI 与各产品入口的统一心智见 -[`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md);多个 GUI/TUI/Remote/CLI/SDK 实例共存时的 Local Agent Host、 +[`agent-sdk-product-architecture.md`](agent-sdk-product-architecture.md);多个 GUI/TUI/Remote/CLI/SDK 实例共存时的 Agent Runtime 部署、 状态共享、隔离、容量与 Plugin Host 关系见 -[`local-agent-host-multi-instance-design.md`](local-agent-host-multi-instance-design.md)。详细设计与本文件冲突时,以本文件为准。 +[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md)。详细设计与本文件冲突时,以本文件为准。 本文件只约束稳定边界,不记录单次 PR 进度,也不把未来可能支持的生态能力提前声明为公开接口。 @@ -62,7 +62,7 @@ BitFun 同时面向桌面 GUI、TUI/CLI、Web、ACP、Server、Remote、SDK 和 13. **一个 Agent Runtime,多种交付形态**:GUI、TUI、Headless CLI、公开 SDK、ACP 与 Server/Remote 都是 同一 Agent Runtime 的 adapter。Query、Session、Tool/MCP、Permission、Hook、Event/Usage 只有一个行为 归属模块;公开 SDK 不成为内部入口的依赖,ACP 和 Headless CLI 也不成为完整 SDK 的别名。目标部署中,第一方 - GUI/TUI/本机 Remote 可以共享 Local Agent Host,一次性 Headless CLI 保留 Embedded,公开 SDK 默认使用私有 SDK Host; + GUI/TUI/本机 Remote 可以共享 Agent Runtime,一次性 Headless CLI 保留 Embedded,公开 SDK 默认使用私有 SDK Host; 这些 Rust 部署都只通过 `PluginRuntimeClient` 和 services 归属模块管理自己的 Node/Bun Plugin Host 子进程。 调用路径长度只作为工程成本处理,不作为独立架构目标。允许保留承担兼容隔离、只读视图或能力选择职责的中间层;不允许为了兼容而长期暴露没有消费方的抽象接口。 @@ -139,9 +139,9 @@ Host adapter 只负责把各自协议映射到 **BitFun Agent SDK**,其跨进程适配器称为 **SDK Host**。该术语区分不要求机械重命名现有 crate/module,但禁止用 Rust preview 的存在证明公开 SDK 已交付。 -第一方多实例目标中的 Rust 进程称为 **Local Agent Host**。它与 SDK Host、Plugin Host、Server/Relay 和 Remote +第一方多实例目标称为 **Shared Agent Runtime deployment**。承载它的 Rust 进程与 SDK Host、Plugin Host、Server/Relay 和 Remote execution Host 都是不同职责;其部署与进程生命周期以 -[`local-agent-host-multi-instance-design.md`](local-agent-host-multi-instance-design.md) 为准。 +[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md) 为准。 Rust 与 TypeScript 的字段一致性以能力所有者的 DTO 为事实源,不以 Tauri command 参数为事实源。单宿主阶段由 前端基础设施层维护对应接口,并用序列化契约测试锁定字段命名、可选字段和错误形状;达到独立版本化门槛后,才使用 @@ -410,10 +410,10 @@ flowchart LR | HarmonyOS PC GUI | 未实现 | 与 CLI/TUI 共享 Runtime 语义,但独立验证宿主、界面和发布 | | Public Agent SDK | Python/TypeScript 尚未交付;Rust Runtime SDK 是内部 preview | 一个 `AgentClient`、多个语言绑定;SDK Host 不依赖或冒充 CLI | -Local Agent Host 是第一方多实例的目标部署,不是上表新增的当前产品形态。当前文档中表示“事实实际所在位置”的泛称 Host -可能仍指 Desktop 进程、Peer、Server 或 Remote execution host,不能据此推断 Local Agent Host、多 Client Session 单写或 +Shared Agent Runtime 是第一方多实例的目标部署,不是上表新增的当前产品形态。当前文档中表示“事实实际所在位置”的泛称 Host +可能仍指 Desktop 进程、Peer、Server 或 Remote execution host,不能据此推断 Shared deployment、多 Client Session 单写或 跨进程重连已经交付;完成条件以 -[`local-agent-host-multi-instance-design.md`](local-agent-host-multi-instance-design.md) 为准。 +[`agent-runtime-deployment-design.md`](agent-runtime-deployment-design.md) 为准。 对外一级状态统一使用[外部 AI 工作内容设计](extensions/external-ai-work-sources-design.md#7-状态与提示规则)定义的 已发现、已应用、可用、需确认、更新中、沿用上一版本、部分受限、暂时过期、已移除/已停用和不可用,并附带 diff --git a/scripts/core-boundaries/checker.mjs b/scripts/core-boundaries/checker.mjs index 5c7c887adc..824650683d 100644 --- a/scripts/core-boundaries/checker.mjs +++ b/scripts/core-boundaries/checker.mjs @@ -427,38 +427,37 @@ function checkCargoManifest(crateDir) { function checkForbiddenManifestDeps(crateDir, forbiddenDeps, messageForDep) { const manifestPath = join(crateDir, 'Cargo.toml'); - const lines = readText(manifestPath).split(/\r?\n/); - lines.forEach((line, index) => { - const trimmed = line.trim(); - if (trimmed.startsWith('#')) { - return; - } - for (const dep of forbiddenDeps) { - if (isManifestDependencyDeclaration(trimmed, dep)) { - failures.push({ - path: manifestPath, - line: index + 1, - message: messageForDep(dep), - }); - } + const deps = parseManifestDependencies(readText(manifestPath).split(/\r?\n/)); + for (const dep of deps) { + const forbidden = matchingForbiddenDependency(dep, forbiddenDeps); + if (forbidden) { + failures.push({ + path: manifestPath, + line: dep.line, + message: messageForDep(forbidden), + }); } - }); + } } function checkForbiddenNonOptionalManifestDeps(crateDir, forbiddenDeps, messageForDep) { const manifestPath = join(crateDir, 'Cargo.toml'); const deps = parseManifestDependencies(readText(manifestPath).split(/\r?\n/)); for (const dep of deps) { - if (!dep.optional && forbiddenDeps.includes(dep.name)) { + const forbidden = matchingForbiddenDependency(dep, forbiddenDeps); + if (!dep.optional && forbidden) { failures.push({ path: manifestPath, line: dep.line, - message: messageForDep(dep.name), + message: messageForDep(forbidden), }); } } } +function matchingForbiddenDependency(dep, forbiddenDeps) { + return forbiddenDeps.find((dependencyName) => manifestDependencyMatches(dep, dependencyName)); +} function manifestDependencyMatches(dep, dependencyName) { const text = manifestDependencyText(dep); return dep.name === dependencyName || new RegExp(`\\bpackage\\s*=\\s*["']${escapeRegex(dependencyName)}["']`).test(text); @@ -1095,6 +1094,7 @@ export function runCoreBoundaryCheck() { isManifestDependencyDeclaration, parseManifestDependencies, manifestDependencyMatches, + matchingForbiddenDependency, manifestDependencyDisablesDefaultFeatures, parseManifestDependencyFeatureNames, productCoreFeatureAssemblyRules, diff --git a/scripts/core-boundaries/rules/crate-layout.mjs b/scripts/core-boundaries/rules/crate-layout.mjs index 97e9b64da8..5ec05e51f4 100644 --- a/scripts/core-boundaries/rules/crate-layout.mjs +++ b/scripts/core-boundaries/rules/crate-layout.mjs @@ -28,6 +28,7 @@ export const crateLayoutRules = [ { crateName: 'acp', layer: 'interfaces', path: 'src/crates/interfaces/acp' }, { crateName: 'sdk-host', layer: 'interfaces', path: 'src/crates/interfaces/sdk-host' }, + { crateName: 'agent-runtime-ipc', layer: 'adapters', path: 'src/crates/adapters/agent-runtime-ipc' }, { crateName: 'ai-adapters', layer: 'adapters', path: 'src/crates/adapters/ai-adapters' }, { crateName: 'claude-code-adapter', layer: 'adapters', path: 'src/crates/adapters/claude-code-adapter' }, { crateName: 'codex-adapter', layer: 'adapters', path: 'src/crates/adapters/codex-adapter' }, diff --git a/scripts/core-boundaries/rules/crate-rules.mjs b/scripts/core-boundaries/rules/crate-rules.mjs index e83f5cdd5f..2ee728f323 100644 --- a/scripts/core-boundaries/rules/crate-rules.mjs +++ b/scripts/core-boundaries/rules/crate-rules.mjs @@ -1,5 +1,46 @@ // Boundary rules for crate dependencies and lightweight profiles. +const agentRuntimeIpcForbiddenDeps = [ + 'bitfun-acp', + 'bitfun-agent-runtime', + 'bitfun-agent-stream', + 'bitfun-agent-tools', + 'bitfun-ai-adapters', + 'bitfun-claude-code-adapter', + 'bitfun-codex-adapter', + 'bitfun-core', + 'bitfun-core-types', + 'bitfun-events', + 'bitfun-external-sources', + 'bitfun-harness', + 'bitfun-opencode-adapter', + 'bitfun-page-function-runtime', + 'bitfun-plugin-runtime-client', + 'bitfun-product-capabilities', + 'bitfun-relay-service', + 'bitfun-runtime-ports', + 'bitfun-runtime-services', + 'bitfun-sdk-host', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-static-hook-support', + 'bitfun-tool-call-jsonrepair', + 'bitfun-tool-packs', + 'bitfun-product-domains', + 'bitfun-transport', + 'bitfun-webdriver', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'tokio-tungstenite', + 'bitfun-cli', + 'ratatui', + 'crossterm', + 'arboard', + 'syntect-tui', +]; + export const noCoreDependencyCrates = [ 'core-types', 'events', @@ -7,6 +48,7 @@ export const noCoreDependencyCrates = [ 'agent-stream', 'tool-call-jsonrepair', 'agent-runtime', + 'agent-runtime-ipc', 'harness', 'plugin-runtime-client', 'product-capabilities', @@ -85,6 +127,12 @@ export const forbiddenManifestDependencyRules = [ ]; export const lightweightBoundaryRules = [ + { + crateName: 'agent-runtime-ipc', + reason: + 'agent-runtime-ipc is a non-published Health-only local transport seam, not a Runtime, SDK Host, service, or product surface', + forbiddenDeps: agentRuntimeIpcForbiddenDeps, + }, { crateName: 'core-types', reason: 'core-types must stay low-level DTO-only', @@ -339,6 +387,13 @@ export const lightweightBoundaryRules = [ ]; export const dependencyProfileRules = [ + { + crateName: 'agent-runtime-ipc', + profileName: 'private Health-only local IPC profile', + reason: + 'agent-runtime-ipc must not acquire Runtime, SDK Host, service, remote transport, or product dependencies before its first reviewed consumer', + forbiddenNonOptionalDeps: agentRuntimeIpcForbiddenDeps, + }, { crateName: 'core', profileName: 'no-default runtime-surface-light profile', diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 329653b602..d683eb328e 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -5,7 +5,9 @@ export const optionalDependencyFeatureOwnerRules = [ crateName: 'services-core', reason: 'services-core workspace runtime dependencies must stay behind the explicit workspace-runtime feature', - dependencies: [{ depName: 'dunce', ownerFeatures: ['workspace-runtime'] }], + dependencies: [ + { depName: 'dunce', ownerFeatures: ['runtime-ownership', 'workspace-runtime'] }, + ], }, { crateName: 'runtime-ports', diff --git a/scripts/core-boundaries/rules/source/forbidden-rules.mjs b/scripts/core-boundaries/rules/source/forbidden-rules.mjs index 3c4ded44a0..50401f0055 100644 --- a/scripts/core-boundaries/rules/source/forbidden-rules.mjs +++ b/scripts/core-boundaries/rules/source/forbidden-rules.mjs @@ -1,6 +1,29 @@ // Boundary rules for source ownership, facades, and required owner content. export const forbiddenContentRules = [ + { + path: 'src/crates/adapters/agent-runtime-ipc/src/lib.rs', + reason: + 'agent-runtime-ipc must remain crate-internal until its first reviewed production consumer', + patterns: [ + { + regex: /^\s*pub\s+(?!\(crate\))/, + message: + 'agent-runtime-ipc must not expose any externally public Rust item before consumer review', + }, + ], + }, + { + path: 'src/crates/adapters/agent-runtime-ipc/src/operation.rs', + reason: 'agent-runtime-ipc operation scope is frozen at Health', + patterns: [ + { + regex: /^\s+(?!Health\b)[A-Z][A-Za-z0-9_]*\b/, + message: + 'agent-runtime-ipc may not add operations or results beyond Health in this foundation', + }, + ], + }, { path: 'src/crates/execution/plugin-runtime-client/src/adapter.rs', reason: 'plugin-runtime-client adapter trait method surface must stay narrow', @@ -4020,6 +4043,17 @@ export const forbiddenContentRules = [ ]; export const forbiddenContentUnderRules = [ + { + path: 'src/crates/adapters/agent-runtime-ipc/src', + reason: 'agent-runtime-ipc transport is restricted to Named Pipe and Unix Domain Socket', + patterns: [ + { + regex: + /\b(?:(?:Tcp|Udp)[A-Za-z0-9_]*|tokio_tungstenite|reqwest|hyper|WebSocket)\b/, + message: 'agent-runtime-ipc must not add network or remote transports', + }, + ], + }, { path: 'src/apps', reason: diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 1a13712445..209ee9297a 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -6,6 +6,7 @@ export function runManifestParserSelfTest({ isManifestDependencyDeclaration, parseManifestDependencies, manifestDependencyMatches, + matchingForbiddenDependency, manifestDependencyDisablesDefaultFeatures, parseManifestDependencyFeatureNames, productCoreFeatureAssemblyRules, @@ -158,6 +159,16 @@ export function runManifestParserSelfTest({ ) { throw new Error('manifest parser must detect workspace aliases to forbidden packages'); } + const aliasedRuntimeDependency = parseManifestDependencies([ + '[dependencies]', + 'runtime = { package = "bitfun-agent-runtime", path = "../../execution/agent-runtime" }', + ])[0]; + if ( + matchingForbiddenDependency(aliasedRuntimeDependency, ['bitfun-agent-runtime']) !== + 'bitfun-agent-runtime' + ) { + throw new Error('forbidden dependency checks must reject Cargo package aliases'); + } const productCoreRulePaths = new Set( productCoreFeatureAssemblyRules.map((rule) => rule.manifestPath), @@ -663,8 +674,10 @@ export function runManifestParserSelfTest({ const servicesCoreDunceOwner = servicesCoreOptionalOwnerRule?.dependencies.find( (dependency) => dependency.depName === 'dunce', ); - if (!servicesCoreDunceOwner?.ownerFeatures.includes('workspace-runtime')) { - throw new Error('services-core workspace-runtime must own optional dependency dunce'); + for (const feature of ['runtime-ownership', 'workspace-runtime']) { + if (!servicesCoreDunceOwner?.ownerFeatures.includes(feature)) { + throw new Error(`services-core ${feature} must own optional dependency dunce`); + } } const servicesOptionalOwnerDeps = new Set( servicesOptionalOwnerRule?.dependencies.map((dependency) => dependency.depName) ?? [], @@ -4790,4 +4803,86 @@ export function runManifestParserSelfTest({ throw new Error(`missing MCP runtime facade-only rule for ${path}`); } } + + if (!noCoreDependencyCrates.includes('agent-runtime-ipc')) { + throw new Error('agent-runtime-ipc must be covered by the no-core dependency guard'); + } + const runtimeIpcBoundary = lightweightBoundaryRules.find( + (rule) => rule.crateName === 'agent-runtime-ipc', + ); + for (const dependency of [ + 'bitfun-agent-runtime', + 'bitfun-sdk-host', + 'bitfun-services-core', + 'bitfun-services-integrations', + 'bitfun-product-domains', + 'bitfun-transport', + 'terminal-core', + 'tool-runtime', + 'tauri', + 'reqwest', + 'tokio-tungstenite', + 'bitfun-cli', + ]) { + if (!runtimeIpcBoundary?.forbiddenDeps.includes(dependency)) { + throw new Error(`agent-runtime-ipc lightweight boundary must forbid ${dependency}`); + } + } + const runtimeIpcProfile = dependencyProfileRules.find( + (rule) => rule.crateName === 'agent-runtime-ipc', + ); + for (const dependency of ['bitfun-agent-runtime', 'bitfun-services-core', 'tauri', 'reqwest']) { + if (!runtimeIpcProfile?.forbiddenNonOptionalDeps.includes(dependency)) { + throw new Error(`agent-runtime-ipc dependency profile must forbid ${dependency}`); + } + } + const runtimeIpcLibRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/adapters/agent-runtime-ipc/src/lib.rs', + ); + const runtimeIpcPublicPattern = runtimeIpcLibRule?.patterns[0]?.regex; + if ( + !runtimeIpcPublicPattern || + !runtimeIpcPublicPattern.test('pub fn leaked_api() {}') || + runtimeIpcPublicPattern.test('pub(crate) fn internal_api() {}') + ) { + throw new Error('agent-runtime-ipc public surface guard must allow only crate visibility'); + } + const runtimeIpcOperationRule = forbiddenContentRules.find( + (rule) => rule.path === 'src/crates/adapters/agent-runtime-ipc/src/operation.rs', + ); + const runtimeIpcOperationPattern = runtimeIpcOperationRule?.patterns[0]?.regex; + if ( + !runtimeIpcOperationPattern || + !runtimeIpcOperationPattern.test(' Execute,') || + runtimeIpcOperationPattern.test(' Health,') + ) { + throw new Error('agent-runtime-ipc operation guard must allow only Health by structure'); + } + const runtimeIpcTransportRule = forbiddenContentUnderRules.find( + (rule) => rule.path === 'src/crates/adapters/agent-runtime-ipc/src', + ); + const runtimeIpcTransportRuleText = runtimeIpcTransportRule?.patterns + .map((pattern) => pattern.regex.source) + .join('\n') ?? ''; + const runtimeIpcTransportPattern = runtimeIpcTransportRule?.patterns[0]?.regex; + for (const transport of ['TcpListener', 'TcpStream', 'TcpSocket', 'UdpSocket', 'WebSocket']) { + if (!runtimeIpcTransportPattern?.test(transport)) { + throw new Error(`agent-runtime-ipc local-only guard must forbid ${transport}`); + } + } + for (const dependency of ['reqwest', 'tokio_tungstenite']) { + if (!runtimeIpcTransportRuleText.includes(dependency)) { + throw new Error(`agent-runtime-ipc local-only guard must forbid ${dependency}`); + } + } + const privateTransportModule = 'src/crates/adapters/agent-runtime-ipc/src/tcp.rs'; + if ( + !runtimeIpcTransportRule || + !privateTransportModule.startsWith(`${runtimeIpcTransportRule.path}/`) || + !runtimeIpcTransportPattern?.test('use tokio::net::TcpListener;') + ) { + throw new Error( + 'agent-runtime-ipc local-only guard must cover network transports in new private modules', + ); + } } diff --git a/src/crates/adapters/AGENTS-CN.md b/src/crates/adapters/AGENTS-CN.md index 055dfa9d1d..03ce7152eb 100644 --- a/src/crates/adapters/AGENTS-CN.md +++ b/src/crates/adapters/AGENTS-CN.md @@ -8,6 +8,7 @@ | Crate | 职责 | 本地文档 | |---|---|---| +| `agent-runtime-ipc` | 不发布且仅 crate 内可见的本机 IPC 预集成边界,为未来第一方 Shared Agent Runtime adapter 提供当前仅 Health 的基础 | [AGENTS.md](agent-runtime-ipc/AGENTS.md) | | `ai-adapters` | AI provider 请求/响应 adapter 与 stream protocol glue | [AGENTS.md](ai-adapters/AGENTS.md) | | `opencode-adapter` | OpenCode Command、standalone Tool 和 Subagent 实时 provider 的生态语义;受管包静态预览 | [AGENTS.md](opencode-adapter/AGENTS.md) | | `transport` | Event transport emitter 与宿主 transport adapter | [AGENTS.md](transport/AGENTS.md) | @@ -18,7 +19,7 @@ - 协议序列化、transport projection、外部 provider 请求整形、宿主通信 adapter 放在这里。 - OS、filesystem、terminal、MCP、remote、git、watch 等可复用实现放在 `services`,除非代码只是协议转换。 - 交付 profile 选择和 adapter 注册属于 `assembly`。 -- 不要为单一宿主或未来协议预建共享 API crate。宿主协议 DTO 应留在入口,直到当前生产消费方证明需要共享且可版本化的边界。 +- 不要为单一宿主或未来协议预建共享 API crate。只有相邻设计明确首个 consumer、稳定测试合同、集成检查和删除条件时,才允许保留不发布且仅 crate 内可见的预集成边界;后续只提升真实 consumer 使用的 API。 ## 依赖边界 diff --git a/src/crates/adapters/AGENTS.md b/src/crates/adapters/AGENTS.md index 02e96af55a..d4937ab324 100644 --- a/src/crates/adapters/AGENTS.md +++ b/src/crates/adapters/AGENTS.md @@ -11,6 +11,7 @@ services. | Crate | Responsibility | Local doc | |---|---|---| +| `agent-runtime-ipc` | Non-published, crate-internal local IPC pre-integration seam for a future first-party Shared Agent Runtime adapter; currently Health-only | [AGENTS.md](agent-runtime-ipc/AGENTS.md) | | `ai-adapters` | AI provider request/response adapters and stream protocol glue | [AGENTS.md](ai-adapters/AGENTS.md) | | `opencode-adapter` | OpenCode source semantics for the live Command, standalone Tool, Subagent, MCP, and static Hook providers; managed-package static preview | [AGENTS.md](opencode-adapter/AGENTS.md) | | `claude-code-adapter` | Runtime-free Claude Code Command, Subagent, MCP, and Hook source semantics with redacted projection | [AGENTS.md](claude-code-adapter/AGENTS.md) | @@ -26,9 +27,10 @@ services. - Keep OS, filesystem, terminal, MCP, remote, git, and watch implementations in `services` unless the code is purely protocol translation. - Keep delivery-profile selection and adapter registration in `assembly`. -- Do not create a shared API crate for a single host or a future protocol. Keep - host-local wire DTOs at the entrypoint until current production consumers - prove a shared, versioned boundary. +- Do not create a shared API crate for a single host or a future protocol. A + non-published pre-integration seam may remain crate-internal only when the + adjacent design names its first consumer, stable test contract, integration + check, and removal condition. Promote only the API used by that consumer. ## Dependency Boundaries diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md new file mode 100644 index 0000000000..54e8d57e38 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS-CN.md @@ -0,0 +1,29 @@ +**中文** | [English](AGENTS.md) + +# Agent Runtime IPC + +范围:`src/crates/adapters/agent-runtime-ipc`。 + +该 crate 不发布,是未来第一方 Shared Agent Runtime adapter 的私有预集成边界。当前只验证 discovery、单实例锁、有界 framing、认证初始化、Health、连接上限和 cleanup;它不是公开 SDK 或 Runtime owner,也没有生产 consumer。 + +## 预集成约束 + +- 首个候选 consumer 仅为另行评审的第一方交互式 TUI attach adapter;不自动包含 GUI、Remote、Headless CLI 或 SDK Host。 +- 稳定测试合同只有本机 endpoint、initialize-first、64 KiB frame、Health、连接上限和 owner-checked discovery cleanup。 +- consumer 必须复用既有 Agent Runtime owners,并证明 Embedded/Shared 行为等价,不能依赖 SDK Host。 +- 若首个 consumer 选择其他 transport,或 Shared 在产品接入前取消,删除本 crate。 + +## 边界 + +- 首个生产 consumer 证明准确 API 前,所有 Rust item 保持 crate 内可见,且 crate 不得发布。 +- Health 是唯一 operation。禁止增加 Session、Turn、Tool、MCP、Permission、UserInput、Hook、event replay、controller lease 或产品配置。 +- 禁止依赖 `bitfun-core`、Agent Runtime、SDK Host、services、CLI/TUI、Tauri、product domains、terminal、tool runtime 或远程 transport。 +- 只使用 Windows Named Pipe 或 Unix Domain Socket;禁止 TCP、HTTP、WebSocket、浏览器访问或远程 fallback。 +- 这是本机同用户隔离,不是沙箱。未来产品 composition 必须提供当前用户私有 runtime 目录。 + +## 验证 + +```bash +cargo test -p bitfun-agent-runtime-ipc +node scripts/check-core-boundaries.mjs +``` diff --git a/src/crates/adapters/agent-runtime-ipc/AGENTS.md b/src/crates/adapters/agent-runtime-ipc/AGENTS.md new file mode 100644 index 0000000000..ee77434b61 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/AGENTS.md @@ -0,0 +1,43 @@ +[中文](AGENTS-CN.md) | **English** + +# Agent Runtime IPC + +Scope: `src/crates/adapters/agent-runtime-ipc`. + +This non-published crate is a private pre-integration seam for a future +first-party Shared Agent Runtime adapter. It currently proves discovery, +one-instance locking, bounded framing, authenticated initialization, Health, +connection bounds, and cleanup. It is not a public SDK or Runtime owner, and it +has no production consumer yet. + +## Pre-integration contract + +- First consumer: the separately reviewed first-party interactive TUI attach + adapter. GUI, Remote, Headless CLI, and SDK Host are not implied consumers. +- Stable test contract: platform-local endpoint, strict initialize-first + handshake, 64 KiB frame limit, Health, bounded connections, and owner-checked + discovery cleanup. +- Integration check: the consumer must reuse existing Agent Runtime owners and + prove Embedded/Shared behavior equivalence without depending on SDK Host. +- Removal condition: delete this seam if the first consumer chooses another + transport or Shared deployment is abandoned before product activation. + +## Boundaries + +- Keep all Rust items crate-internal until the first production consumer proves + the exact API it needs. Do not publish this crate. +- Health is the only operation. Do not add Session, Turn, Tool, MCP, Permission, + UserInput, Hook, event replay, controller lease, or product configuration. +- Do not depend on `bitfun-core`, Agent Runtime, SDK Host, services, CLI/TUI, + Tauri, product domains, terminal, tool runtime, or remote transports. +- Use only Windows Named Pipes or Unix Domain Sockets. Do not add TCP, HTTP, + WebSocket, browser access, or remote fallback. +- Treat this as same-user local isolation, not a sandbox. Product composition + must supply a user-private runtime directory. + +## Verification + +```bash +cargo test -p bitfun-agent-runtime-ipc +node scripts/check-core-boundaries.mjs +``` diff --git a/src/crates/adapters/agent-runtime-ipc/Cargo.toml b/src/crates/adapters/agent-runtime-ipc/Cargo.toml new file mode 100644 index 0000000000..aa91c937e2 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "bitfun-agent-runtime-ipc" +version.workspace = true +authors.workspace = true +edition.workspace = true +publish = false +description = "Private pre-integration IPC seam for a shared BitFun Agent Runtime" + +[lib] +name = "bitfun_agent_runtime_ipc" +crate-type = ["rlib"] + +[dependencies] +dunce = { workspace = true } +fs2 = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +sha2 = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +uuid = { workspace = true } +tempfile = { workspace = true } + +[target.'cfg(windows)'.dependencies] +windows = { workspace = true, features = ["Win32_Storage_FileSystem"] } + +[lints] +workspace = true diff --git a/src/crates/adapters/agent-runtime-ipc/src/client.rs b/src/crates/adapters/agent-runtime-ipc/src/client.rs new file mode 100644 index 0000000000..cd3b11ef15 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/client.rs @@ -0,0 +1,162 @@ +use crate::{ + read_frame, write_frame, DiscoveryRecord, HealthResult, InitializeRequest, LocalIpcEndpoint, + LocalIpcStream, RuntimeIpcError, RuntimeIpcFrame, RuntimeIpcIoError, RuntimeIpcOperation, + RuntimeIpcOperationResult, RuntimeIpcTransportError, PROTOCOL_VERSION, +}; +use std::fmt; +use std::path::Path; +use std::time::Duration; + +pub struct RuntimeIpcClient { + stream: LocalIpcStream, + instance_identity: String, + request_timeout: Duration, + next_request_id: u64, +} + +impl fmt::Debug for RuntimeIpcClient { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("RuntimeIpcClient") + .field("instance_identity", &self.instance_identity) + .field("request_timeout", &self.request_timeout) + .field("next_request_id", &self.next_request_id) + .finish_non_exhaustive() + } +} + +impl RuntimeIpcClient { + pub async fn connect( + runtime_root: &Path, + discovery: &DiscoveryRecord, + client_id: &str, + client_version: &str, + timeout: Duration, + ) -> Result { + if discovery.protocol_version != PROTOCOL_VERSION { + return Err(RuntimeIpcClientError::IncompatibleProtocol { + expected: PROTOCOL_VERSION, + observed: discovery.protocol_version, + }); + } + validate_client_fact(client_id)?; + validate_client_fact(client_version)?; + let endpoint = LocalIpcEndpoint::parse_for_root( + &discovery.endpoint, + runtime_root, + &discovery.instance_identity, + )?; + let mut stream = endpoint.connect(timeout).await?; + let request_id = 1; + let frame = RuntimeIpcFrame::Initialize { + request_id, + request: InitializeRequest { + protocol_version: PROTOCOL_VERSION, + instance_identity: discovery.instance_identity.as_str().to_string(), + token: discovery.token.clone(), + client_id: client_id.to_string(), + client_version: client_version.to_string(), + }, + }; + timeout_io(timeout, write_frame(&mut stream, &frame)).await?; + let response = timeout_io(timeout, read_frame(&mut stream)).await?; + match response { + RuntimeIpcFrame::Initialized { + request_id: response_id, + result, + } if response_id == request_id + && result.protocol_version == PROTOCOL_VERSION + && result.instance_identity == discovery.instance_identity.as_str() + && result.capabilities.health => {} + RuntimeIpcFrame::Error { + request_id: Some(response_id), + error, + } if response_id == request_id => return Err(RuntimeIpcClientError::Remote(error)), + _ => return Err(RuntimeIpcClientError::UnexpectedResponse), + } + + Ok(Self { + stream, + instance_identity: discovery.instance_identity.as_str().to_string(), + request_timeout: timeout, + next_request_id: 2, + }) + } + + pub async fn health(&mut self) -> Result { + let request_id = self.next_request_id; + self.next_request_id = self + .next_request_id + .checked_add(1) + .ok_or(RuntimeIpcClientError::RequestIdExhausted)?; + timeout_io( + self.request_timeout, + write_frame( + &mut self.stream, + &RuntimeIpcFrame::Request { + request_id, + operation: RuntimeIpcOperation::Health, + }, + ), + ) + .await?; + let response = timeout_io(self.request_timeout, read_frame(&mut self.stream)).await?; + match response { + RuntimeIpcFrame::Response { + request_id: response_id, + result: + RuntimeIpcOperationResult::Health { + instance_identity, + process_id, + }, + } if response_id == request_id && instance_identity == self.instance_identity => { + Ok(HealthResult { + instance_identity, + process_id, + }) + } + RuntimeIpcFrame::Error { + request_id: Some(response_id), + error, + } if response_id == request_id => Err(RuntimeIpcClientError::Remote(error)), + _ => Err(RuntimeIpcClientError::UnexpectedResponse), + } + } +} + +async fn timeout_io( + timeout: Duration, + future: impl std::future::Future>, +) -> Result { + tokio::time::timeout(timeout, future) + .await + .map_err(|_| RuntimeIpcClientError::Timeout)? + .map_err(RuntimeIpcClientError::Io) +} + +fn validate_client_fact(value: &str) -> Result<(), RuntimeIpcClientError> { + if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) { + return Err(RuntimeIpcClientError::InvalidClientIdentity); + } + Ok(()) +} + +#[derive(Debug, thiserror::Error)] +pub enum RuntimeIpcClientError { + #[error("runtime IPC protocol mismatch: expected {expected}, observed {observed}")] + IncompatibleProtocol { expected: u32, observed: u32 }, + #[error("runtime IPC client identity is invalid")] + InvalidClientIdentity, + #[error("runtime IPC request timed out")] + Timeout, + #[error("runtime IPC returned an unexpected response")] + UnexpectedResponse, + #[error("runtime IPC request identifiers are exhausted")] + RequestIdExhausted, + #[error("runtime IPC request was rejected: {0:?}")] + Remote(RuntimeIpcError), + #[error(transparent)] + Transport(#[from] RuntimeIpcTransportError), + #[error(transparent)] + Io(#[from] RuntimeIpcIoError), +} diff --git a/src/crates/adapters/agent-runtime-ipc/src/discovery.rs b/src/crates/adapters/agent-runtime-ipc/src/discovery.rs new file mode 100644 index 0000000000..8046b6ba9a --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/discovery.rs @@ -0,0 +1,525 @@ +use fs2::FileExt; +use serde::{de::Error as _, Deserialize, Deserializer, Serialize, Serializer}; +use sha2::{Digest, Sha256}; +use std::fmt::{self, Write as _}; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use crate::PROTOCOL_VERSION; + +const MAX_DISCOVERY_BYTES: u64 = 16 * 1024; +const DISCOVERY_REPLACE_ATTEMPTS: usize = 6; +const DISCOVERY_READ_ATTEMPTS: usize = 4; + +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct RuntimeInstanceIdentity(String); + +impl RuntimeInstanceIdentity { + pub fn for_workspace( + workspace_root: &Path, + product_identity: &str, + release_channel: &str, + user_identity: &str, + protocol_version: u32, + ) -> Result { + validate_identity_part(product_identity)?; + validate_identity_part(release_channel)?; + validate_identity_part(user_identity)?; + let canonical_workspace = dunce::canonicalize(workspace_root).map_err(|source| { + RuntimeIpcDiscoveryError::CanonicalizeWorkspace { + path: workspace_root.to_path_buf(), + source, + } + })?; + + let mut hasher = Sha256::new(); + hasher.update(b"bitfun-agent-runtime-instance-v2\0"); + for part in [product_identity, release_channel, user_identity] { + hasher.update(part.as_bytes()); + hasher.update(b"\0"); + } + hasher.update(protocol_version.to_le_bytes()); + hasher.update(b"\0"); + hash_canonical_path(&mut hasher, &canonical_workspace); + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + Ok(Self(encoded)) + } + + pub fn parse(value: impl Into) -> Result { + let value = value.into(); + if value.len() != 64 + || !value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(RuntimeIpcDiscoveryError::InvalidInstanceIdentity); + } + Ok(Self(value)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl fmt::Debug for RuntimeInstanceIdentity { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_tuple("RuntimeInstanceIdentity") + .field(&self.0) + .finish() + } +} + +impl Serialize for RuntimeInstanceIdentity { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for RuntimeInstanceIdentity { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let value = String::deserialize(deserializer)?; + Self::parse(value).map_err(D::Error::custom) + } +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DiscoveryRecord { + pub protocol_version: u32, + pub instance_identity: RuntimeInstanceIdentity, + pub endpoint: String, + pub process_id: u32, + pub token: String, + pub owner_id: String, +} + +impl DiscoveryRecord { + pub fn new( + instance_identity: RuntimeInstanceIdentity, + endpoint: String, + process_id: u32, + token: String, + owner_id: String, + ) -> Self { + Self { + protocol_version: PROTOCOL_VERSION, + instance_identity, + endpoint, + process_id, + token, + owner_id, + } + } +} + +impl fmt::Debug for DiscoveryRecord { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("DiscoveryRecord") + .field("protocol_version", &self.protocol_version) + .field("instance_identity", &self.instance_identity) + .field("endpoint", &self.endpoint) + .field("process_id", &self.process_id) + .field("token", &"[REDACTED]") + .field("owner_id", &self.owner_id) + .finish() + } +} + +pub struct DiscoveryStore { + root: PathBuf, + identity: RuntimeInstanceIdentity, +} + +impl DiscoveryStore { + pub fn new(root: &Path, identity: RuntimeInstanceIdentity) -> Self { + Self { + root: root.to_path_buf(), + identity, + } + } + + pub fn identity(&self) -> &RuntimeInstanceIdentity { + &self.identity + } + + pub fn read(&self) -> Result, RuntimeIpcDiscoveryError> { + let path = self.record_path(); + let Some(mut file) = open_discovery_with_retry(&path)? else { + return Ok(None); + }; + let size = file + .metadata() + .map_err(|source| RuntimeIpcDiscoveryError::ReadDiscovery { + path: path.clone(), + source, + })? + .len(); + if size > MAX_DISCOVERY_BYTES { + return Err(RuntimeIpcDiscoveryError::DiscoveryTooLarge { size }); + } + let mut bytes = Vec::with_capacity(size as usize); + file.read_to_end(&mut bytes) + .map_err(|source| RuntimeIpcDiscoveryError::ReadDiscovery { + path: path.clone(), + source, + })?; + let record = serde_json::from_slice::(&bytes) + .map_err(RuntimeIpcDiscoveryError::InvalidDiscovery)?; + if record.instance_identity != self.identity { + return Err(RuntimeIpcDiscoveryError::WrongInstanceIdentity); + } + Ok(Some(record)) + } + + pub fn write(&self, record: &DiscoveryRecord) -> Result<(), RuntimeIpcDiscoveryError> { + if record.instance_identity != self.identity { + return Err(RuntimeIpcDiscoveryError::WrongInstanceIdentity); + } + ensure_private_directory(&self.root)?; + let bytes = + serde_json::to_vec(record).map_err(RuntimeIpcDiscoveryError::SerializeDiscovery)?; + if bytes.len() as u64 > MAX_DISCOVERY_BYTES { + return Err(RuntimeIpcDiscoveryError::DiscoveryTooLarge { + size: bytes.len() as u64, + }); + } + let path = self.record_path(); + let mut temporary = tempfile::NamedTempFile::new_in(&self.root).map_err(|source| { + RuntimeIpcDiscoveryError::WriteDiscovery { + path: path.clone(), + source, + } + })?; + temporary + .write_all(&bytes) + .and_then(|_| temporary.as_file().sync_all()) + .map_err(|source| RuntimeIpcDiscoveryError::WriteDiscovery { + path: path.clone(), + source, + })?; + let temporary_path = temporary.into_temp_path(); + replace_discovery_atomically(temporary_path.as_ref(), &path) + .map_err(|source| RuntimeIpcDiscoveryError::WriteDiscovery { path, source }) + } + + pub fn remove_if_owned( + &self, + expected: &DiscoveryRecord, + ) -> Result { + if self.read()?.as_ref() != Some(expected) { + return Ok(false); + } + let path = self.record_path(); + match std::fs::remove_file(&path) { + Ok(()) => Ok(true), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(source) => Err(RuntimeIpcDiscoveryError::RemoveDiscovery { path, source }), + } + } + + fn record_path(&self) -> PathBuf { + self.root.join(format!("{}.json", self.identity.as_str())) + } +} + +pub struct RuntimeInstanceLock { + file: File, +} + +impl RuntimeInstanceLock { + pub fn try_acquire( + root: &Path, + identity: &RuntimeInstanceIdentity, + ) -> Result { + ensure_private_directory(root)?; + let path = root.join(format!("{}.instance.lock", identity.as_str())); + let mut options = OpenOptions::new(); + options.create(true).truncate(false).read(true).write(true); + configure_private_file(&mut options); + let file = + options + .open(&path) + .map_err(|source| RuntimeIpcDiscoveryError::OpenInstanceLock { + path: path.clone(), + source, + })?; + FileExt::try_lock_exclusive(&file) + .map_err(|source| RuntimeIpcDiscoveryError::InstanceAlreadyOwned { source })?; + Ok(Self { file }) + } +} + +impl Drop for RuntimeInstanceLock { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.file); + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RuntimeIpcDiscoveryError { + #[error( + "runtime identity parts must be non-empty, bounded, and contain no control characters" + )] + InvalidIdentityPart, + #[error("runtime instance identity must be a lowercase SHA-256 digest")] + InvalidInstanceIdentity, + #[error("failed to canonicalize runtime workspace {path}")] + CanonicalizeWorkspace { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to create private runtime directory {path}")] + CreateRuntimeDirectory { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to read runtime discovery record {path}")] + ReadDiscovery { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to write runtime discovery record {path}")] + WriteDiscovery { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to remove runtime discovery record {path}")] + RemoveDiscovery { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("runtime discovery record exceeds {MAX_DISCOVERY_BYTES} bytes: {size}")] + DiscoveryTooLarge { size: u64 }, + #[error("runtime discovery record is invalid")] + InvalidDiscovery(#[source] serde_json::Error), + #[error("failed to serialize runtime discovery record")] + SerializeDiscovery(#[source] serde_json::Error), + #[error("runtime discovery record belongs to another instance")] + WrongInstanceIdentity, + #[error("failed to open runtime instance lock {path}")] + OpenInstanceLock { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("runtime instance is already owned")] + InstanceAlreadyOwned { + #[source] + source: std::io::Error, + }, +} + +fn validate_identity_part(value: &str) -> Result<(), RuntimeIpcDiscoveryError> { + if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) { + return Err(RuntimeIpcDiscoveryError::InvalidIdentityPart); + } + Ok(()) +} + +fn hash_canonical_path(hasher: &mut Sha256, path: &Path) { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + hasher.update(path.as_os_str().as_bytes()); + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + for unit in path.as_os_str().encode_wide() { + hasher.update(unit.to_le_bytes()); + } + } +} + +fn ensure_private_directory(path: &Path) -> Result<(), RuntimeIpcDiscoveryError> { + std::fs::create_dir_all(path).map_err(|source| { + RuntimeIpcDiscoveryError::CreateRuntimeDirectory { + path: path.to_path_buf(), + source, + } + })?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700)).map_err( + |source| RuntimeIpcDiscoveryError::CreateRuntimeDirectory { + path: path.to_path_buf(), + source, + }, + )?; + } + Ok(()) +} + +fn configure_private_file(options: &mut OpenOptions) { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + #[cfg(not(unix))] + let _ = options; +} + +fn open_discovery_for_read(path: &Path) -> std::io::Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + use windows::Win32::Storage::FileSystem::{ + FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + }; + options.share_mode((FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE).0); + } + options.open(path) +} + +fn open_discovery_with_retry(path: &Path) -> Result, RuntimeIpcDiscoveryError> { + let mut last_transient_error = None; + for attempt in 0..DISCOVERY_READ_ATTEMPTS { + match open_discovery_for_read(path) { + Ok(file) => return Ok(Some(file)), + Err(error) if is_transient_discovery_read_error(&error) => { + last_transient_error = Some(error); + if attempt + 1 < DISCOVERY_READ_ATTEMPTS { + std::thread::sleep(Duration::from_millis(1)); + } + } + Err(source) => { + return Err(RuntimeIpcDiscoveryError::ReadDiscovery { + path: path.to_path_buf(), + source, + }); + } + } + } + match last_transient_error { + Some(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Some(source) => Err(RuntimeIpcDiscoveryError::ReadDiscovery { + path: path.to_path_buf(), + source, + }), + None => unreachable!("a non-empty read retry loop records a terminal result"), + } +} + +fn is_transient_discovery_read_error(error: &std::io::Error) -> bool { + error.kind() == std::io::ErrorKind::NotFound + || cfg!(windows) && matches!(error.raw_os_error(), Some(32 | 33)) +} + +fn replace_discovery_atomically(temporary: &Path, target: &Path) -> std::io::Result<()> { + let mut last_error = None; + for attempt in 0..DISCOVERY_REPLACE_ATTEMPTS { + match replace_discovery_once(temporary, target) { + Ok(()) => { + sync_discovery_parent(target)?; + return Ok(()); + } + Err(error) if is_retryable_replace_error(&error) => { + last_error = Some(error); + if attempt + 1 < DISCOVERY_REPLACE_ATTEMPTS { + std::thread::sleep(Duration::from_millis(2 * (attempt as u64 + 1))); + } + } + Err(error) => return Err(error), + } + } + Err(last_error.expect("a bounded replace loop always records its final error")) +} + +#[cfg(windows)] +fn replace_discovery_once(temporary: &Path, target: &Path) -> std::io::Result<()> { + use std::os::windows::ffi::OsStrExt; + use windows::core::PCWSTR; + use windows::Win32::Storage::FileSystem::{ + MoveFileExW, ReplaceFileW, MOVEFILE_WRITE_THROUGH, REPLACEFILE_WRITE_THROUGH, + }; + + let target_exists = target.exists(); + let temporary = temporary + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + let target = target + .as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect::>(); + // SAFETY: both buffers are owned, NUL-terminated UTF-16 paths that remain + // alive for the call. The temporary file is created in the target's + // directory, so replacement cannot cross volumes. + unsafe { + if target_exists { + ReplaceFileW( + PCWSTR(target.as_ptr()), + PCWSTR(temporary.as_ptr()), + PCWSTR::null(), + REPLACEFILE_WRITE_THROUGH, + None, + None, + ) + } else { + MoveFileExW( + PCWSTR(temporary.as_ptr()), + PCWSTR(target.as_ptr()), + MOVEFILE_WRITE_THROUGH, + ) + } + } + .map_err(|error| std::io::Error::other(error.to_string())) +} + +#[cfg(not(windows))] +fn replace_discovery_once(temporary: &Path, target: &Path) -> std::io::Result<()> { + std::fs::rename(temporary, target) +} + +#[cfg(unix)] +fn sync_discovery_parent(target: &Path) -> std::io::Result<()> { + File::open( + target + .parent() + .ok_or_else(|| std::io::Error::other("discovery path has no parent"))?, + )? + .sync_all() +} + +#[cfg(not(unix))] +fn sync_discovery_parent(_target: &Path) -> std::io::Result<()> { + Ok(()) +} + +fn is_retryable_replace_error(error: &std::io::Error) -> bool { + matches!( + error.kind(), + std::io::ErrorKind::PermissionDenied + | std::io::ErrorKind::WouldBlock + | std::io::ErrorKind::Interrupted + | std::io::ErrorKind::TimedOut + | std::io::ErrorKind::AlreadyExists + | std::io::ErrorKind::Other + ) +} diff --git a/src/crates/adapters/agent-runtime-ipc/src/framing.rs b/src/crates/adapters/agent-runtime-ipc/src/framing.rs new file mode 100644 index 0000000000..07a9407012 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/framing.rs @@ -0,0 +1,54 @@ +use crate::RuntimeIpcFrame; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +pub const MAX_FRAME_BYTES: usize = 64 * 1024; + +pub async fn write_frame( + writer: &mut W, + frame: &RuntimeIpcFrame, +) -> Result<(), RuntimeIpcIoError> +where + W: AsyncWrite + Unpin, +{ + let bytes = serde_json::to_vec(frame).map_err(RuntimeIpcIoError::Serialize)?; + if bytes.len() > MAX_FRAME_BYTES { + return Err(RuntimeIpcIoError::FrameTooLarge { size: bytes.len() }); + } + writer + .write_u32(bytes.len() as u32) + .await + .map_err(RuntimeIpcIoError::Io)?; + writer + .write_all(&bytes) + .await + .map_err(RuntimeIpcIoError::Io)?; + writer.flush().await.map_err(RuntimeIpcIoError::Io) +} + +pub async fn read_frame(reader: &mut R) -> Result +where + R: AsyncRead + Unpin, +{ + let size = reader.read_u32().await.map_err(RuntimeIpcIoError::Io)? as usize; + if size > MAX_FRAME_BYTES { + return Err(RuntimeIpcIoError::FrameTooLarge { size }); + } + let mut bytes = vec![0; size]; + reader + .read_exact(&mut bytes) + .await + .map_err(RuntimeIpcIoError::Io)?; + serde_json::from_slice(&bytes).map_err(RuntimeIpcIoError::Deserialize) +} + +#[derive(Debug, thiserror::Error)] +pub enum RuntimeIpcIoError { + #[error("runtime IPC frame exceeds {MAX_FRAME_BYTES} bytes: {size}")] + FrameTooLarge { size: usize }, + #[error("runtime IPC transport failed")] + Io(#[source] std::io::Error), + #[error("failed to serialize runtime IPC frame")] + Serialize(#[source] serde_json::Error), + #[error("runtime IPC frame is invalid")] + Deserialize(#[source] serde_json::Error), +} diff --git a/src/crates/adapters/agent-runtime-ipc/src/ipc.rs b/src/crates/adapters/agent-runtime-ipc/src/ipc.rs new file mode 100644 index 0000000000..047543bed0 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/ipc.rs @@ -0,0 +1,312 @@ +use crate::RuntimeInstanceIdentity; +use std::path::Path; +#[cfg(unix)] +use std::path::PathBuf; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; +#[cfg(windows)] +use std::time::Instant; +use tokio::io::{AsyncRead, AsyncWrite, ReadBuf}; + +#[cfg(unix)] +const MAX_PORTABLE_UDS_PATH_BYTES: usize = 103; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalIpcEndpoint { + discovery_value: String, + #[cfg(unix)] + path: PathBuf, +} + +impl LocalIpcEndpoint { + pub(crate) fn for_instance( + runtime_root: &Path, + identity: &RuntimeInstanceIdentity, + ) -> Result { + let suffix = &identity.as_str()[..16]; + #[cfg(windows)] + { + let _ = runtime_root; + Ok(Self { + discovery_value: format!(r"\\.\pipe\bitfun-agent-runtime-{suffix}"), + }) + } + #[cfg(unix)] + { + let root = dunce::canonicalize(runtime_root) + .map_err(RuntimeIpcTransportError::CanonicalizeRuntimeRoot)?; + let discovery_value = format!("bf-ar-{suffix}.sock"); + let path = root.join(&discovery_value); + validate_uds_path_length(&path)?; + Ok(Self { + discovery_value, + path, + }) + } + } + + pub(crate) fn parse_for_root( + value: &str, + runtime_root: &Path, + identity: &RuntimeInstanceIdentity, + ) -> Result { + let expected = Self::for_instance(runtime_root, identity)?; + if value != expected.discovery_value { + return Err(RuntimeIpcTransportError::InvalidEndpoint); + } + Ok(expected) + } + + pub fn discovery_value(&self) -> &str { + &self.discovery_value + } + + #[cfg(unix)] + pub fn as_path(&self) -> &Path { + &self.path + } + + #[cfg(windows)] + fn as_pipe_name(&self) -> &str { + &self.discovery_value + } + + pub async fn connect( + &self, + deadline: Duration, + ) -> Result { + if deadline.is_zero() { + return Err(RuntimeIpcTransportError::InvalidDeadline); + } + #[cfg(windows)] + { + use tokio::net::windows::named_pipe::ClientOptions; + let expires_at = Instant::now() + deadline; + loop { + match ClientOptions::new().open(self.as_pipe_name()) { + Ok(client) => return Ok(LocalIpcStream::WindowsClient(client)), + Err(error) if Instant::now() < expires_at => { + let remaining = expires_at.saturating_duration_since(Instant::now()); + tokio::time::sleep(remaining.min(Duration::from_millis(10))).await; + if error.kind() != std::io::ErrorKind::NotFound + && error.raw_os_error() != Some(231) + { + return Err(RuntimeIpcTransportError::Connect(error)); + } + } + Err(error) => return Err(RuntimeIpcTransportError::Connect(error)), + } + } + } + #[cfg(unix)] + { + let stream = + tokio::time::timeout(deadline, tokio::net::UnixStream::connect(self.as_path())) + .await + .map_err(|_| RuntimeIpcTransportError::ConnectTimeout)? + .map_err(RuntimeIpcTransportError::Connect)?; + Ok(LocalIpcStream::Unix(stream)) + } + } +} + +#[cfg(unix)] +fn validate_uds_path_length(path: &Path) -> Result<(), RuntimeIpcTransportError> { + use std::os::unix::ffi::OsStrExt; + let observed = path.as_os_str().as_bytes().len(); + if observed > MAX_PORTABLE_UDS_PATH_BYTES { + return Err(RuntimeIpcTransportError::EndpointTooLong { + observed, + maximum: MAX_PORTABLE_UDS_PATH_BYTES, + }); + } + Ok(()) +} + +pub struct LocalIpcListener { + endpoint: LocalIpcEndpoint, + #[cfg(windows)] + server: Option, + #[cfg(unix)] + listener: tokio::net::UnixListener, +} + +impl LocalIpcListener { + pub(crate) async fn bind(endpoint: LocalIpcEndpoint) -> Result { + #[cfg(windows)] + { + let server = create_windows_server(&endpoint, true)?; + Ok(Self { + endpoint, + server: Some(server), + }) + } + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let path = endpoint.as_path(); + remove_stale_unix_socket(path)?; + let listener = + tokio::net::UnixListener::bind(path).map_err(RuntimeIpcTransportError::Bind)?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(RuntimeIpcTransportError::Bind)?; + Ok(Self { endpoint, listener }) + } + } + + pub(crate) async fn accept(&mut self) -> Result { + #[cfg(windows)] + { + self.server + .as_mut() + .expect("Windows listener always owns an accept instance") + .connect() + .await + .map_err(RuntimeIpcTransportError::Accept)?; + let server = self + .server + .take() + .expect("connected Windows listener instance must remain owned"); + self.server = Some(create_windows_server(&self.endpoint, false)?); + Ok(LocalIpcStream::WindowsServer(server)) + } + #[cfg(unix)] + { + let (stream, _) = self + .listener + .accept() + .await + .map_err(RuntimeIpcTransportError::Accept)?; + Ok(LocalIpcStream::Unix(stream)) + } + } +} + +#[cfg(windows)] +fn create_windows_server( + endpoint: &LocalIpcEndpoint, + first: bool, +) -> Result { + use tokio::net::windows::named_pipe::ServerOptions; + ServerOptions::new() + .first_pipe_instance(first) + .reject_remote_clients(true) + .create(endpoint.as_pipe_name()) + .map_err(RuntimeIpcTransportError::Bind) +} + +#[cfg(unix)] +fn remove_stale_unix_socket(path: &Path) -> Result<(), RuntimeIpcTransportError> { + use std::os::unix::fs::FileTypeExt; + match std::fs::symlink_metadata(path) { + Ok(metadata) if metadata.file_type().is_socket() => { + std::fs::remove_file(path).map_err(RuntimeIpcTransportError::Bind) + } + Ok(_) => Err(RuntimeIpcTransportError::EndpointOccupied), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(RuntimeIpcTransportError::Bind(error)), + } +} + +impl Drop for LocalIpcListener { + fn drop(&mut self) { + #[cfg(unix)] + { + let _ = remove_stale_unix_socket(self.endpoint.as_path()); + } + } +} + +pub enum LocalIpcStream { + #[cfg(windows)] + WindowsClient(tokio::net::windows::named_pipe::NamedPipeClient), + #[cfg(windows)] + WindowsServer(tokio::net::windows::named_pipe::NamedPipeServer), + #[cfg(unix)] + Unix(tokio::net::UnixStream), +} + +impl AsyncRead for LocalIpcStream { + fn poll_read( + self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &mut ReadBuf<'_>, + ) -> Poll> { + match self.get_mut() { + #[cfg(windows)] + Self::WindowsClient(stream) => Pin::new(stream).poll_read(context, buffer), + #[cfg(windows)] + Self::WindowsServer(stream) => Pin::new(stream).poll_read(context, buffer), + #[cfg(unix)] + Self::Unix(stream) => Pin::new(stream).poll_read(context, buffer), + } + } +} + +impl AsyncWrite for LocalIpcStream { + fn poll_write( + self: Pin<&mut Self>, + context: &mut Context<'_>, + buffer: &[u8], + ) -> Poll> { + match self.get_mut() { + #[cfg(windows)] + Self::WindowsClient(stream) => Pin::new(stream).poll_write(context, buffer), + #[cfg(windows)] + Self::WindowsServer(stream) => Pin::new(stream).poll_write(context, buffer), + #[cfg(unix)] + Self::Unix(stream) => Pin::new(stream).poll_write(context, buffer), + } + } + + fn poll_flush( + self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + match self.get_mut() { + #[cfg(windows)] + Self::WindowsClient(stream) => Pin::new(stream).poll_flush(context), + #[cfg(windows)] + Self::WindowsServer(stream) => Pin::new(stream).poll_flush(context), + #[cfg(unix)] + Self::Unix(stream) => Pin::new(stream).poll_flush(context), + } + } + + fn poll_shutdown( + self: Pin<&mut Self>, + context: &mut Context<'_>, + ) -> Poll> { + match self.get_mut() { + #[cfg(windows)] + Self::WindowsClient(stream) => Pin::new(stream).poll_shutdown(context), + #[cfg(windows)] + Self::WindowsServer(stream) => Pin::new(stream).poll_shutdown(context), + #[cfg(unix)] + Self::Unix(stream) => Pin::new(stream).poll_shutdown(context), + } + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RuntimeIpcTransportError { + #[error("runtime IPC endpoint is invalid")] + InvalidEndpoint, + #[error("runtime IPC endpoint path is too long: {observed} bytes exceeds {maximum}")] + EndpointTooLong { observed: usize, maximum: usize }, + #[error("runtime IPC endpoint path is occupied by a non-socket entry")] + EndpointOccupied, + #[error("runtime IPC deadline must be greater than zero")] + InvalidDeadline, + #[error("failed to canonicalize runtime IPC directory")] + CanonicalizeRuntimeRoot(#[source] std::io::Error), + #[error("failed to bind runtime IPC endpoint")] + Bind(#[source] std::io::Error), + #[error("failed to accept runtime IPC connection")] + Accept(#[source] std::io::Error), + #[error("failed to connect to runtime IPC endpoint")] + Connect(#[source] std::io::Error), + #[error("timed out connecting to runtime IPC endpoint")] + ConnectTimeout, +} diff --git a/src/crates/adapters/agent-runtime-ipc/src/lib.rs b/src/crates/adapters/agent-runtime-ipc/src/lib.rs new file mode 100644 index 0000000000..72a61c5f74 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/lib.rs @@ -0,0 +1,45 @@ +//! Private local IPC foundation for a future shared BitFun Agent Runtime. +//! +//! The crate implements only discovery, authentication, protocol framing, and +//! Health. All items remain crate-internal until a reviewed first-party adapter +//! becomes a production consumer. This is not a public SDK or Runtime owner. + +#![allow(dead_code, unreachable_pub)] + +mod client; +mod discovery; +mod framing; +mod ipc; +mod operation; +mod protocol; +mod server; + +#[cfg(test)] +pub(crate) use client::{RuntimeIpcClient, RuntimeIpcClientError}; +pub(crate) use discovery::{ + DiscoveryRecord, DiscoveryStore, RuntimeInstanceIdentity, RuntimeInstanceLock, + RuntimeIpcDiscoveryError, +}; +#[cfg(test)] +pub(crate) use framing::MAX_FRAME_BYTES; +pub(crate) use framing::{read_frame, write_frame, RuntimeIpcIoError}; +pub(crate) use ipc::{ + LocalIpcEndpoint, LocalIpcListener, LocalIpcStream, RuntimeIpcTransportError, +}; +pub(crate) use operation::{RuntimeIpcOperation, RuntimeIpcOperationResult}; +pub(crate) use protocol::{ + HealthResult, InitializeRequest, InitializeResult, RuntimeIpcCapabilities, RuntimeIpcError, + RuntimeIpcErrorCode, RuntimeIpcFrame, PROTOCOL_VERSION, +}; +#[cfg(test)] +pub(crate) use server::{RuntimeIpcServer, RuntimeIpcServerConfig}; + +#[cfg(test)] +#[path = "tests/discovery_and_framing.rs"] +mod discovery_and_framing_tests; +#[cfg(test)] +#[path = "tests/local_health.rs"] +mod local_health_tests; +#[cfg(test)] +#[path = "tests/protocol_contracts.rs"] +mod protocol_contract_tests; diff --git a/src/crates/adapters/agent-runtime-ipc/src/operation.rs b/src/crates/adapters/agent-runtime-ipc/src/operation.rs new file mode 100644 index 0000000000..a5927e9e98 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/operation.rs @@ -0,0 +1,16 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "operation", rename_all = "snake_case", deny_unknown_fields)] +pub enum RuntimeIpcOperation { + Health, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "result", rename_all = "snake_case", deny_unknown_fields)] +pub enum RuntimeIpcOperationResult { + Health { + instance_identity: String, + process_id: u32, + }, +} diff --git a/src/crates/adapters/agent-runtime-ipc/src/protocol.rs b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs new file mode 100644 index 0000000000..754461859f --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/protocol.rs @@ -0,0 +1,94 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +use crate::{RuntimeIpcOperation, RuntimeIpcOperationResult}; + +pub const PROTOCOL_VERSION: u32 = 1; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] +pub enum RuntimeIpcFrame { + Initialize { + request_id: u64, + request: InitializeRequest, + }, + Initialized { + request_id: u64, + result: InitializeResult, + }, + Request { + request_id: u64, + operation: RuntimeIpcOperation, + }, + Response { + request_id: u64, + result: RuntimeIpcOperationResult, + }, + Error { + request_id: Option, + error: RuntimeIpcError, + }, +} + +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InitializeRequest { + pub protocol_version: u32, + pub instance_identity: String, + pub token: String, + pub client_id: String, + pub client_version: String, +} + +impl fmt::Debug for InitializeRequest { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("InitializeRequest") + .field("protocol_version", &self.protocol_version) + .field("instance_identity", &self.instance_identity) + .field("token", &"[REDACTED]") + .field("client_id", &self.client_id) + .field("client_version", &self.client_version) + .finish() + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct InitializeResult { + pub protocol_version: u32, + pub instance_identity: String, + pub server_version: String, + pub capabilities: RuntimeIpcCapabilities, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimeIpcCapabilities { + pub health: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HealthResult { + pub instance_identity: String, + pub process_id: u32, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RuntimeIpcErrorCode { + InvalidRequest, + Unauthorized, + IncompatibleProtocol, + WrongInstance, + FrameTooLarge, + Unavailable, + Internal, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RuntimeIpcError { + pub code: RuntimeIpcErrorCode, + pub message: String, +} diff --git a/src/crates/adapters/agent-runtime-ipc/src/server.rs b/src/crates/adapters/agent-runtime-ipc/src/server.rs new file mode 100644 index 0000000000..dcca12f7d4 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/server.rs @@ -0,0 +1,387 @@ +use crate::{ + read_frame, write_frame, DiscoveryRecord, DiscoveryStore, InitializeResult, LocalIpcEndpoint, + LocalIpcListener, LocalIpcStream, RuntimeInstanceIdentity, RuntimeInstanceLock, + RuntimeIpcCapabilities, RuntimeIpcDiscoveryError, RuntimeIpcError, RuntimeIpcErrorCode, + RuntimeIpcFrame, RuntimeIpcIoError, RuntimeIpcOperation, RuntimeIpcOperationResult, + RuntimeIpcTransportError, PROTOCOL_VERSION, +}; +use std::path::Path; +use std::sync::Arc; +use std::time::Duration; +use tokio::task::JoinSet; +use uuid::Uuid; + +const MAX_CONNECTION_LIMIT: usize = 1024; + +#[derive(Debug, Clone)] +pub struct RuntimeIpcServerConfig { + pub server_version: String, + pub idle_timeout: Duration, + pub io_timeout: Duration, + pub max_connections: usize, +} + +pub struct RuntimeIpcServer { + listener: LocalIpcListener, + endpoint: LocalIpcEndpoint, + discovery_store: DiscoveryStore, + discovery_record: DiscoveryRecord, + _instance_lock: RuntimeInstanceLock, + connection: Arc, + idle_timeout: Duration, + max_connections: usize, +} + +impl RuntimeIpcServer { + pub async fn bind( + runtime_root: &Path, + identity: RuntimeInstanceIdentity, + config: RuntimeIpcServerConfig, + ) -> Result { + validate_server_config(&config)?; + let instance_lock = RuntimeInstanceLock::try_acquire(runtime_root, &identity)?; + let owner_id = Uuid::new_v4().simple().to_string(); + let token = format!("{}{}", Uuid::new_v4().simple(), Uuid::new_v4().simple()); + let endpoint = LocalIpcEndpoint::for_instance(runtime_root, &identity)?; + let listener = LocalIpcListener::bind(endpoint.clone()).await?; + let discovery_store = DiscoveryStore::new(runtime_root, identity.clone()); + let discovery_record = DiscoveryRecord::new( + identity.clone(), + endpoint.discovery_value().to_string(), + std::process::id(), + token.clone(), + owner_id, + ); + discovery_store.write(&discovery_record)?; + + Ok(Self { + listener, + endpoint, + discovery_store, + discovery_record, + _instance_lock: instance_lock, + connection: Arc::new(ConnectionConfig { + instance_identity: identity.as_str().to_string(), + token, + server_version: config.server_version, + io_timeout: config.io_timeout, + }), + idle_timeout: config.idle_timeout, + max_connections: config.max_connections, + }) + } + + pub fn discovery_record(&self) -> &DiscoveryRecord { + &self.discovery_record + } + + pub fn endpoint(&self) -> &LocalIpcEndpoint { + &self.endpoint + } + + pub async fn serve(mut self) -> Result<(), RuntimeIpcServerError> { + let result = self.serve_until_idle().await; + let cleanup = self + .discovery_store + .remove_if_owned(&self.discovery_record) + .map(|_| ()) + .map_err(RuntimeIpcServerError::Discovery); + result.and(cleanup) + } + + async fn serve_until_idle(&mut self) -> Result<(), RuntimeIpcServerError> { + let mut clients = JoinSet::new(); + loop { + if clients.is_empty() { + tokio::select! { + accepted = self.listener.accept() => { + let stream = accepted?; + spawn_connection(&mut clients, stream, self.connection.clone()); + } + _ = tokio::time::sleep(self.idle_timeout) => break, + } + } else if clients.len() >= self.max_connections { + observe_connection(clients.join_next().await)?; + } else { + tokio::select! { + accepted = self.listener.accept() => { + let stream = accepted?; + spawn_connection(&mut clients, stream, self.connection.clone()); + } + completed = clients.join_next() => { + observe_connection(completed)?; + } + } + } + } + Ok(()) + } +} + +impl Drop for RuntimeIpcServer { + fn drop(&mut self) { + let _ = self.discovery_store.remove_if_owned(&self.discovery_record); + } +} + +fn spawn_connection( + clients: &mut JoinSet>, + stream: LocalIpcStream, + config: Arc, +) { + clients.spawn(async move { handle_connection(stream, &config).await }); +} + +fn observe_connection( + completed: Option, tokio::task::JoinError>>, +) -> Result<(), RuntimeIpcServerError> { + match completed { + Some(Ok(Ok(()))) | Some(Ok(Err(_))) => Ok(()), + Some(Err(error)) => Err(RuntimeIpcServerError::ConnectionTask(error)), + None => Ok(()), + } +} + +struct ConnectionConfig { + instance_identity: String, + token: String, + server_version: String, + io_timeout: Duration, +} + +async fn handle_connection( + mut stream: LocalIpcStream, + config: &ConnectionConfig, +) -> Result<(), RuntimeIpcServerError> { + let first = match timeout_read(config.io_timeout, &mut stream).await { + Ok(frame) => frame, + Err(RuntimeIpcServerError::Disconnected) => return Ok(()), + Err(error) => return Err(error), + }; + let (request_id, request) = match first { + RuntimeIpcFrame::Initialize { + request_id, + request, + } => (request_id, request), + frame => { + send_error( + &mut stream, + config.io_timeout, + request_id_of(&frame), + RuntimeIpcErrorCode::InvalidRequest, + "initialize must be the first frame", + ) + .await?; + return Ok(()); + } + }; + + if !constant_time_eq(request.token.as_bytes(), config.token.as_bytes()) { + send_error( + &mut stream, + config.io_timeout, + Some(request_id), + RuntimeIpcErrorCode::Unauthorized, + "runtime IPC authentication failed", + ) + .await?; + return Ok(()); + } + if request.protocol_version != PROTOCOL_VERSION { + send_error( + &mut stream, + config.io_timeout, + Some(request_id), + RuntimeIpcErrorCode::IncompatibleProtocol, + "runtime IPC protocol version is incompatible", + ) + .await?; + return Ok(()); + } + if request.instance_identity != config.instance_identity { + send_error( + &mut stream, + config.io_timeout, + Some(request_id), + RuntimeIpcErrorCode::WrongInstance, + "runtime IPC endpoint belongs to another instance", + ) + .await?; + return Ok(()); + } + if !valid_client_fact(&request.client_id) || !valid_client_fact(&request.client_version) { + send_error( + &mut stream, + config.io_timeout, + Some(request_id), + RuntimeIpcErrorCode::InvalidRequest, + "runtime IPC client identity is invalid", + ) + .await?; + return Ok(()); + } + + timeout_write( + config.io_timeout, + &mut stream, + &RuntimeIpcFrame::Initialized { + request_id, + result: InitializeResult { + protocol_version: PROTOCOL_VERSION, + instance_identity: config.instance_identity.clone(), + server_version: config.server_version.clone(), + capabilities: RuntimeIpcCapabilities { health: true }, + }, + }, + ) + .await?; + + loop { + let frame = match timeout_read(config.io_timeout, &mut stream).await { + Ok(frame) => frame, + Err(RuntimeIpcServerError::Disconnected) => return Ok(()), + Err(error) => return Err(error), + }; + match frame { + RuntimeIpcFrame::Request { + request_id, + operation: RuntimeIpcOperation::Health, + } => { + timeout_write( + config.io_timeout, + &mut stream, + &RuntimeIpcFrame::Response { + request_id, + result: RuntimeIpcOperationResult::Health { + instance_identity: config.instance_identity.clone(), + process_id: std::process::id(), + }, + }, + ) + .await?; + } + frame => { + send_error( + &mut stream, + config.io_timeout, + request_id_of(&frame), + RuntimeIpcErrorCode::InvalidRequest, + "runtime IPC frame is not valid after initialization", + ) + .await?; + return Ok(()); + } + } + } +} + +async fn timeout_read( + timeout: Duration, + stream: &mut LocalIpcStream, +) -> Result { + match tokio::time::timeout(timeout, read_frame(stream)).await { + Err(_) => Err(RuntimeIpcServerError::IoTimeout), + Ok(Err(RuntimeIpcIoError::Io(error))) + if matches!( + error.kind(), + std::io::ErrorKind::UnexpectedEof + | std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionReset + ) => + { + Err(RuntimeIpcServerError::Disconnected) + } + Ok(Err(error)) => Err(RuntimeIpcServerError::Io(error)), + Ok(Ok(frame)) => Ok(frame), + } +} + +async fn timeout_write( + timeout: Duration, + stream: &mut LocalIpcStream, + frame: &RuntimeIpcFrame, +) -> Result<(), RuntimeIpcServerError> { + tokio::time::timeout(timeout, write_frame(stream, frame)) + .await + .map_err(|_| RuntimeIpcServerError::IoTimeout)? + .map_err(RuntimeIpcServerError::Io) +} + +async fn send_error( + stream: &mut LocalIpcStream, + timeout: Duration, + request_id: Option, + code: RuntimeIpcErrorCode, + message: &str, +) -> Result<(), RuntimeIpcServerError> { + timeout_write( + timeout, + stream, + &RuntimeIpcFrame::Error { + request_id, + error: RuntimeIpcError { + code, + message: message.to_string(), + }, + }, + ) + .await +} + +fn request_id_of(frame: &RuntimeIpcFrame) -> Option { + match frame { + RuntimeIpcFrame::Initialize { request_id, .. } + | RuntimeIpcFrame::Initialized { request_id, .. } + | RuntimeIpcFrame::Request { request_id, .. } + | RuntimeIpcFrame::Response { request_id, .. } => Some(*request_id), + RuntimeIpcFrame::Error { request_id, .. } => *request_id, + } +} + +fn valid_client_fact(value: &str) -> bool { + !value.is_empty() && value.len() <= 128 && !value.chars().any(char::is_control) +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + let length = left.len().max(right.len()); + let mut difference = left.len() ^ right.len(); + for index in 0..length { + difference |= usize::from( + left.get(index).copied().unwrap_or_default() + ^ right.get(index).copied().unwrap_or_default(), + ); + } + difference == 0 +} + +fn validate_server_config(config: &RuntimeIpcServerConfig) -> Result<(), RuntimeIpcServerError> { + if config.server_version.is_empty() + || config.server_version.len() > 128 + || config.server_version.chars().any(char::is_control) + || config.idle_timeout.is_zero() + || config.io_timeout.is_zero() + || config.max_connections == 0 + || config.max_connections > MAX_CONNECTION_LIMIT + { + return Err(RuntimeIpcServerError::InvalidConfig); + } + Ok(()) +} + +#[derive(Debug, thiserror::Error)] +pub enum RuntimeIpcServerError { + #[error("runtime IPC server configuration is invalid")] + InvalidConfig, + #[error("runtime IPC connection timed out")] + IoTimeout, + #[error("runtime IPC client disconnected")] + Disconnected, + #[error("runtime IPC connection task failed")] + ConnectionTask(#[source] tokio::task::JoinError), + #[error(transparent)] + Discovery(#[from] RuntimeIpcDiscoveryError), + #[error(transparent)] + Transport(#[from] RuntimeIpcTransportError), + #[error(transparent)] + Io(#[from] RuntimeIpcIoError), +} diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/discovery_and_framing.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/discovery_and_framing.rs new file mode 100644 index 0000000000..2c47c6ad01 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/discovery_and_framing.rs @@ -0,0 +1,233 @@ +use crate::{ + read_frame, write_frame, DiscoveryRecord, DiscoveryStore, RuntimeInstanceIdentity, + RuntimeInstanceLock, RuntimeIpcFrame, RuntimeIpcIoError, RuntimeIpcOperation, MAX_FRAME_BYTES, + PROTOCOL_VERSION, +}; +use tempfile::tempdir; +use tokio::io::AsyncWriteExt; + +#[test] +fn discovery_replacement_never_exposes_partial_json() { + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::Arc; + + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = RuntimeInstanceIdentity::for_workspace( + workspace.path(), + "bitfun", + "stable", + "user-a", + PROTOCOL_VERSION, + ) + .expect("identity"); + let store = DiscoveryStore::new(runtime_root.path(), identity.clone()); + let record = |owner: usize| { + DiscoveryRecord::new( + identity.clone(), + format!("endpoint-{owner}-{}", "x".repeat(8 * 1024)), + 42, + format!("token-{owner}"), + format!("owner-{owner}"), + ) + }; + store.write(&record(0)).expect("initial discovery"); + + let running = Arc::new(AtomicBool::new(true)); + let reader_running = running.clone(); + let reader_root = runtime_root.path().to_path_buf(); + let reader_identity = identity.clone(); + let reader = std::thread::spawn(move || { + let reader_store = DiscoveryStore::new(&reader_root, reader_identity); + while reader_running.load(Ordering::Acquire) { + assert!(reader_store + .read() + .expect("discovery remains valid") + .is_some()); + } + }); + + for owner in 1..=500 { + store.write(&record(owner)).expect("replace discovery"); + } + running.store(false, Ordering::Release); + reader.join().expect("reader remains healthy"); + assert_eq!(store.read().expect("final discovery"), Some(record(500))); +} + +#[cfg(windows)] +#[test] +fn sharing_violation_is_never_reported_as_missing_discovery() { + use std::os::windows::fs::OpenOptionsExt; + + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = RuntimeInstanceIdentity::for_workspace( + workspace.path(), + "bitfun", + "stable", + "user-a", + PROTOCOL_VERSION, + ) + .expect("identity"); + let store = DiscoveryStore::new(runtime_root.path(), identity.clone()); + let record = DiscoveryRecord::new( + identity.clone(), + "local-endpoint".to_string(), + 42, + "secret-token".to_string(), + "owner".to_string(), + ); + store.write(&record).expect("write discovery"); + let path = runtime_root + .path() + .join(format!("{}.json", identity.as_str())); + let _exclusive_reader = std::fs::OpenOptions::new() + .read(true) + .share_mode(0) + .open(path) + .expect("hold discovery without sharing"); + + assert!(matches!( + store.read(), + Err(crate::RuntimeIpcDiscoveryError::ReadDiscovery { source, .. }) + if matches!(source.raw_os_error(), Some(32 | 33)) + )); +} + +#[test] +fn instance_identity_is_stable_and_scoped_to_runtime_facts() { + let first_workspace = tempdir().expect("first workspace"); + let second_workspace = tempdir().expect("second workspace"); + + let first = RuntimeInstanceIdentity::for_workspace( + first_workspace.path(), + "bitfun", + "stable", + "user-a", + PROTOCOL_VERSION, + ) + .expect("first identity"); + let same = RuntimeInstanceIdentity::for_workspace( + &first_workspace.path().join("."), + "bitfun", + "stable", + "user-a", + PROTOCOL_VERSION, + ) + .expect("same identity"); + let other_workspace = RuntimeInstanceIdentity::for_workspace( + second_workspace.path(), + "bitfun", + "stable", + "user-a", + PROTOCOL_VERSION, + ) + .expect("other workspace identity"); + let other_user = RuntimeInstanceIdentity::for_workspace( + first_workspace.path(), + "bitfun", + "stable", + "user-b", + PROTOCOL_VERSION, + ) + .expect("other user identity"); + + assert_eq!(first, same); + assert_ne!(first, other_workspace); + assert_ne!(first, other_user); + assert_eq!(first.as_str().len(), 64); +} + +#[cfg(unix)] +#[test] +fn instance_identity_preserves_non_utf8_workspace_bytes() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let root = tempdir().expect("workspace root"); + let first_path = root.path().join(OsString::from_vec(vec![b'w', 0x80])); + let second_path = root.path().join(OsString::from_vec(vec![b'w', 0x81])); + std::fs::create_dir(&first_path).expect("first non-UTF-8 workspace"); + std::fs::create_dir(&second_path).expect("second non-UTF-8 workspace"); + + let identity = |path| { + RuntimeInstanceIdentity::for_workspace(path, "bitfun", "stable", "user-a", PROTOCOL_VERSION) + .expect("workspace identity") + }; + + assert_ne!(identity(&first_path), identity(&second_path)); +} + +#[test] +fn discovery_is_owner_checked_and_instance_lock_is_exclusive() { + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = RuntimeInstanceIdentity::for_workspace( + workspace.path(), + "bitfun", + "stable", + "user-a", + PROTOCOL_VERSION, + ) + .expect("identity"); + let store = DiscoveryStore::new(runtime_root.path(), identity.clone()); + let record = DiscoveryRecord::new( + identity.clone(), + "local-endpoint".to_string(), + 42, + "secret-token".to_string(), + "owner-one".to_string(), + ); + + let instance_lock = RuntimeInstanceLock::try_acquire(runtime_root.path(), &identity) + .expect("first instance lock"); + assert!(RuntimeInstanceLock::try_acquire(runtime_root.path(), &identity).is_err()); + + store.write(&record).expect("write discovery"); + assert_eq!(store.read().expect("read discovery"), Some(record.clone())); + assert!(!format!("{record:?}").contains("secret-token")); + + let another_owner = DiscoveryRecord::new( + identity, + "local-endpoint".to_string(), + 42, + "other-token".to_string(), + "owner-two".to_string(), + ); + assert!(!store + .remove_if_owned(&another_owner) + .expect("do not remove another owner's record")); + assert!(store.remove_if_owned(&record).expect("remove owned record")); + assert_eq!(store.read().expect("read removed discovery"), None); + + drop(instance_lock); + RuntimeInstanceLock::try_acquire(runtime_root.path(), store.identity()) + .expect("lock released by RAII drop"); +} + +#[tokio::test] +async fn framing_round_trips_health_and_rejects_oversized_lengths() { + let (mut writer, mut reader) = tokio::io::duplex(MAX_FRAME_BYTES + 16); + let expected = RuntimeIpcFrame::Request { + request_id: 9, + operation: RuntimeIpcOperation::Health, + }; + write_frame(&mut writer, &expected) + .await + .expect("write bounded frame"); + assert_eq!( + read_frame(&mut reader).await.expect("read bounded frame"), + expected + ); + + let (mut writer, mut reader) = tokio::io::duplex(8); + writer + .write_u32((MAX_FRAME_BYTES + 1) as u32) + .await + .expect("write oversized length prefix"); + let error = read_frame(&mut reader) + .await + .expect_err("reject oversized frame"); + assert!(matches!(error, RuntimeIpcIoError::FrameTooLarge { .. })); +} diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs new file mode 100644 index 0000000000..6cae625bc6 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/local_health.rs @@ -0,0 +1,425 @@ +use crate::{ + read_frame, write_frame, DiscoveryStore, InitializeRequest, RuntimeInstanceIdentity, + RuntimeIpcClient, RuntimeIpcClientError, RuntimeIpcErrorCode, RuntimeIpcFrame, + RuntimeIpcOperation, RuntimeIpcServer, RuntimeIpcServerConfig, RuntimeIpcTransportError, + MAX_FRAME_BYTES, PROTOCOL_VERSION, +}; +use std::time::Duration; +use tempfile::tempdir; +use tokio::io::AsyncWriteExt; + +fn runtime_identity(workspace: &std::path::Path) -> RuntimeInstanceIdentity { + RuntimeInstanceIdentity::for_workspace( + workspace, + "bitfun", + "stable", + "user-a", + PROTOCOL_VERSION, + ) + .expect("runtime identity") +} + +fn server_config() -> RuntimeIpcServerConfig { + RuntimeIpcServerConfig { + server_version: "0.2.14-test".to_string(), + idle_timeout: Duration::from_millis(80), + io_timeout: Duration::from_secs(2), + max_connections: 8, + } +} + +#[tokio::test] +async fn authenticated_client_can_read_health_and_idle_server_cleans_discovery() { + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = runtime_identity(workspace.path()); + let server = RuntimeIpcServer::bind(runtime_root.path(), identity.clone(), server_config()) + .await + .expect("bind server"); + let discovery = server.discovery_record().clone(); + let store = DiscoveryStore::new(runtime_root.path(), identity.clone()); + assert_eq!( + store.read().expect("read discovery"), + Some(discovery.clone()) + ); + + let server_task = tokio::spawn(server.serve()); + let mut client = RuntimeIpcClient::connect( + runtime_root.path(), + &discovery, + "foundation-test", + "0.1.0", + Duration::from_secs(2), + ) + .await + .expect("initialize client"); + let health = client.health().await.expect("read health"); + assert_eq!(health.instance_identity, identity.as_str()); + assert_eq!(health.process_id, std::process::id()); + drop(client); + + tokio::time::timeout(Duration::from_secs(2), server_task) + .await + .expect("server exits after idle timeout") + .expect("server task joins") + .expect("server exits cleanly"); + assert_eq!(store.read().expect("read cleaned discovery"), None); +} + +#[tokio::test] +async fn dropping_a_bound_server_cleans_its_discovery_record() { + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = runtime_identity(workspace.path()); + let server = RuntimeIpcServer::bind(runtime_root.path(), identity.clone(), server_config()) + .await + .expect("bind server"); + let store = DiscoveryStore::new(runtime_root.path(), identity); + assert!(store.read().expect("read discovery").is_some()); + + drop(server); + + assert_eq!(store.read().expect("read cleaned discovery"), None); +} + +#[tokio::test] +async fn cancelling_the_server_task_cleans_its_discovery_record() { + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = runtime_identity(workspace.path()); + let server = RuntimeIpcServer::bind(runtime_root.path(), identity.clone(), server_config()) + .await + .expect("bind server"); + let store = DiscoveryStore::new(runtime_root.path(), identity); + let server_task = tokio::spawn(server.serve()); + + server_task.abort(); + assert!(server_task + .await + .expect_err("server task is cancelled") + .is_cancelled()); + + assert_eq!(store.read().expect("read cleaned discovery"), None); +} + +#[tokio::test] +async fn handshake_rejects_bad_token_wrong_instance_and_protocol_mismatch() { + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let other_workspace = tempdir().expect("other workspace"); + let identity = runtime_identity(workspace.path()); + let server = RuntimeIpcServer::bind(runtime_root.path(), identity, server_config()) + .await + .expect("bind server"); + let endpoint = server.endpoint().clone(); + let discovery = server.discovery_record().clone(); + let server_task = tokio::spawn(server.serve()); + + let mut bad_token = discovery.clone(); + bad_token.token = "wrong-token".to_string(); + let error = RuntimeIpcClient::connect( + runtime_root.path(), + &bad_token, + "foundation-test", + "0.1.0", + Duration::from_secs(2), + ) + .await + .expect_err("bad token must fail"); + assert!(matches!( + error, + RuntimeIpcClientError::Remote(ref remote) + if remote.code == RuntimeIpcErrorCode::Unauthorized + )); + + let wrong_identity = runtime_identity(other_workspace.path()); + let mut raw = endpoint + .connect(Duration::from_secs(2)) + .await + .expect("connect raw local stream"); + write_frame( + &mut raw, + &RuntimeIpcFrame::Initialize { + request_id: 41, + request: InitializeRequest { + protocol_version: PROTOCOL_VERSION, + instance_identity: wrong_identity.as_str().to_string(), + token: discovery.token.clone(), + client_id: "foundation-test".to_string(), + client_version: "0.1.0".to_string(), + }, + }, + ) + .await + .expect("write wrong-instance initialize"); + assert!(matches!( + read_frame(&mut raw).await.expect("read wrong-instance rejection"), + RuntimeIpcFrame::Error { request_id: Some(41), error } + if error.code == RuntimeIpcErrorCode::WrongInstance + )); + drop(raw); + + let mut mismatched_discovery = discovery.clone(); + mismatched_discovery.instance_identity = wrong_identity; + assert!(matches!( + RuntimeIpcClient::connect( + runtime_root.path(), + &mismatched_discovery, + "foundation-test", + "0.1.0", + Duration::from_secs(2), + ) + .await, + Err(RuntimeIpcClientError::Transport( + RuntimeIpcTransportError::InvalidEndpoint + )) + )); + + let mut wrong_protocol = discovery; + wrong_protocol.protocol_version = PROTOCOL_VERSION + 1; + assert!(matches!( + RuntimeIpcClient::connect( + runtime_root.path(), + &wrong_protocol, + "foundation-test", + "0.1.0", + Duration::from_secs(2), + ) + .await, + Err(RuntimeIpcClientError::IncompatibleProtocol { .. }) + )); + + tokio::time::timeout(Duration::from_secs(2), server_task) + .await + .expect("server exits") + .expect("server task joins") + .expect("server exits cleanly"); +} + +#[tokio::test] +async fn first_frame_must_be_initialize() { + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = runtime_identity(workspace.path()); + let server = RuntimeIpcServer::bind(runtime_root.path(), identity, server_config()) + .await + .expect("bind server"); + let endpoint = server.endpoint().clone(); + let server_task = tokio::spawn(server.serve()); + + let mut stream = endpoint + .connect(Duration::from_secs(2)) + .await + .expect("connect raw local stream"); + write_frame( + &mut stream, + &RuntimeIpcFrame::Request { + request_id: 99, + operation: RuntimeIpcOperation::Health, + }, + ) + .await + .expect("write pre-initialize request"); + let response = read_frame(&mut stream).await.expect("read rejection"); + assert!(matches!( + response, + RuntimeIpcFrame::Error { + request_id: Some(99), + error + } if error.code == RuntimeIpcErrorCode::InvalidRequest + )); + drop(stream); + + tokio::time::timeout(Duration::from_secs(2), server_task) + .await + .expect("server exits") + .expect("server task joins") + .expect("server exits cleanly"); +} + +#[tokio::test] +async fn malformed_client_is_isolated_from_later_health_clients() { + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = runtime_identity(workspace.path()); + let mut config = server_config(); + config.idle_timeout = Duration::from_millis(250); + let server = RuntimeIpcServer::bind(runtime_root.path(), identity, config) + .await + .expect("bind server"); + let endpoint = server.endpoint().clone(); + let discovery = server.discovery_record().clone(); + let server_task = tokio::spawn(server.serve()); + + let mut malformed = endpoint + .connect(Duration::from_secs(2)) + .await + .expect("connect malformed client"); + malformed + .write_u32((MAX_FRAME_BYTES + 1) as u32) + .await + .expect("write oversized frame prefix"); + drop(malformed); + tokio::time::sleep(Duration::from_millis(20)).await; + + let mut healthy = RuntimeIpcClient::connect( + runtime_root.path(), + &discovery, + "foundation-test", + "0.1.0", + Duration::from_secs(2), + ) + .await + .expect("server remains available after malformed client"); + healthy.health().await.expect("Health remains available"); + drop(healthy); + + tokio::time::timeout(Duration::from_secs(2), server_task) + .await + .expect("server exits") + .expect("server task joins") + .expect("server exits cleanly"); +} + +#[tokio::test] +async fn connection_limit_applies_before_authentication() { + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = runtime_identity(workspace.path()); + let mut config = server_config(); + config.idle_timeout = Duration::from_millis(250); + config.max_connections = 1; + let server = RuntimeIpcServer::bind(runtime_root.path(), identity, config) + .await + .expect("bind server"); + let endpoint = server.endpoint().clone(); + let discovery = server.discovery_record().clone(); + let server_task = tokio::spawn(server.serve()); + + let blocker = endpoint + .connect(Duration::from_secs(2)) + .await + .expect("connect unauthenticated blocker"); + tokio::time::sleep(Duration::from_millis(20)).await; + assert!(matches!( + RuntimeIpcClient::connect( + runtime_root.path(), + &discovery, + "bounded-client", + "0.1.0", + Duration::from_millis(50), + ) + .await, + Err(RuntimeIpcClientError::Timeout) + )); + + drop(blocker); + let mut client = RuntimeIpcClient::connect( + runtime_root.path(), + &discovery, + "bounded-client", + "0.1.0", + Duration::from_secs(2), + ) + .await + .expect("capacity recovers after blocker disconnects"); + client + .health() + .await + .expect("Health after capacity recovery"); + drop(client); + + tokio::time::timeout(Duration::from_secs(2), server_task) + .await + .expect("server exits") + .expect("server task joins") + .expect("server exits cleanly"); +} + +#[cfg(unix)] +#[tokio::test] +async fn non_utf8_runtime_root_supports_discovery_bind_and_health() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let parent = tempdir().expect("runtime parent"); + let runtime_root = parent + .path() + .join(OsString::from_vec(b"runtime-\x80".to_vec())); + std::fs::create_dir(&runtime_root).expect("create non-UTF-8 runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = runtime_identity(workspace.path()); + let server = RuntimeIpcServer::bind(&runtime_root, identity.clone(), server_config()) + .await + .expect("bind server under non-UTF-8 runtime root"); + let discovery = server.discovery_record().clone(); + let server_task = tokio::spawn(server.serve()); + + let mut client = RuntimeIpcClient::connect( + &runtime_root, + &discovery, + "non-utf8-test", + "0.1.0", + Duration::from_secs(2), + ) + .await + .expect("initialize through lossless Unix endpoint"); + let health = client.health().await.expect("read Health"); + assert_eq!(health.instance_identity, identity.as_str()); + drop(client); + + tokio::time::timeout(Duration::from_secs(2), server_task) + .await + .expect("server exits") + .expect("server task joins") + .expect("server exits cleanly"); +} + +#[cfg(unix)] +#[tokio::test] +async fn unix_endpoint_rejects_a_runtime_root_that_exceeds_the_portable_uds_limit() { + let parent = tempdir().expect("runtime parent"); + let runtime_root = parent.path().join("r".repeat(120)); + std::fs::create_dir(&runtime_root).expect("create long runtime root"); + let workspace = tempdir().expect("workspace"); + + let error = match RuntimeIpcServer::bind( + &runtime_root, + runtime_identity(workspace.path()), + server_config(), + ) + .await + { + Ok(_) => panic!("overlong UDS path must fail before bind"), + Err(error) => error, + }; + + assert!(matches!( + error, + crate::server::RuntimeIpcServerError::Transport( + RuntimeIpcTransportError::EndpointTooLong { .. } + ) + )); +} + +#[cfg(unix)] +#[tokio::test] +async fn stale_unix_socket_is_replaced_by_the_next_locked_owner() { + use std::os::unix::net::UnixListener; + + let runtime_root = tempdir().expect("runtime root"); + let workspace = tempdir().expect("workspace"); + let identity = runtime_identity(workspace.path()); + let endpoint = crate::LocalIpcEndpoint::for_instance(runtime_root.path(), &identity) + .expect("stable endpoint"); + let stale = UnixListener::bind(endpoint.as_path()).expect("create stale socket"); + drop(stale); + assert!(endpoint.as_path().exists()); + + let server = RuntimeIpcServer::bind(runtime_root.path(), identity, server_config()) + .await + .expect("next owner replaces stale socket"); + assert_eq!(server.endpoint(), &endpoint); + drop(server); + assert!(!endpoint.as_path().exists()); +} diff --git a/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs new file mode 100644 index 0000000000..79a423a2e4 --- /dev/null +++ b/src/crates/adapters/agent-runtime-ipc/src/tests/protocol_contracts.rs @@ -0,0 +1,38 @@ +use crate::{InitializeRequest, RuntimeIpcFrame, RuntimeIpcOperation, PROTOCOL_VERSION}; + +#[test] +fn protocol_rejects_unknown_fields_and_operations() { + let unknown_field = + r#"{"type":"request","request_id":1,"operation":{"operation":"health"},"metadata":{}}"# + .to_string(); + assert!(serde_json::from_str::(&unknown_field).is_err()); + + let unknown_operation = + r#"{"type":"request","request_id":1,"operation":{"operation":"list_sessions"}}"#; + assert!(serde_json::from_str::(unknown_operation).is_err()); +} + +#[test] +fn initialize_debug_redacts_the_bearer_token() { + let request = InitializeRequest { + protocol_version: PROTOCOL_VERSION, + instance_identity: "a".repeat(64), + token: "top-secret-token".to_string(), + client_id: "foundation-test".to_string(), + client_version: "0.1.0".to_string(), + }; + + let debug = format!("{request:?}"); + assert!(!debug.contains("top-secret-token")); + assert!(debug.contains("[REDACTED]")); + + let frame = RuntimeIpcFrame::Request { + request_id: 7, + operation: RuntimeIpcOperation::Health, + }; + let json = serde_json::to_string(&frame).expect("serialize Health frame"); + assert_eq!( + json, + r#"{"type":"request","request_id":7,"operation":{"operation":"health"}}"# + ); +} diff --git a/src/crates/services/services-core/AGENTS.md b/src/crates/services/services-core/AGENTS.md index 713f10ed04..f04892dcf5 100644 --- a/src/crates/services/services-core/AGENTS.md +++ b/src/crates/services/services-core/AGENTS.md @@ -7,7 +7,7 @@ without the full product runtime. This includes generic filesystem/search/JSON IO helpers, LSP package/protocol/watch/process primitives, session metadata storage helpers, and local OS action primitives such as command lookup, clipboard, file/url opening, script execution, workspace runtime FS/shell -providers, managed process-tree lifecycle, and system facts. Product crates may layer routing, policy, +providers, managed process-tree lifecycle, process-level Agent Runtime ownership locks, and system facts. Product crates may layer routing, policy, capability selection, event emission, or legacy error mapping outside this crate. @@ -27,6 +27,9 @@ crate. managers, filesystem orchestration, or product behavior stay outside this crate. `workspace-runtime` may implement local `bitfun-runtime-ports` providers, but not workspace selection or product orchestration. +- `runtime_ownership` owns only canonical identity plus Embedded shared-lock and + Shared exclusive-lock primitives. It must not select workspaces, start or + cache Runtime instances, or define Session/Turn ownership. - Do not add remote SSH, MiniApp storage, tool-result persistence, `PathManager` globals, or product runtime bindings to `filesystem`; keep those in core or a reviewed adapter/provider. @@ -45,6 +48,7 @@ crate. ```bash cargo test -p bitfun-services-core --features lsp cargo test -p bitfun-services-core --features workspace-runtime workspace +cargo test -p bitfun-services-core --features runtime-ownership --test runtime_ownership_contracts node scripts/check-core-boundaries.mjs cargo check -p bitfun-core --features product-full ``` diff --git a/src/crates/services/services-core/Cargo.toml b/src/crates/services/services-core/Cargo.toml index 0369d14475..f0fbba31c3 100644 --- a/src/crates/services/services-core/Cargo.toml +++ b/src/crates/services/services-core/Cargo.toml @@ -51,6 +51,7 @@ default = ["lsp"] lsp = ["dep:anyhow", "dep:notify", "dep:zip"] markdown = ["dep:serde_yaml"] workspace-runtime = ["dep:anyhow", "dep:async-trait", "dep:bitfun-runtime-ports", "dep:dunce"] +runtime-ownership = ["dep:dunce"] permission = ["dep:async-trait", "dep:bitfun-runtime-ports", "dep:rusqlite", "bitfun-runtime-ports/permission"] [dev-dependencies] @@ -65,5 +66,9 @@ required-features = ["markdown"] name = "lsp_plugin_registry_contracts" required-features = ["lsp"] +[[test]] +name = "runtime_ownership_contracts" +required-features = ["runtime-ownership"] + [lints] workspace = true diff --git a/src/crates/services/services-core/src/lib.rs b/src/crates/services/services-core/src/lib.rs index 757cd92dd8..3853cd327c 100644 --- a/src/crates/services/services-core/src/lib.rs +++ b/src/crates/services/services-core/src/lib.rs @@ -19,6 +19,8 @@ pub mod permission_store; pub mod persistence; pub mod process_manager; pub mod process_tree; +#[cfg(feature = "runtime-ownership")] +pub mod runtime_ownership; pub mod session; pub mod session_usage; pub mod storage_cleanup; diff --git a/src/crates/services/services-core/src/runtime_ownership.rs b/src/crates/services/services-core/src/runtime_ownership.rs new file mode 100644 index 0000000000..597618ce40 --- /dev/null +++ b/src/crates/services/services-core/src/runtime_ownership.rs @@ -0,0 +1,181 @@ +//! Process-level ownership primitives for local Agent Runtime deployments. +//! +//! This module does not select a workspace or start an Agent Runtime. Product +//! assembly may use the shared/exclusive lock to prevent an embedded runtime +//! and a future shared runtime from owning the same workspace simultaneously. + +use fs2::FileExt; +use sha2::{Digest, Sha256}; +use std::fmt::{self, Write as _}; +use std::fs::{File, OpenOptions}; +use std::path::{Path, PathBuf}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RuntimeDeployment { + Embedded, + Shared, +} + +impl RuntimeDeployment { + fn as_str(self) -> &'static str { + match self { + Self::Embedded => "embedded", + Self::Shared => "shared", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RuntimeOwnershipKey(String); + +impl RuntimeOwnershipKey { + pub fn for_workspace( + workspace_root: &Path, + product_identity: &str, + ) -> Result { + validate_product_identity(product_identity)?; + let canonical_workspace = dunce::canonicalize(workspace_root).map_err(|source| { + RuntimeOwnershipError::CanonicalizeWorkspace { + path: workspace_root.to_path_buf(), + source, + } + })?; + + let mut hasher = Sha256::new(); + hasher.update(b"bitfun-runtime-ownership-v2\0"); + hasher.update(product_identity.as_bytes()); + hasher.update(b"\0"); + hash_canonical_path(&mut hasher, &canonical_workspace); + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + write!(&mut encoded, "{byte:02x}").expect("writing to String cannot fail"); + } + Ok(Self(encoded)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + fn lock_path(&self, ownership_root: &Path) -> PathBuf { + ownership_root.join(format!("{}.lock", self.0)) + } +} + +pub struct WorkspaceRuntimeOwnership { + deployment: RuntimeDeployment, + file: File, +} + +impl WorkspaceRuntimeOwnership { + pub fn try_acquire( + ownership_root: &Path, + key: &RuntimeOwnershipKey, + deployment: RuntimeDeployment, + ) -> Result { + std::fs::create_dir_all(ownership_root).map_err(|source| { + RuntimeOwnershipError::CreateOwnershipDirectory { + path: ownership_root.to_path_buf(), + source, + } + })?; + let path = key.lock_path(ownership_root); + let file = OpenOptions::new() + .create(true) + .truncate(false) + .read(true) + .write(true) + .open(&path) + .map_err(|source| RuntimeOwnershipError::OpenLockFile { + path: path.clone(), + source, + })?; + + let lock_result = match deployment { + RuntimeDeployment::Embedded => FileExt::try_lock_shared(&file), + RuntimeDeployment::Shared => FileExt::try_lock_exclusive(&file), + }; + lock_result + .map_err(|source| RuntimeOwnershipError::OwnershipUnavailable { deployment, source })?; + + Ok(Self { deployment, file }) + } + + pub fn deployment(&self) -> RuntimeDeployment { + self.deployment + } +} + +impl Drop for WorkspaceRuntimeOwnership { + fn drop(&mut self) { + let _ = FileExt::unlock(&self.file); + } +} + +impl fmt::Debug for WorkspaceRuntimeOwnership { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter + .debug_struct("WorkspaceRuntimeOwnership") + .field("deployment", &self.deployment) + .finish_non_exhaustive() + } +} + +#[derive(Debug, thiserror::Error)] +pub enum RuntimeOwnershipError { + #[error("product identity must be non-empty, bounded, and contain no control characters")] + InvalidProductIdentity, + #[error("failed to canonicalize runtime workspace {path}")] + CanonicalizeWorkspace { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to create runtime ownership directory {path}")] + CreateOwnershipDirectory { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("failed to open runtime ownership lock {path}")] + OpenLockFile { + path: PathBuf, + #[source] + source: std::io::Error, + }, + #[error("{deployment} runtime ownership is unavailable")] + OwnershipUnavailable { + deployment: RuntimeDeployment, + #[source] + source: std::io::Error, + }, +} + +impl fmt::Display for RuntimeDeployment { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(self.as_str()) + } +} + +fn validate_product_identity(value: &str) -> Result<(), RuntimeOwnershipError> { + if value.is_empty() || value.len() > 128 || value.chars().any(char::is_control) { + return Err(RuntimeOwnershipError::InvalidProductIdentity); + } + Ok(()) +} + +fn hash_canonical_path(hasher: &mut Sha256, path: &Path) { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + hasher.update(path.as_os_str().as_bytes()); + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + for unit in path.as_os_str().encode_wide() { + hasher.update(unit.to_le_bytes()); + } + } +} diff --git a/src/crates/services/services-core/tests/runtime_ownership_contracts.rs b/src/crates/services/services-core/tests/runtime_ownership_contracts.rs new file mode 100644 index 0000000000..e3cffd27b4 --- /dev/null +++ b/src/crates/services/services-core/tests/runtime_ownership_contracts.rs @@ -0,0 +1,98 @@ +use bitfun_services_core::runtime_ownership::{ + RuntimeDeployment, RuntimeOwnershipKey, WorkspaceRuntimeOwnership, +}; +use tempfile::tempdir; + +#[test] +fn ownership_key_is_stable_and_scoped_by_workspace_and_product() { + let first_workspace = tempdir().expect("first workspace"); + let second_workspace = tempdir().expect("second workspace"); + + let first = RuntimeOwnershipKey::for_workspace(first_workspace.path(), "bitfun") + .expect("create first ownership key"); + let same = RuntimeOwnershipKey::for_workspace(&first_workspace.path().join("."), "bitfun") + .expect("normalize same workspace"); + let other_workspace = RuntimeOwnershipKey::for_workspace(second_workspace.path(), "bitfun") + .expect("create second ownership key"); + let other_product = + RuntimeOwnershipKey::for_workspace(first_workspace.path(), "bitfun-preview") + .expect("create product-scoped ownership key"); + + assert_eq!(first, same); + assert_ne!(first, other_workspace); + assert_ne!(first, other_product); + assert_eq!(first.as_str().len(), 64); + assert!(first.as_str().bytes().all(|byte| byte.is_ascii_hexdigit())); +} + +#[cfg(unix)] +#[test] +fn ownership_key_preserves_non_utf8_workspace_bytes() { + use std::ffi::OsString; + use std::os::unix::ffi::OsStringExt; + + let root = tempdir().expect("workspace root"); + let first_path = root.path().join(OsString::from_vec(vec![b'w', 0x80])); + let second_path = root.path().join(OsString::from_vec(vec![b'w', 0x81])); + std::fs::create_dir(&first_path).expect("first non-UTF-8 workspace"); + std::fs::create_dir(&second_path).expect("second non-UTF-8 workspace"); + + let first = + RuntimeOwnershipKey::for_workspace(&first_path, "bitfun").expect("first ownership key"); + let second = + RuntimeOwnershipKey::for_workspace(&second_path, "bitfun").expect("second ownership key"); + + assert_ne!(first, second); +} + +#[test] +fn embedded_owners_can_coexist_but_shared_ownership_is_exclusive() { + let workspace = tempdir().expect("workspace"); + let ownership_root = tempdir().expect("ownership root"); + let key = RuntimeOwnershipKey::for_workspace(workspace.path(), "bitfun") + .expect("create ownership key"); + + let embedded_one = WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &key, + RuntimeDeployment::Embedded, + ) + .expect("first embedded owner"); + let embedded_two = WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &key, + RuntimeDeployment::Embedded, + ) + .expect("second embedded owner"); + + assert!(WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &key, + RuntimeDeployment::Shared, + ) + .is_err()); + + drop(embedded_one); + drop(embedded_two); + + let shared = WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &key, + RuntimeDeployment::Shared, + ) + .expect("shared owner after embedded owners release"); + assert!(WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &key, + RuntimeDeployment::Embedded, + ) + .is_err()); + drop(shared); + + WorkspaceRuntimeOwnership::try_acquire( + ownership_root.path(), + &key, + RuntimeDeployment::Embedded, + ) + .expect("ownership released by RAII drop"); +}